Files
site/nova/resources/js/views/Lens.vue

995 lines
27 KiB
Vue
Executable File

<template>
<loading-view :loading="initialLoading" :dusk="lens + '-lens-component'">
<custom-lens-header class="mb-3" :resource-name="resourceName" />
<div v-if="shouldShowCards">
<cards
v-if="smallCards.length > 0"
:cards="smallCards"
class="mb-3"
:resource-name="resourceName"
:lens="lens"
/>
<cards
v-if="largeCards.length > 0"
:cards="largeCards"
size="large"
:resource-name="resourceName"
:lens="lens"
/>
</div>
<heading v-if="resourceResponse" class="mb-3">
<router-link
:to="{
name: 'index',
params: {
resourceName: resourceName,
},
}"
class="no-underline text-primary font-bold dim"
data-testid="lens-back"
>&larr;</router-link
>
<span class="px-2 text-70">/</span> {{ lenseName }}
</heading>
<card>
<div class="py-3 flex items-center border-b border-50">
<div class="px-3" v-if="shouldShowCheckBoxes">
<!-- Select All -->
<dropdown
dusk="select-all-dropdown"
placement="bottom-end"
class="-mx-2"
>
<dropdown-trigger class="px-2">
<fake-checkbox :checked="selectAllChecked" />
</dropdown-trigger>
<dropdown-menu slot="menu" direction="ltr" width="250">
<div class="p-4">
<ul class="list-reset">
<li class="flex items-center mb-4">
<checkbox-with-label
:checked="selectAllChecked"
@input="toggleSelectAll"
>
{{ __('Select All') }}
</checkbox-with-label>
</li>
<li
class="flex items-center"
v-if="allMatchingResourceCount > 0"
>
<checkbox-with-label
dusk="select-all-matching-button"
:checked="selectAllMatchingChecked"
@input="toggleSelectAllMatching"
>
<template>
<span class="mr-1">
{{ __('Select All Matching') }} ({{
allMatchingResourceCount
}})
</span>
</template>
</checkbox-with-label>
</li>
</ul>
</div>
</dropdown-menu>
</dropdown>
</div>
<div class="flex items-center ml-auto px-3">
<!-- Action Selector -->
<action-selector
v-if="selectedResources.length > 0 || haveStandaloneActions"
:resource-name="resourceName"
:actions="availableActions"
:pivot-actions="pivotActions"
:pivot-name="pivotName"
:selected-resources="selectedResourcesForActionSelector"
:endpoint="lensActionEndpoint"
:query-string="{
currentSearch,
encodedFilters,
currentTrashed,
viaResource,
viaResourceId,
viaRelationship,
}"
@actionExecuted="getResources"
/>
<filter-menu
:resourceName="resourceName"
:soft-deletes="softDeletes"
:via-resource="viaResource"
:via-has-one="viaHasOne"
:trashed="trashed"
:per-page="perPage"
:per-page-options="
perPageOptions || resourceInformation.perPageOptions
"
:show-trashed-option="
authorizedToForceDeleteAnyResources ||
authorizedToRestoreAnyResources
"
:lens="lens"
@clear-selected-filters="clearSelectedFilters(lens)"
@filter-changed="filterChanged"
@trashed-changed="trashedChanged"
@per-page-changed="updatePerPageChanged"
/>
<delete-menu
v-if="shouldShowDeleteMenu"
dusk="delete-menu"
:soft-deletes="softDeletes"
:resources="resources"
:selected-resources="selectedResources"
:via-many-to-many="viaManyToMany"
:all-matching-resource-count="allMatchingResourceCount"
:all-matching-selected="selectAllMatchingChecked"
:authorized-to-delete-selected-resources="
authorizedToDeleteSelectedResources
"
:authorized-to-force-delete-selected-resources="
authorizedToForceDeleteSelectedResources
"
:authorized-to-delete-any-resources="authorizedToDeleteAnyResources"
:authorized-to-force-delete-any-resources="
authorizedToForceDeleteAnyResources
"
:authorized-to-restore-selected-resources="
authorizedToRestoreSelectedResources
"
:authorized-to-restore-any-resources="
authorizedToRestoreAnyResources
"
@deleteSelected="deleteSelectedResources"
@deleteAllMatching="deleteAllMatchingResources"
@forceDeleteSelected="forceDeleteSelectedResources"
@forceDeleteAllMatching="forceDeleteAllMatchingResources"
@restoreSelected="restoreSelectedResources"
@restoreAllMatching="restoreAllMatchingResources"
@close="deleteModalOpen = false"
/>
</div>
</div>
<loading-view :loading="loading">
<div
v-if="!resources.length"
class="flex justify-center items-center px-6 py-8"
>
<div class="text-center">
<svg
class="mb-3"
xmlns="http://www.w3.org/2000/svg"
width="65"
height="51"
viewBox="0 0 65 51"
>
<path
fill="#A8B9C5"
d="M56 40h2c.552285 0 1 .447715 1 1s-.447715 1-1 1h-2v2c0 .552285-.447715 1-1 1s-1-.447715-1-1v-2h-2c-.552285 0-1-.447715-1-1s.447715-1 1-1h2v-2c0-.552285.447715-1 1-1s1 .447715 1 1v2zm-5.364125-8H38v8h7.049375c.350333-3.528515 2.534789-6.517471 5.5865-8zm-5.5865 10H6c-3.313708 0-6-2.686292-6-6V6c0-3.313708 2.686292-6 6-6h44c3.313708 0 6 2.686292 6 6v25.049375C61.053323 31.5511 65 35.814652 65 41c0 5.522847-4.477153 10-10 10-5.185348 0-9.4489-3.946677-9.950625-9zM20 30h16v-8H20v8zm0 2v8h16v-8H20zm34-2v-8H38v8h16zM2 30h16v-8H2v8zm0 2v4c0 2.209139 1.790861 4 4 4h12v-8H2zm18-12h16v-8H20v8zm34 0v-8H38v8h16zM2 20h16v-8H2v8zm52-10V6c0-2.209139-1.790861-4-4-4H6C3.790861 2 2 3.790861 2 6v4h52zm1 39c4.418278 0 8-3.581722 8-8s-3.581722-8-8-8-8 3.581722-8 8 3.581722 8 8 8z"
/>
</svg>
<h3 class="text-base text-80 font-normal mb-6">
{{
__('No :resource matched the given criteria.', {
resource: resourceInformation.label.toLowerCase(),
})
}}
</h3>
<create-resource-button
classes="btn btn-sm btn-outline"
:singular-name="singularName"
:resource-name="resourceName"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:relationship-type="relationshipType"
:authorized-to-create="authorizedToCreate && !resourceIsFull"
:authorized-to-relate="authorizedToRelate"
/>
</div>
</div>
<!-- Resource Table -->
<div class="overflow-hidden overflow-x-auto relative">
<resource-table
:authorized-to-relate="authorizedToRelate"
:resource-name="resourceName"
:resources="resources"
:singular-name="singularName"
:selected-resources="selectedResources"
:selected-resource-ids="selectedResourceIds"
:actions-are-available="allActions.length > 0"
:actions-endpoint="lensActionEndpoint"
:should-show-checkboxes="shouldShowCheckBoxes"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:relationship-type="relationshipType"
:update-selection-status="updateSelectionStatus"
@order="orderByField"
@reset-order-by="resetOrderBy"
@delete="deleteResources"
@restore="restoreResources"
@actionExecuted="getResources"
ref="resourceTable"
/>
</div>
<!-- Pagination -->
<component
:is="paginationComponent"
v-if="resourceResponse && resources.length > 0"
:next="hasNextPage"
:previous="hasPreviousPage"
@load-more="loadMore"
@page="selectPage"
:pages="totalPages"
:page="currentPage"
:per-page="perPage"
:resource-count-label="resourceCountLabel"
:current-resource-count="resources.length"
:all-matching-resource-count="allMatchingResourceCount"
>
<span
v-if="resourceCountLabel"
class="text-sm text-80 px-4"
:class="{
'ml-auto': paginationComponent == 'pagination-links',
}"
>
{{ resourceCountLabel }}
</span>
</component>
</loading-view>
</card>
</loading-view>
</template>
<script>
import {
HasCards,
Deletable,
Errors,
Filterable,
Minimum,
Paginatable,
PerPageable,
InteractsWithQueryString,
InteractsWithResourceInformation,
} from 'laravel-nova'
import HasActions from '@/mixins/HasActions'
import { CancelToken, Cancel } from 'axios'
export default {
mixins: [
HasActions,
HasCards,
Deletable,
Filterable,
Paginatable,
PerPageable,
InteractsWithResourceInformation,
InteractsWithQueryString,
],
metaInfo() {
return {
title: this.lenseName,
}
},
props: {
resourceName: {
type: String,
required: true,
},
viaResource: {
default: '',
},
viaResourceId: {
default: '',
},
viaRelationship: {
default: '',
},
relationshipType: {
type: String,
default: '',
},
lens: {
type: String,
required: true,
},
},
data: () => ({
canceller: null,
initialLoading: true,
loading: true,
resourceResponse: null,
resources: [],
softDeletes: false,
selectedResources: [],
selectAllMatchingResources: false,
allMatchingResourceCount: 0,
hasId: false,
deleteModalOpen: false,
actionValidationErrors: new Errors(),
authorizedToRelate: false,
orderBy: '',
orderByDirection: '',
trashed: '',
// Load More Pagination
currentPageLoadMore: null,
}),
/**
* Mount the component and retrieve its initial data.
*/
async created() {
if (Nova.missingResource(this.resourceName))
return this.$router.push({ name: '404' })
this.initializeSearchFromQueryString()
this.initializePerPageFromQueryString()
this.initializeTrashedFromQueryString()
this.initializeOrderingFromQueryString()
await this.initializeFilters(this.lens)
this.getResources()
// this.getAuthorizationToRelate()
this.getActions()
this.initialLoading = false
this.$watch(
() => {
return (
this.lens +
this.resourceName +
this.encodedFilters +
this.currentSearch +
this.currentPage +
this.currentPerPage +
this.currentOrderBy +
this.currentOrderByDirection +
this.currentTrashed
)
},
() => {
if (this.canceller !== null) this.canceller()
this.getResources()
}
)
},
watch: {
$route(to, from) {
if (
to.params.resourceName === from.params.resourceName &&
to.params.lens === from.params.lens
) {
this.initializeState(this.lens)
} else {
this.initializeFilters(this.lens)
this.getActions()
}
},
},
methods: {
selectAllResources() {
this.selectedResources = this.resources.slice(0)
},
toggleSelectAll() {
if (this.selectAllChecked) return this.clearResourceSelections()
this.selectAllResources()
},
toggleSelectAllMatching() {
if (!this.selectAllMatchingResources) {
this.selectAllResources()
this.selectAllMatchingResources = true
return
}
this.selectAllMatchingResources = false
},
/*
* Update the resource selection status
*/
updateSelectionStatus(resource) {
if (!_(this.selectedResources).includes(resource))
return this.selectedResources.push(resource)
const index = this.selectedResources.indexOf(resource)
if (index > -1) return this.selectedResources.splice(index, 1)
},
/**
* Get the resources based on the current page, search, filters, etc.
*/
getResources() {
this.loading = true
this.$nextTick(() => {
this.clearResourceSelections()
return Minimum(
Nova.request().get(
'/nova-api/' + this.resourceName + '/lens/' + this.lens,
{
params: this.resourceRequestQueryString,
cancelToken: new CancelToken(canceller => {
this.canceller = canceller
}),
}
),
300
)
.then(({ data }) => {
this.resources = []
this.resourceResponse = data
this.resources = data.resources
this.softDeletes = data.softDeletes
this.perPage = data.per_page
this.hasId = data.hasId
this.loading = false
this.getAllMatchingResourceCount()
Nova.$emit('resources-loaded')
})
.catch(e => {
if (e instanceof Cancel) {
return
}
throw e
})
})
},
/**
* Get the actions available for the current resource.
*/
getActions() {
this.actions = []
this.pivotActions = null
Nova.request()
.get(`/nova-api/${this.resourceName}/lens/${this.lens}/actions`, {
params: {
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
viaRelationship: this.viaRelationship,
relationshipType: this.relationshipType,
display: 'index',
},
})
.then(response => {
this.actions = response.data.actions
this.pivotActions = response.data.pivotActions
})
},
/**
* Clear the selected resouces and the "select all" states.
*/
clearResourceSelections() {
this.selectAllMatchingResources = false
this.selectedResources = []
},
/**
* Get the count of all of the matching resources.
*/
getAllMatchingResourceCount() {
Nova.request()
.get(
'/nova-api/' + this.resourceName + '/lens/' + this.lens + '/count',
{
params: this.resourceRequestQueryString,
}
)
.then(response => {
this.allMatchingResourceCount = response.data.count
})
},
/**
* Sort the resources by the given field.
*/
orderByField(field) {
let direction = this.currentOrderByDirection == 'asc' ? 'desc' : 'asc'
if (this.currentOrderBy != field.sortableUriKey) {
direction = 'asc'
}
this.updateQueryString({
[this.orderByParameter]: field.sortableUriKey,
[this.orderByDirectionParameter]: direction,
})
},
/**
* Reset the order by to its default state
*/
resetOrderBy(field) {
this.updateQueryString({
[this.orderByParameter]: field.sortableUriKey,
[this.orderByDirectionParameter]: null,
})
},
/**
* Sync the current search value from the query string.
*/
initializeSearchFromQueryString() {
this.search = this.currentSearch
},
/**
* Sync the current order by values from the query string.
*/
initializeOrderingFromQueryString() {
this.orderBy = this.currentOrderBy
this.orderByDirection = this.currentOrderByDirection
},
/**
* Sync the trashed state values from the query string.
*/
initializeTrashedFromQueryString() {
this.trashed = this.currentTrashed
},
/**
* Update the trashed constraint for the resource listing.
*/
trashedChanged(trashedStatus) {
this.trashed = trashedStatus
this.updateQueryString({ [this.trashedParameter]: this.trashed })
},
/**
* Update the per page parameter in the query string
*/
updatePerPageChanged(perPage) {
this.perPage = perPage
this.perPageChanged()
},
/**
* Load more resources.
*/
loadMore() {
if (this.currentPageLoadMore === null) {
this.currentPageLoadMore = this.currentPage
}
this.currentPageLoadMore = this.currentPageLoadMore + 1
return Minimum(
Nova.request().get(
'/nova-api/' + this.resourceName + '/lens/' + this.lens,
{
params: {
...this.resourceRequestQueryString,
page: this.currentPageLoadMore, // We do this to override whatever page number is in the URL
},
}
),
300
).then(({ data }) => {
this.resourceResponse = data
this.resources = [...this.resources, ...data.resources]
this.getAllMatchingResourceCount()
Nova.$emit('resources-loaded')
})
},
/**
* Select the next page.
*/
selectPage(page) {
this.updateQueryString({ [this.pageParameter]: page })
},
/**
* Sync the per page values from the query string.
*/
initializePerPageFromQueryString() {
this.perPage =
this.$route.query[this.perPageParameter] ||
this.resourceInformation.perPageOptions[0]
},
},
computed: {
/**
* Get the endpoint for this resource's actions.
*/
lensActionEndpoint() {
return `/nova-api/${this.resourceName}/lens/${this.lens}/action`
},
/**
* Get the name of the search query string variable.
*/
searchParameter() {
return this.resourceName + '_search'
},
/**
* Get the name of the order by query string variable.
*/
orderByParameter() {
return this.resourceName + '_order'
},
/**
* Get the name of the order by direction query string variable.
*/
orderByDirectionParameter() {
return this.resourceName + '_direction'
},
/**
* Get the name of the trashed constraint query string variable.
*/
trashedParameter() {
return this.resourceName + '_trashed'
},
/**
* Get the name of the per page query string variable.
*/
perPageParameter() {
return this.resourceName + '_per_page'
},
/**
* Get the name of the page query string variable.
*/
pageParameter() {
return this.resourceName + '_page'
},
/**
* Build the resource request query string.
*/
resourceRequestQueryString() {
return {
search: this.currentSearch,
filters: this.encodedFilters,
orderBy: this.currentOrderBy,
orderByDirection: this.currentOrderByDirection,
perPage: this.currentPerPage,
page: this.currentPage,
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
// viaRelationship: this.viaRelationship,
viaResourceRelationship: this.viaResourceRelationship,
relationshipType: this.relationshipType,
}
},
/**
* Determine if all resources are selected.
*/
selectAllChecked() {
return this.selectedResources.length == this.resources.length
},
/**
* Determine if all matching resources are selected.
*/
selectAllMatchingChecked() {
return (
this.selectedResources.length == this.resources.length &&
this.selectAllMatchingResources
)
},
/**
* Get the IDs for the selected resources.
*/
selectedResourceIds() {
return _.map(this.selectedResources, resource => resource.id.value)
},
/**
* Get the current search value from the query string.
*/
currentSearch() {
return this.$route.query[this.searchParameter] || ''
},
/**
* Get the current order by value from the query string.
*/
currentOrderBy() {
return this.$route.query[this.orderByParameter] || ''
},
/**
* Get the current order by direction from the query string.
*/
currentOrderByDirection() {
return this.$route.query[this.orderByDirectionParameter] || 'desc'
},
/**
* Get the current trashed constraint value from the query string.
*/
currentTrashed() {
return this.$route.query[this.trashedParameter] || ''
},
/**
* Determine if the current resource listing is via a many-to-many relationship.
*/
viaManyToMany() {
return (
this.relationshipType == 'belongsToMany' ||
this.relationshipType == 'morphToMany'
)
},
/**
* Determine if the resource / relationship is "full".
*/
resourceIsFull() {
return this.viaHasOne && this.resources.length > 0
},
/**
* Determine if the current resource listing is via a has-one relationship.
*/
viaHasOne() {
return (
this.relationshipType == 'hasOne' || this.relationshipType == 'morphOne'
)
},
/**
* Get the singular name for the resource
*/
singularName() {
return this.resourceInformation.singularLabel
},
/**
* Determine if there are any resources for the view
*/
hasResources() {
return Boolean(this.resources.length > 0)
},
/**
* Determine if the resource should show any cards
*/
shouldShowCards() {
return this.cards.length > 0
},
/**
* Get the endpoint for this resource's metrics.
*/
cardsEndpoint() {
return `/nova-api/${this.resourceName}/lens/${this.lens}/cards`
},
/**
* Determine whether to show the selection checkboxes for resources
*/
shouldShowCheckBoxes() {
return (
Boolean(this.hasResources && !this.viaHasOne) &&
Boolean(
this.actionsAreAvailable ||
this.authorizedToDeleteAnyResources ||
this.canShowDeleteMenu
)
)
},
/**
* Determine whether the delete menu should be shown to the user
*/
shouldShowDeleteMenu() {
return (
Boolean(this.selectedResources.length > 0) && this.canShowDeleteMenu
)
},
/**
* Determine if any selected resources may be deleted.
*/
authorizedToDeleteSelectedResources() {
return Boolean(
_.find(this.selectedResources, resource => resource.authorizedToDelete)
)
},
/**
* Determine if any selected resources may be force deleted.
*/
authorizedToForceDeleteSelectedResources() {
return Boolean(
_.find(
this.selectedResources,
resource => resource.authorizedToForceDelete
)
)
},
/**
* Determine if the user is authorized to delete any listed resource.
*/
authorizedToDeleteAnyResources() {
return (
this.resources.length > 0 &&
Boolean(_.find(this.resources, resource => resource.authorizedToDelete))
)
},
/**
* Determine if the user is authorized to force delete any listed resource.
*/
authorizedToForceDeleteAnyResources() {
return (
this.resources.length > 0 &&
Boolean(
_.find(this.resources, resource => resource.authorizedToForceDelete)
)
)
},
/**
* Determine if any selected resources may be restored.
*/
authorizedToRestoreSelectedResources() {
return Boolean(
_.find(this.selectedResources, resource => resource.authorizedToRestore)
)
},
/**
* Determine if the user is authorized to restore any listed resource.
*/
authorizedToRestoreAnyResources() {
return (
this.resources.length > 0 &&
Boolean(
_.find(this.resources, resource => resource.authorizedToRestore)
)
)
},
/**
* Determine whether the user is authorized to perform actions on the delete menu
*/
canShowDeleteMenu() {
return (
this.hasId &&
Boolean(
this.authorizedToDeleteSelectedResources ||
this.authorizedToForceDeleteSelectedResources ||
this.authorizedToDeleteAnyResources ||
this.authorizedToForceDeleteAnyResources ||
this.authorizedToRestoreSelectedResources ||
this.authorizedToRestoreAnyResources
)
)
},
/**
* Return the currently encoded filter string from the store
*/
encodedFilters() {
return this.$store.getters[`${this.resourceName}/currentEncodedFilters`]
},
/**
* Return the initial encoded filters from the query string
*/
initialEncodedFilters() {
return this.$route.query[this.filterParameter] || ''
},
paginationComponent() {
return `pagination-${Nova.config['pagination'] || 'links'}`
},
hasNextPage() {
return Boolean(
this.resourceResponse && this.resourceResponse.next_page_url
)
},
hasPreviousPage() {
return Boolean(
this.resourceResponse && this.resourceResponse.prev_page_url
)
},
totalPages() {
return Math.ceil(this.allMatchingResourceCount / this.currentPerPage)
},
/**
* Return the resource count label
*/
resourceCountLabel() {
const first = this.perPage * (this.currentPage - 1)
return (
this.resources.length &&
`${first + 1}-${first + this.resources.length} ${this.__('of')} ${
this.allMatchingResourceCount
}`
)
},
/**
* Get the current per page value from the query string.
*/
currentPerPage() {
return this.perPage
},
/**
* The per-page options configured for this resource.
*/
perPageOptions() {
if (this.resourceResponse) {
return this.resourceResponse.per_page_options
}
},
/**
* The Lense name.
*/
lenseName() {
if (this.resourceResponse) {
return this.resourceResponse.name
}
},
},
}
</script>