add nova
This commit is contained in:
129
nova/resources/js/components/Dropdowns/ActionDropdown.vue
Normal file
129
nova/resources/js/components/Dropdowns/ActionDropdown.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Confirm Action Modal -->
|
||||
<component
|
||||
v-if="actionModalVisible"
|
||||
:show="actionModalVisible"
|
||||
class="text-left"
|
||||
:is="selectedAction?.component"
|
||||
:working="working"
|
||||
:selected-resources="selectedResources"
|
||||
:resource-name="resourceName"
|
||||
:action="selectedAction"
|
||||
:errors="errors"
|
||||
@confirm="runAction"
|
||||
@close="closeConfirmationModal"
|
||||
/>
|
||||
|
||||
<component
|
||||
v-if="responseModalVisible"
|
||||
:show="responseModalVisible"
|
||||
:is="actionResponseData?.modal"
|
||||
@confirm="handleResponseModalConfirm"
|
||||
@close="handleResponseModalClose"
|
||||
:data="actionResponseData"
|
||||
/>
|
||||
|
||||
<Dropdown>
|
||||
<template #default>
|
||||
<slot name="trigger">
|
||||
<Button
|
||||
@click.stop
|
||||
:dusk="triggerDuskAttribute"
|
||||
variant="ghost"
|
||||
icon="ellipsis-horizontal"
|
||||
v-tooltip="__('Actions')"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<template #menu>
|
||||
<DropdownMenu width="auto">
|
||||
<ScrollWrap :height="250">
|
||||
<nav
|
||||
class="px-1 divide-y divide-gray-100 dark:divide-gray-800 divide-solid"
|
||||
>
|
||||
<slot name="menu" />
|
||||
|
||||
<div v-if="actions.length > 0">
|
||||
<DropdownMenuHeading v-if="showHeadings">{{
|
||||
__('User Actions')
|
||||
}}</DropdownMenuHeading>
|
||||
|
||||
<div class="py-1">
|
||||
<DropdownMenuItem
|
||||
v-for="action in actions"
|
||||
:key="action.uriKey"
|
||||
:data-action-id="action.uriKey"
|
||||
as="button"
|
||||
class="border-none"
|
||||
@click="() => handleClick(action)"
|
||||
:title="action.name"
|
||||
:disabled="action.authorizedToRun === false"
|
||||
>
|
||||
{{ action.name }}
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</ScrollWrap>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useActions } from '@/composables/useActions'
|
||||
import { useStore } from 'vuex'
|
||||
const store = useStore()
|
||||
import { Button } from 'laravel-nova-ui'
|
||||
import DropdownMenuHeading from './DropdownMenuHeading.vue'
|
||||
|
||||
const emitter = defineEmits(['actionExecuted'])
|
||||
|
||||
const props = defineProps({
|
||||
resourceName: {},
|
||||
viaResource: {},
|
||||
viaResourceId: {},
|
||||
viaRelationship: {},
|
||||
relationshipType: {},
|
||||
actions: { type: Array, default: [] },
|
||||
selectedResources: { type: [Array, String], default: () => [] },
|
||||
endpoint: { type: String, default: null },
|
||||
triggerDuskAttribute: { type: String, default: null },
|
||||
showHeadings: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const {
|
||||
errors,
|
||||
actionModalVisible,
|
||||
responseModalVisible,
|
||||
openConfirmationModal,
|
||||
closeConfirmationModal,
|
||||
closeResponseModal,
|
||||
handleActionClick,
|
||||
selectedAction,
|
||||
working,
|
||||
executeAction,
|
||||
actionResponseData,
|
||||
} = useActions(props, emitter, store)
|
||||
|
||||
const runAction = () => executeAction(() => emitter('actionExecuted'))
|
||||
|
||||
const handleClick = action => {
|
||||
if (action.authorizedToRun !== false) {
|
||||
handleActionClick(action.uriKey)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResponseModalConfirm = () => {
|
||||
closeResponseModal()
|
||||
emitter('actionExecuted')
|
||||
}
|
||||
|
||||
const handleResponseModalClose = () => {
|
||||
closeResponseModal()
|
||||
emitter('actionExecuted')
|
||||
}
|
||||
</script>
|
||||
255
nova/resources/js/components/Dropdowns/DetailActionDropdown.vue
Normal file
255
nova/resources/js/components/Dropdowns/DetailActionDropdown.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<ActionDropdown
|
||||
v-if="resource"
|
||||
:resource="resource"
|
||||
:actions="actions"
|
||||
:via-resource="viaResource"
|
||||
:via-resource-id="viaResourceId"
|
||||
:via-relationship="viaRelationship"
|
||||
:resource-name="resourceName"
|
||||
@actionExecuted="$emit('actionExecuted')"
|
||||
:selected-resources="[resource.id.value]"
|
||||
:trigger-dusk-attribute="`${resource.id.value}-control-selector`"
|
||||
:show-headings="true"
|
||||
>
|
||||
<template #menu>
|
||||
<div
|
||||
v-if="
|
||||
resource.authorizedToReplicate ||
|
||||
(currentUser.canImpersonate && resource.authorizedToImpersonate) ||
|
||||
(resource.authorizedToDelete && !resource.softDeleted) ||
|
||||
(resource.authorizedToRestore && resource.softDeleted) ||
|
||||
resource.authorizedToForceDelete
|
||||
"
|
||||
>
|
||||
<DropdownMenuHeading>{{ __('Actions') }}</DropdownMenuHeading>
|
||||
<div class="py-1">
|
||||
<!-- Replicate Resource Link -->
|
||||
<DropdownMenuItem
|
||||
v-if="resource.authorizedToReplicate"
|
||||
:dusk="`${resource.id.value}-replicate-button`"
|
||||
:href="
|
||||
$url(
|
||||
`/resources/${resourceName}/${resource.id.value}/replicate`,
|
||||
{
|
||||
viaResource,
|
||||
viaResourceId,
|
||||
viaRelationship,
|
||||
}
|
||||
)
|
||||
"
|
||||
:title="__('Replicate')"
|
||||
>
|
||||
{{ __('Replicate') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Impersonate Resource Button -->
|
||||
<DropdownMenuItem
|
||||
as="button"
|
||||
v-if="
|
||||
currentUser.canImpersonate && resource.authorizedToImpersonate
|
||||
"
|
||||
:dusk="`${resource.id.value}-impersonate-button`"
|
||||
@click.prevent="
|
||||
startImpersonating({
|
||||
resource: resourceName,
|
||||
resourceId: resource.id.value,
|
||||
})
|
||||
"
|
||||
:title="__('Impersonate')"
|
||||
>
|
||||
{{ __('Impersonate') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
v-if="resource.authorizedToDelete && !resource.softDeleted"
|
||||
dusk="open-delete-modal-button"
|
||||
@click.prevent="openDeleteModal"
|
||||
>
|
||||
{{ __('Delete Resource') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
as="button"
|
||||
v-if="resource.authorizedToRestore && resource.softDeleted"
|
||||
dusk="open-restore-modal-button"
|
||||
@click.prevent="openRestoreModal"
|
||||
>
|
||||
{{ __('Restore Resource') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
as="button"
|
||||
v-if="resource.authorizedToForceDelete"
|
||||
dusk="open-force-delete-modal-button"
|
||||
@click.prevent="openForceDeleteModal"
|
||||
>
|
||||
{{ __('Force Delete Resource') }}
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ActionDropdown>
|
||||
|
||||
<DeleteResourceModal
|
||||
:show="deleteModalOpen"
|
||||
mode="delete"
|
||||
@close="closeDeleteModal"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
|
||||
<RestoreResourceModal
|
||||
:show="restoreModalOpen"
|
||||
@close="closeRestoreModal"
|
||||
@confirm="confirmRestore"
|
||||
/>
|
||||
|
||||
<DeleteResourceModal
|
||||
:show="forceDeleteModalOpen"
|
||||
mode="force delete"
|
||||
@close="closeForceDeleteModal"
|
||||
@confirm="confirmForceDelete"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Deletable, InteractsWithResourceInformation, mapProps } from '@/mixins'
|
||||
import { mapGetters, mapActions } from 'vuex'
|
||||
|
||||
export default {
|
||||
emits: ['actionExecuted', 'resource-deleted', 'resource-restored'],
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
mixins: [Deletable, InteractsWithResourceInformation],
|
||||
|
||||
props: {
|
||||
resource: { type: Object },
|
||||
actions: { type: Array },
|
||||
viaManyToMany: { type: Boolean },
|
||||
|
||||
...mapProps([
|
||||
'resourceName',
|
||||
'viaResource',
|
||||
'viaResourceId',
|
||||
'viaRelationship',
|
||||
]),
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
deleteModalOpen: false,
|
||||
restoreModalOpen: false,
|
||||
forceDeleteModalOpen: false,
|
||||
}),
|
||||
|
||||
methods: {
|
||||
...mapActions(['startImpersonating']),
|
||||
|
||||
/**
|
||||
* Show the confirmation modal for deleting or detaching a resource
|
||||
*/
|
||||
async confirmDelete() {
|
||||
this.deleteResources([this.resource], response => {
|
||||
Nova.success(
|
||||
this.__('The :resource was deleted!', {
|
||||
resource: this.resourceInformation.singularLabel.toLowerCase(),
|
||||
})
|
||||
)
|
||||
|
||||
if (response && response.data && response.data.redirect) {
|
||||
Nova.visit(response.data.redirect)
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.resource.softDeletes) {
|
||||
Nova.visit(`/resources/${this.resourceName}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.closeDeleteModal()
|
||||
this.$emit('resource-deleted')
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the delete modal
|
||||
*/
|
||||
openDeleteModal() {
|
||||
this.deleteModalOpen = true
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the delete modal
|
||||
*/
|
||||
closeDeleteModal() {
|
||||
this.deleteModalOpen = false
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the confirmation modal for restoring a resource
|
||||
*/
|
||||
async confirmRestore() {
|
||||
this.restoreResources([this.resource], () => {
|
||||
Nova.success(
|
||||
this.__('The :resource was restored!', {
|
||||
resource: this.resourceInformation.singularLabel.toLowerCase(),
|
||||
})
|
||||
)
|
||||
|
||||
this.closeRestoreModal()
|
||||
this.$emit('resource-restored')
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the restore modal
|
||||
*/
|
||||
openRestoreModal() {
|
||||
this.restoreModalOpen = true
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the restore modal
|
||||
*/
|
||||
closeRestoreModal() {
|
||||
this.restoreModalOpen = false
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the confirmation modal for force deleting
|
||||
*/
|
||||
async confirmForceDelete() {
|
||||
this.forceDeleteResources([this.resource], response => {
|
||||
Nova.success(
|
||||
this.__('The :resource was deleted!', {
|
||||
resource: this.resourceInformation.singularLabel.toLowerCase(),
|
||||
})
|
||||
)
|
||||
|
||||
if (response && response.data && response.data.redirect) {
|
||||
Nova.visit(response.data.redirect)
|
||||
return
|
||||
}
|
||||
|
||||
Nova.visit(`/resources/${this.resourceName}`)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the force delete modal
|
||||
*/
|
||||
openForceDeleteModal() {
|
||||
this.forceDeleteModalOpen = true
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the force delete modal
|
||||
*/
|
||||
closeForceDeleteModal() {
|
||||
this.forceDeleteModalOpen = false
|
||||
},
|
||||
},
|
||||
|
||||
computed: mapGetters(['currentUser']),
|
||||
}
|
||||
</script>
|
||||
205
nova/resources/js/components/Dropdowns/Dropdown.vue
Normal file
205
nova/resources/js/components/Dropdowns/Dropdown.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<script>
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
size,
|
||||
useFloating,
|
||||
} from '@floating-ui/vue'
|
||||
import {
|
||||
cloneVNode,
|
||||
computed,
|
||||
h,
|
||||
mergeProps,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
Teleport,
|
||||
Transition,
|
||||
watch,
|
||||
withModifiers,
|
||||
} from 'vue'
|
||||
import { useId } from '../../composables/useId'
|
||||
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
||||
import { renderSlotFragments } from '../../util/renderSlotFragments'
|
||||
import { useCloseOnEsc } from '../../composables/useCloseOnEsc'
|
||||
|
||||
export default {
|
||||
emits: ['menu-opened', 'menu-closed'],
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
offset: { type: [Number, String], default: 5 },
|
||||
placement: { type: String, default: 'bottom-start' },
|
||||
boundary: { type: String, default: 'viewPort' },
|
||||
dusk: { type: String, default: null },
|
||||
shouldCloseOnBlur: { type: Boolean, default: true },
|
||||
},
|
||||
|
||||
setup(props, { slots }) {
|
||||
const menuShown = ref(false)
|
||||
const triggerRef = ref(null)
|
||||
const teleportedRef = ref(null)
|
||||
const menuRef = ref(null)
|
||||
|
||||
const { activate, deactivate } = useFocusTrap(menuRef, {
|
||||
initialFocus: false,
|
||||
allowOutsideClick: true,
|
||||
})
|
||||
|
||||
const usesFocusTrap = ref(true)
|
||||
|
||||
const hasTrapFocus = computed(() => {
|
||||
return menuShown.value === true && usesFocusTrap.value === true
|
||||
})
|
||||
|
||||
const disableModalFocusTrap = () => {
|
||||
usesFocusTrap.value = false
|
||||
}
|
||||
|
||||
const enableModalFocusTrap = () => {
|
||||
usesFocusTrap.value = true
|
||||
}
|
||||
|
||||
useCloseOnEsc(() => (menuShown.value = false))
|
||||
|
||||
const dropdownButtonLabel = computed(
|
||||
() => `nova-ui-dropdown-button-${useId()}`
|
||||
)
|
||||
const menuLabel = computed(() => `nova-ui-dropdown-menu-${useId()}`)
|
||||
|
||||
const resolvedPlacement = computed(() => {
|
||||
if (!Nova.config('rtlEnabled')) {
|
||||
return props.placement
|
||||
}
|
||||
|
||||
return {
|
||||
'auto-start': 'auto-end',
|
||||
'auto-end': 'auto-start',
|
||||
'top-start': 'top-end',
|
||||
'top-end': 'top-start',
|
||||
'bottom-start': 'bottom-end',
|
||||
'bottom-end': 'bottom-start',
|
||||
'right-start': 'right-end',
|
||||
'right-end': 'right-start',
|
||||
'left-start': 'left-end',
|
||||
'left-end': 'left-start',
|
||||
}[props.placement]
|
||||
})
|
||||
|
||||
const { floatingStyles } = useFloating(triggerRef, menuRef, {
|
||||
whileElementsMounted: autoUpdate,
|
||||
placement: resolvedPlacement.value,
|
||||
middleware: [offset(props.offset), flip(), shift({ padding: 5 }), size()],
|
||||
})
|
||||
|
||||
watch(
|
||||
() => hasTrapFocus,
|
||||
async v => {
|
||||
await nextTick()
|
||||
v ? activate() : deactivate()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
Nova.$on('disable-focus-trap', disableModalFocusTrap)
|
||||
Nova.$on('enable-focus-trap', enableModalFocusTrap)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
Nova.$off('disable-focus-trap', disableModalFocusTrap)
|
||||
Nova.$off('enable-focus-trap', enableModalFocusTrap)
|
||||
|
||||
usesFocusTrap.value = false
|
||||
})
|
||||
|
||||
return () => {
|
||||
const children = renderSlotFragments(slots.default())
|
||||
const [trigger, ...otherChildren] = children
|
||||
|
||||
const mergedProps = mergeProps({
|
||||
...trigger.props,
|
||||
...{
|
||||
id: dropdownButtonLabel.value,
|
||||
'aria-expanded': menuShown.value === true ? 'true' : 'false',
|
||||
'aria-haspopup': 'true',
|
||||
'aria-controls': menuLabel.value,
|
||||
onClick: withModifiers(() => {
|
||||
menuShown.value = !menuShown.value
|
||||
}, ['stop']),
|
||||
},
|
||||
})
|
||||
|
||||
const cloned = cloneVNode(trigger, mergedProps)
|
||||
|
||||
// Explicitly override props starting with `on`.
|
||||
// It seems cloneVNode from Vue doesn't like overriding `onXXX` props. So
|
||||
// we have to do it manually.
|
||||
for (const prop in mergedProps) {
|
||||
if (prop.startsWith('on')) {
|
||||
cloned.props ||= {}
|
||||
cloned.props[prop] = mergedProps[prop]
|
||||
}
|
||||
}
|
||||
|
||||
return h('div', { dusk: props.dusk }, [
|
||||
h('span', { ref: triggerRef }, cloned),
|
||||
h(
|
||||
Teleport,
|
||||
{ to: 'body' },
|
||||
h(
|
||||
Transition,
|
||||
{
|
||||
enterActiveClass: 'transition duration-0 ease-out',
|
||||
enterFromClass: 'opacity-0',
|
||||
enterToClass: 'opacity-100',
|
||||
leaveActiveClass: 'transition duration-300 ease-in',
|
||||
leaveFromClass: 'opacity-100',
|
||||
leaveToClass: 'opacity-0',
|
||||
},
|
||||
() => [
|
||||
menuShown.value
|
||||
? h(
|
||||
'div',
|
||||
{
|
||||
ref: teleportedRef,
|
||||
dusk: 'dropdown-teleported',
|
||||
},
|
||||
[
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
ref: menuRef,
|
||||
id: menuLabel.value,
|
||||
'aria-labelledby': dropdownButtonLabel.value,
|
||||
tabindex: '0',
|
||||
class: 'relative z-[70]',
|
||||
style: floatingStyles.value,
|
||||
'data-menu-open': menuShown.value,
|
||||
dusk: 'dropdown-menu',
|
||||
onClick: () =>
|
||||
props.shouldCloseOnBlur
|
||||
? (menuShown.value = false)
|
||||
: null,
|
||||
},
|
||||
slots.menu()
|
||||
),
|
||||
h('div', {
|
||||
class: 'z-[69] fixed inset-0',
|
||||
dusk: 'dropdown-overlay',
|
||||
onClick: () => (menuShown.value = false),
|
||||
}),
|
||||
]
|
||||
)
|
||||
: null,
|
||||
]
|
||||
)
|
||||
),
|
||||
])
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
27
nova/resources/js/components/Dropdowns/DropdownMenu.vue
Normal file
27
nova/resources/js/components/Dropdowns/DropdownMenu.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div
|
||||
:style="styles"
|
||||
class="select-none overflow-hidden bg-white dark:bg-gray-900 shadow-lg rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
:class="{ 'max-w-sm lg:max-w-lg': width === 'auto' }"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
width: {
|
||||
default: 120,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
styles() {
|
||||
return {
|
||||
width: this.width === 'auto' ? 'auto' : `${this.width}px`,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<h3 class="mt-3 px-3 text-xs font-bold">
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
58
nova/resources/js/components/Dropdowns/DropdownMenuItem.vue
Normal file
58
nova/resources/js/components/Dropdowns/DropdownMenuItem.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<component
|
||||
:is="component"
|
||||
v-bind="defaultAttributes"
|
||||
class="block w-full text-left px-3 focus:outline-none rounded truncate whitespace-nowrap"
|
||||
:class="{
|
||||
'text-sm py-1.5': size === 'small',
|
||||
'text-sm py-2': size === 'large',
|
||||
'hover:bg-gray-50 dark:hover:bg-gray-800 focus:ring cursor-pointer':
|
||||
!disabled,
|
||||
'text-gray-400 dark:text-gray-700 cursor-default': disabled,
|
||||
'text-gray-500 active:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400 dark:active:text-gray-600':
|
||||
!disabled,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
as: {
|
||||
type: String,
|
||||
default: 'external',
|
||||
validator: v => ['button', 'external', 'form-button', 'link'].includes(v),
|
||||
},
|
||||
disabled: { type: Boolean, default: false },
|
||||
size: {
|
||||
type: String,
|
||||
default: 'small',
|
||||
validator: v => ['small', 'large'].includes(v),
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
component() {
|
||||
return {
|
||||
button: 'button',
|
||||
external: 'a',
|
||||
link: 'Link',
|
||||
'form-button': 'FormButton',
|
||||
}[this.as]
|
||||
},
|
||||
|
||||
defaultAttributes() {
|
||||
return {
|
||||
...this.$attrs,
|
||||
...{
|
||||
disabled:
|
||||
this.as === 'button' && this.disabled === true ? true : null,
|
||||
type: this.as === 'button' ? 'button' : null,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
113
nova/resources/js/components/Dropdowns/InlineActionDropdown.vue
Normal file
113
nova/resources/js/components/Dropdowns/InlineActionDropdown.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<ActionDropdown
|
||||
:resource="resource"
|
||||
:actions="actions"
|
||||
:via-resource="viaResource"
|
||||
:via-resource-id="viaResourceId"
|
||||
:via-relationship="viaRelationship"
|
||||
:resource-name="resourceName"
|
||||
@actionExecuted="$emit('actionExecuted')"
|
||||
:selected-resources="[resource.id.value]"
|
||||
:show-headings="true"
|
||||
>
|
||||
<template #trigger>
|
||||
<Button
|
||||
variant="action"
|
||||
icon="ellipsis-horizontal"
|
||||
:dusk="`${resource.id.value}-control-selector`"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #menu>
|
||||
<div
|
||||
v-if="
|
||||
(resource.authorizedToView && resource.previewHasFields) ||
|
||||
resource.authorizedToReplicate ||
|
||||
(currentUser.canImpersonate && resource.authorizedToImpersonate)
|
||||
"
|
||||
>
|
||||
<DropdownMenuHeading>{{ __('Actions') }}</DropdownMenuHeading>
|
||||
<div class="py-1">
|
||||
<!-- Preview Resource Link -->
|
||||
<DropdownMenuItem
|
||||
v-if="resource.authorizedToView && resource.previewHasFields"
|
||||
:dusk="`${resource.id.value}-preview-button`"
|
||||
as="button"
|
||||
@click.prevent="$emit('show-preview')"
|
||||
:title="__('Preview')"
|
||||
>
|
||||
{{ __('Preview') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Replicate Resource Link -->
|
||||
<DropdownMenuItem
|
||||
v-if="resource.authorizedToReplicate"
|
||||
:dusk="`${resource.id.value}-replicate-button`"
|
||||
:href="
|
||||
$url(
|
||||
`/resources/${resourceName}/${resource.id.value}/replicate`,
|
||||
{
|
||||
viaResource,
|
||||
viaResourceId,
|
||||
viaRelationship,
|
||||
}
|
||||
)
|
||||
"
|
||||
:title="__('Replicate')"
|
||||
>
|
||||
{{ __('Replicate') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Impersonate Resource Button -->
|
||||
<DropdownMenuItem
|
||||
as="button"
|
||||
v-if="
|
||||
currentUser.canImpersonate && resource.authorizedToImpersonate
|
||||
"
|
||||
:dusk="`${resource.id.value}-impersonate-button`"
|
||||
@click.prevent="
|
||||
startImpersonating({
|
||||
resource: resourceName,
|
||||
resourceId: resource.id.value,
|
||||
})
|
||||
"
|
||||
:title="__('Impersonate')"
|
||||
>
|
||||
{{ __('Impersonate') }}
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ActionDropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapProps } from '@/mixins'
|
||||
import { mapGetters, mapActions } from 'vuex'
|
||||
import { Button } from 'laravel-nova-ui'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Button,
|
||||
},
|
||||
|
||||
emits: ['actionExecuted', 'show-preview'],
|
||||
|
||||
props: {
|
||||
resource: { type: Object },
|
||||
actions: { type: Array },
|
||||
viaManyToMany: { type: Boolean },
|
||||
|
||||
...mapProps([
|
||||
'resourceName',
|
||||
'viaResource',
|
||||
'viaResourceId',
|
||||
'viaRelationship',
|
||||
]),
|
||||
},
|
||||
|
||||
methods: mapActions(['startImpersonating']),
|
||||
|
||||
computed: mapGetters(['currentUser']),
|
||||
}
|
||||
</script>
|
||||
110
nova/resources/js/components/Dropdowns/SelectAllDropdown.vue
Normal file
110
nova/resources/js/components/Dropdowns/SelectAllDropdown.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<Dropdown placement="bottom-start" dusk="select-all-dropdown">
|
||||
<Button
|
||||
variant="ghost"
|
||||
trailing-icon="chevron-down"
|
||||
class="-ml-1"
|
||||
:class="{
|
||||
'enabled:bg-gray-700/5 dark:enabled:bg-gray-950':
|
||||
selectAllOrSelectAllMatchingChecked || selectedResourcesCount > 0,
|
||||
}"
|
||||
dusk="select-all-dropdown-trigger"
|
||||
>
|
||||
<Checkbox
|
||||
:aria-label="__('Select this page')"
|
||||
:indeterminate="selectAllIndeterminate"
|
||||
:model-value="selectAllAndSelectAllMatchingChecked"
|
||||
class="pointer-events-none"
|
||||
dusk="select-all-indicator"
|
||||
tabindex="-1"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref="selectedStatus"
|
||||
v-if="selectedResourcesCount > 0"
|
||||
class="rounded-lg h-9 inline-flex items-center text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1 pl-1">
|
||||
<span class="font-bold">{{
|
||||
__(':amount selected', {
|
||||
amount: selectAllMatchingChecked
|
||||
? allMatchingResourceCount
|
||||
: selectedResourcesCount,
|
||||
label: singularOrPlural(selectedResourcesCount, 'resources'),
|
||||
})
|
||||
}}</span>
|
||||
</span>
|
||||
<Button
|
||||
@click.stop="$emit('deselect')"
|
||||
variant="link"
|
||||
icon="x-circle"
|
||||
size="small"
|
||||
state="mellow"
|
||||
class="-mr-2"
|
||||
:aria-label="__('Deselect All')"
|
||||
dusk="deselect-all-button"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<template #menu>
|
||||
<DropdownMenu direction="ltr" width="250">
|
||||
<div class="p-4 flex flex-col items-start gap-4">
|
||||
<!-- @click="$emit('toggle-select-all')"-->
|
||||
<!-- @keydown.space.stop="$emit('toggle-select-all')"-->
|
||||
<Checkbox
|
||||
@change="$emit('toggle-select-all')"
|
||||
:model-value="selectAllChecked"
|
||||
dusk="select-all-button"
|
||||
>
|
||||
<span>
|
||||
{{ __('Select this page') }}
|
||||
</span>
|
||||
<CircleBadge>
|
||||
{{ currentPageCount }}
|
||||
</CircleBadge>
|
||||
</Checkbox>
|
||||
|
||||
<Checkbox
|
||||
@change="$emit('toggle-select-all-matching')"
|
||||
:model-value="selectAllMatchingChecked"
|
||||
dusk="select-all-matching-button"
|
||||
>
|
||||
<span>
|
||||
<span>
|
||||
{{ __('Select all') }}
|
||||
</span>
|
||||
<CircleBadge dusk="select-all-matching-count">
|
||||
{{ allMatchingResourceCount }}
|
||||
</CircleBadge>
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import { singularOrPlural } from '@/util'
|
||||
import { Checkbox, Button } from 'laravel-nova-ui'
|
||||
|
||||
defineEmits(['toggle-select-all', 'toggle-select-all-matching', 'deselect'])
|
||||
|
||||
const selectedResourcesCount = inject('selectedResourcesCount')
|
||||
const selectAllChecked = inject('selectAllChecked')
|
||||
const selectAllMatchingChecked = inject('selectAllMatchingChecked')
|
||||
const selectAllAndSelectAllMatchingChecked = inject(
|
||||
'selectAllAndSelectAllMatchingChecked'
|
||||
)
|
||||
const selectAllOrSelectAllMatchingChecked = inject(
|
||||
'selectAllOrSelectAllMatchingChecked'
|
||||
)
|
||||
const selectAllIndeterminate = inject('selectAllIndeterminate')
|
||||
|
||||
defineProps({
|
||||
currentPageCount: { type: Number, default: 0 },
|
||||
allMatchingResourceCount: { type: Number, default: 0 },
|
||||
})
|
||||
</script>
|
||||
151
nova/resources/js/components/Dropdowns/ThemeDropdown.vue
Normal file
151
nova/resources/js/components/Dropdowns/ThemeDropdown.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<Dropdown v-if="themeSwitcherEnabled" placement="bottom-end">
|
||||
<Button variant="action" :icon="themeIcon" :class="themeColor" />
|
||||
|
||||
<template #menu>
|
||||
<DropdownMenu width="auto">
|
||||
<nav class="flex flex-col py-1 px-1">
|
||||
<DropdownMenuItem
|
||||
as="button"
|
||||
size="small"
|
||||
class="flex items-center gap-2"
|
||||
@click="toggleLightTheme"
|
||||
>
|
||||
<Icon name="sun" type="micro" />
|
||||
<span>{{ __('Light') }}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
as="button"
|
||||
class="flex items-center gap-2"
|
||||
@click="toggleDarkTheme"
|
||||
>
|
||||
<Icon name="moon" type="micro" />
|
||||
<span>{{ __('Dark') }}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
as="button"
|
||||
class="flex items-center gap-2"
|
||||
@click="toggleSystemTheme"
|
||||
>
|
||||
<Icon name="computer-desktop" type="micro" />
|
||||
<span>{{ __('System') }}</span>
|
||||
</DropdownMenuItem>
|
||||
</nav>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Button, Icon } from 'laravel-nova-ui'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Button,
|
||||
Icon,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
theme: 'system',
|
||||
listener: null,
|
||||
matcher: window.matchMedia('(prefers-color-scheme: dark)'),
|
||||
themes: ['light', 'dark'],
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (Nova.config('themeSwitcherEnabled')) {
|
||||
if (this.themes.includes(localStorage.novaTheme)) {
|
||||
this.theme = localStorage.novaTheme
|
||||
}
|
||||
|
||||
this.listener = () => {
|
||||
if (this.theme === 'system') {
|
||||
this.applyColorScheme()
|
||||
}
|
||||
}
|
||||
this.matcher.addEventListener('change', this.listener)
|
||||
} else {
|
||||
localStorage.removeItem('novaTheme')
|
||||
}
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
if (Nova.config('themeSwitcherEnabled')) {
|
||||
this.matcher.removeEventListener('change', this.listener)
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
theme(theme) {
|
||||
if (theme === 'light') {
|
||||
localStorage.novaTheme = 'light'
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
|
||||
if (theme === 'dark') {
|
||||
localStorage.novaTheme = 'dark'
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
|
||||
if (theme === 'system') {
|
||||
localStorage.removeItem('novaTheme')
|
||||
this.applyColorScheme()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
applyColorScheme() {
|
||||
if (Nova.config('themeSwitcherEnabled')) {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
toggleLightTheme() {
|
||||
this.theme = 'light'
|
||||
},
|
||||
|
||||
toggleDarkTheme() {
|
||||
this.theme = 'dark'
|
||||
},
|
||||
|
||||
toggleSystemTheme() {
|
||||
this.theme = 'system'
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
themeSwitcherEnabled() {
|
||||
return Nova.config('themeSwitcherEnabled')
|
||||
},
|
||||
|
||||
themeIcon() {
|
||||
// if (this.theme === 'system') {
|
||||
// return 'desktop-computer'
|
||||
// }
|
||||
|
||||
return {
|
||||
light: 'sun',
|
||||
dark: 'moon',
|
||||
system: 'computer-desktop',
|
||||
}[this.theme]
|
||||
},
|
||||
|
||||
themeColor() {
|
||||
return {
|
||||
light: 'text-primary-500',
|
||||
dark: 'dark:text-primary-500',
|
||||
system: '',
|
||||
}[this.theme]
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user