This commit is contained in:
2024-09-01 18:54:23 +05:00
parent 76d18365a5
commit 061f09eca1
1597 changed files with 109451 additions and 1 deletions

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
<template>
<h3 class="mt-3 px-3 text-xs font-bold">
<slot />
</h3>
</template>

View 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>

View 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>

View 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>

View 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>