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,666 @@
<template>
<LoadingView :loading="initialLoading">
<template v-if="relatedResourceLabel">
<Head
:title="
__('Attach :resource', {
resource: relatedResourceLabel,
})
"
/>
</template>
<Heading
class="mb-3"
v-text="__('Attach :resource', { resource: relatedResourceLabel })"
dusk="attach-heading"
/>
<form
v-if="field"
@submit.prevent="attachResource"
@change="onUpdateFormStatus"
:data-form-unique-id="formUniqueId"
autocomplete="off"
>
<Card class="mb-8">
<!-- Related Resource -->
<div
v-if="parentResource"
dusk="via-resource-field"
class="field-wrapper flex flex-col md:flex-row border-b border-gray-100 dark:border-gray-700"
>
<div class="w-1/5 px-8 py-6">
<label
:for="parentResource.name"
class="inline-block text-gray-500 pt-2 leading-tight"
>
{{ parentResource.name }}
</label>
</div>
<div class="py-6 px-8 w-1/2">
<span class="inline-block font-bold text-gray-500 pt-2">
{{ parentResource.display }}
</span>
</div>
</div>
<DefaultField
:field="field"
:errors="validationErrors"
:show-help-text="true"
>
<template #field>
<div class="flex items-center">
<SearchInput
v-if="field.searchable"
:dusk="`${field.resourceName}-search-input`"
@input="performSearch"
@clear="clearResourceSelection"
@selected="selectResource"
:debounce="field.debounce"
:value="selectedResource"
:data="availableResources"
trackBy="value"
class="w-full"
>
<div v-if="selectedResource" class="flex items-center">
<div v-if="selectedResource.avatar" class="mr-3">
<img
:src="selectedResource.avatar"
class="w-8 h-8 rounded-full block"
/>
</div>
{{ selectedResource.display }}
</div>
<template #option="{ selected, option }">
<div class="flex items-center">
<div v-if="option.avatar" class="flex-none mr-3">
<img
:src="option.avatar"
class="w-8 h-8 rounded-full block"
/>
</div>
<div class="flex-auto">
<div
class="text-sm font-semibold leading-5"
:class="{ 'text-white': selected }"
>
{{ option.display }}
</div>
<div
v-if="field.withSubtitles"
class="mt-1 text-xs font-semibold leading-5 text-gray-500"
:class="{ 'text-white': selected }"
>
<span v-if="option.subtitle">{{
option.subtitle
}}</span>
<span v-else>{{
__('No additional information...')
}}</span>
</div>
</div>
</div>
</template>
</SearchInput>
<SelectControl
v-else
class="w-full"
:class="{
'form-control-bordered-error': validationErrors.has(
field.attribute
),
}"
dusk="attachable-select"
v-model:selected="selectedResourceId"
@change="selectResourceFromSelectControl"
:options="availableResources"
:label="'display'"
>
<option value="" disabled selected>
{{
__('Choose :resource', {
resource: relatedResourceLabel,
})
}}
</option>
</SelectControl>
<CreateRelationButton
v-if="canShowNewRelationModal"
@click="openRelationModal"
class="ml-2"
:dusk="`${field.attribute}-inline-create`"
/>
</div>
<CreateRelationModal
:show="canShowNewRelationModal && relationModalOpen"
@set-resource="handleSetResource"
@create-cancelled="closeRelationModal"
:resource-name="field.resourceName"
:resource-id="resourceId"
:via-relationship="viaRelationship"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
/>
<TrashedCheckbox
v-if="softDeletes"
class="mt-3"
:resource-name="field.resourceName"
:checked="withTrashed"
@input="toggleWithTrashed"
/>
</template>
</DefaultField>
<LoadingView :loading="loading">
<!-- Pivot Fields -->
<div v-for="field in fields" :key="field.uniqueKey">
<component
:is="`form-${field.component}`"
:resource-name="resourceName"
:resource-id="resourceId"
:related-resource-name="relatedResourceName"
:field="field"
:form-unique-id="formUniqueId"
:errors="validationErrors"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:show-help-text="true"
/>
</div>
</LoadingView>
</Card>
<!-- Attach Button -->
<div
class="flex flex-col md:flex-row md:items-center justify-center md:justify-end space-y-2 md:space-y-0 space-x-3"
>
<Button
dusk="cancel-attach-button"
@click="cancelAttachingResource"
:label="__('Cancel')"
variant="ghost"
/>
<Button
dusk="attach-and-attach-another-button"
@click.native.prevent="attachAndAttachAnother"
:disabled="isWorking"
:loading="submittedViaAttachAndAttachAnother"
>
{{ __('Attach & Attach Another') }}
</Button>
<Button
type="submit"
dusk="attach-button"
:disabled="isWorking"
:loading="submittedViaAttachResource"
>
{{
__('Attach :resource', {
resource: relatedResourceLabel,
})
}}
</Button>
</div>
</form>
</LoadingView>
</template>
<script>
import each from 'lodash/each'
import find from 'lodash/find'
import tap from 'lodash/tap'
import {
PerformsSearches,
TogglesTrashed,
FormEvents,
HandlesFormRequest,
PreventsFormAbandonment,
} from '@/mixins'
import { mapActions } from 'vuex'
import { Button } from 'laravel-nova-ui'
export default {
components: {
Button,
},
mixins: [
FormEvents,
HandlesFormRequest,
PerformsSearches,
TogglesTrashed,
PreventsFormAbandonment,
],
props: {
resourceName: {
type: String,
required: true,
},
resourceId: {
required: true,
},
relatedResourceName: {
type: String,
required: true,
},
viaResource: {
default: '',
},
viaResourceId: {
default: '',
},
parentResource: {
type: Object,
},
viaRelationship: {
default: '',
},
polymorphic: {
default: false,
},
},
data: () => ({
initialLoading: true,
loading: true,
submittedViaAttachAndAttachAnother: false,
submittedViaAttachResource: false,
field: null,
softDeletes: false,
fields: [],
selectedResource: null,
selectedResourceId: null,
relationModalOpen: false,
initializingWithExistingResource: false,
}),
created() {
if (Nova.missingResource(this.resourceName)) return Nova.visit('/404')
},
/**
* Mount the component.
*/
mounted() {
this.initializeComponent()
},
methods: {
...mapActions(['fetchPolicies']),
/**
* Initialize the component's data.
*/
initializeComponent() {
this.softDeletes = false
this.disableWithTrashed()
this.clearSelection()
this.getField()
this.getPivotFields()
this.resetErrors()
this.allowLeavingForm()
},
/**
* Handle pivot fields loaded event.
*/
handlePivotFieldsLoaded() {
this.loading = false
each(this.fields, field => {
field.fill = () => ''
})
},
/**
* Get the many-to-many relationship field.
*/
getField() {
this.field = null
Nova.request()
.get(
'/nova-api/' + this.resourceName + '/field/' + this.viaRelationship,
{
params: {
relatable: true,
},
}
)
.then(({ data }) => {
this.field = data
this.field.searchable
? this.determineIfSoftDeletes()
: this.getAvailableResources()
this.initialLoading = false
})
},
/**
* Get all of the available pivot fields for the relationship.
*/
getPivotFields() {
this.fields = []
this.loading = true
Nova.request()
.get(
'/nova-api/' +
this.resourceName +
'/' +
this.resourceId +
'/creation-pivot-fields/' +
this.relatedResourceName,
{
params: {
editing: true,
editMode: 'attach',
viaRelationship: this.viaRelationship,
},
}
)
.then(({ data }) => {
this.fields = data
this.handlePivotFieldsLoaded()
})
},
/**
* Get all of the available resources for the current search / trashed state.
*/
getAvailableResources(search = '') {
Nova.$progress.start()
return Nova.request()
.get(
`/nova-api/${this.resourceName}/${this.resourceId}/attachable/${this.relatedResourceName}`,
{
params: {
search,
current: this.selectedResourceId,
first: this.initializingWithExistingResource,
withTrashed: this.withTrashed,
viaRelationship: this.viaRelationship,
},
}
)
.then(response => {
Nova.$progress.done()
if (this.isSearchable) {
this.initializingWithExistingResource = false
}
this.availableResources = response.data.resources
this.withTrashed = response.data.withTrashed
this.softDeletes = response.data.softDeletes
})
.catch(e => {
Nova.$progress.done()
})
},
/**
* Determine if the related resource is soft deleting.
*/
determineIfSoftDeletes() {
Nova.request()
.get('/nova-api/' + this.relatedResourceName + '/soft-deletes')
.then(response => {
this.softDeletes = response.data.softDeletes
})
},
/**
* Attach the selected resource.
*/
async attachResource() {
this.submittedViaAttachResource = true
try {
await this.attachRequest()
this.submittedViaAttachResource = false
this.allowLeavingForm()
await this.fetchPolicies(),
Nova.success(this.__('The resource was attached!'))
Nova.visit(`/resources/${this.resourceName}/${this.resourceId}`)
} catch (error) {
window.scrollTo(0, 0)
this.submittedViaAttachResource = false
this.preventLeavingForm()
this.handleOnCreateResponseError(error)
}
},
/**
* Attach a new resource and reset the form
*/
async attachAndAttachAnother() {
this.submittedViaAttachAndAttachAnother = true
try {
await this.attachRequest()
window.scrollTo(0, 0)
this.disableNavigateBackUsingHistory()
this.allowLeavingForm()
this.submittedViaAttachAndAttachAnother = false
await this.fetchPolicies()
// Reset the form by refetching the fields
this.initializeComponent()
} catch (error) {
this.submittedViaAttachAndAttachAnother = false
this.handleOnCreateResponseError(error)
}
},
cancelAttachingResource() {
this.handleProceedingToPreviousPage()
this.allowLeavingForm()
this.proceedToPreviousPage(
`/resources/${this.resourceName}/${this.resourceId}`
)
},
/**
* Send an attach request for this resource
*/
attachRequest() {
return Nova.request().post(
this.attachmentEndpoint,
this.attachmentFormData(),
{
params: {
editing: true,
editMode: 'attach',
},
}
)
},
/**
* Get the form data for the resource attachment.
*/
attachmentFormData() {
return tap(new FormData(), formData => {
each(this.fields, field => {
field.fill(formData)
})
if (!this.selectedResource) {
formData.append(this.relatedResourceName, '')
} else {
formData.append(this.relatedResourceName, this.selectedResource.value)
}
formData.append(this.relatedResourceName + '_trashed', this.withTrashed)
formData.append('viaRelationship', this.viaRelationship)
})
},
/**
* Select a resource using the <select> control
*/
selectResourceFromSelectControl(value) {
this.selectedResourceId = value
this.selectInitialResource()
if (this.field) {
this.emitFieldValueChange(this.fieldAttribute, this.selectedResourceId)
}
},
/**
* Select the initial selected resource
*/
selectInitialResource() {
this.selectedResource = find(
this.availableResources,
r => r.value == this.selectedResourceId
)
},
/**
* Toggle the trashed state of the search
*/
toggleWithTrashed() {
this.withTrashed = !this.withTrashed
// Reload the data if the component doesn't support searching
if (!this.isSearchable) {
this.getAvailableResources()
}
},
/**
* Prevent accidental abandonment only if form was changed.
*/
onUpdateFormStatus() {
this.updateFormStatus()
},
handleSetResource({ id }) {
this.closeRelationModal()
this.selectedResourceId = id
this.initializingWithExistingResource = true
this.getAvailableResources().then(() => this.selectInitialResource())
},
openRelationModal() {
Nova.$emit('create-relation-modal-opened')
this.relationModalOpen = true
},
closeRelationModal() {
this.relationModalOpen = false
Nova.$emit('create-relation-modal-closed')
},
clearResourceSelection() {
this.clearSelection()
if (!this.isSearchable) {
this.initializingWithExistingResource = false
this.getAvailableResources()
}
},
},
computed: {
/**
* Get the attachment endpoint for the relationship type.
*/
attachmentEndpoint() {
return this.polymorphic
? '/nova-api/' +
this.resourceName +
'/' +
this.resourceId +
'/attach-morphed/' +
this.relatedResourceName
: '/nova-api/' +
this.resourceName +
'/' +
this.resourceId +
'/attach/' +
this.relatedResourceName
},
/**
* Get the label for the related resource.
*/
relatedResourceLabel() {
if (this.field) {
return this.field.singularLabel
}
},
/**
* Determine if the related resources is searchable
*/
isSearchable() {
return this.field.searchable
},
/**
* Determine if the form is being processed
*/
isWorking() {
return (
this.submittedViaAttachResource ||
this.submittedViaAttachAndAttachAnother
)
},
/**
* Return the heading for the view
*/
headingTitle() {
return this.__('Attach :resource', {
resource: this.relatedResourceLabel,
})
},
shouldShowTrashed() {
return Boolean(this.softDeletes)
},
authorizedToCreate() {
return find(Nova.config('resources'), resource => {
return resource.uriKey == this.field.resourceName
}).authorizedToCreate
},
canShowNewRelationModal() {
return this.field.showCreateRelationButton && this.authorizedToCreate
},
},
}
</script>

View File

@@ -0,0 +1,112 @@
<template>
<CreateForm
@resource-created="handleResourceCreated"
@resource-created-and-adding-another="handleResourceCreatedAndAddingAnother"
@create-cancelled="handleCreateCancelled"
:mode="mode"
:resource-name="resourceName"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
@update-form-status="onUpdateFormStatus"
@finished-loading="$emit('finished-loading')"
:should-override-meta="mode === 'form'"
:form-unique-id="formUniqueId"
/>
</template>
<script>
import {
mapProps,
PreventsFormAbandonment,
PreventsModalAbandonment,
} from '@/mixins'
import { uid } from 'uid/single'
export default {
emits: ['refresh', 'create-cancelled', 'finished-loading'],
mixins: [PreventsFormAbandonment, PreventsModalAbandonment],
provide() {
return {
removeFile: this.removeFile,
}
},
props: {
mode: {
type: String,
default: 'form',
validator: val => ['modal', 'form'].includes(val),
},
...mapProps([
'resourceName',
'viaResource',
'viaResourceId',
'viaRelationship',
]),
},
data: () => ({
formUniqueId: uid(),
}),
methods: {
handleResourceCreated({ redirect, id }) {
this.mode === 'form' ? this.allowLeavingForm() : this.allowLeavingModal()
Nova.$emit('resource-created', {
resourceName: this.resourceName,
resourceId: id,
})
if (this.mode === 'form') {
return Nova.visit(redirect)
}
return this.$emit('refresh', { redirect, id })
},
handleResourceCreatedAndAddingAnother() {
this.disableNavigateBackUsingHistory()
},
handleCreateCancelled() {
if (this.mode === 'form') {
this.handleProceedingToPreviousPage()
this.allowLeavingForm()
this.proceedToPreviousPage(
this.isRelation
? `/resources/${this.viaResource}/${this.viaResourceId}`
: `/resources/${this.resourceName}`
)
return
}
this.allowLeavingModal()
return this.$emit('create-cancelled')
},
/**
* Prevent accidental abandonment only if form was changed.
*/
onUpdateFormStatus() {
this.mode === 'form' ? this.updateFormStatus() : this.updateModalStatus()
},
removeFile(attribute) {
//
},
},
computed: {
isRelation() {
return Boolean(this.viaResourceId && this.viaRelationship)
},
},
}
</script>

View File

@@ -0,0 +1,20 @@
<template>
<ErrorLayout>
<Head title="Error" />
<h1 class="text-[5rem] md:text-[4rem] font-normal leading-none">
{{ __(':-(') }}
</h1>
<p class="text-2xl">{{ __('Whoops') }}&hellip;</p>
<p class="text-lg leading-normal">
{{ __('Nova experienced an unrecoverable error.') }}
</p>
</ErrorLayout>
</template>
<script>
import ErrorLayout from '@/layouts/ErrorLayout'
export default {
components: { ErrorLayout },
}
</script>

View File

@@ -0,0 +1,20 @@
<template>
<ErrorLayout status="403">
<Head title="Forbidden" />
<h1 class="text-[5rem] md:text-[4rem] font-normal leading-none">403</h1>
<p class="text-2xl">{{ __('Hold Up!') }}</p>
<p class="text-lg leading-normal">
{{
__("The government won't let us show you what's behind these doors")
}}&hellip;
</p>
</ErrorLayout>
</template>
<script>
import ErrorLayout from '@/layouts/ErrorLayout'
export default {
components: { ErrorLayout },
}
</script>

View File

@@ -0,0 +1,22 @@
<template>
<ErrorLayout status="404">
<Head title="Page Not Found" />
<h1 class="text-[5rem] md:text-[4rem] font-normal leading-none">404</h1>
<p class="text-2xl">{{ __('Whoops') }}&hellip;</p>
<p class="text-lg leading-normal">
{{
__(
"We're lost in space. The page you were trying to view does not exist."
)
}}
</p>
</ErrorLayout>
</template>
<script>
import ErrorLayout from '@/layouts/ErrorLayout'
export default {
components: { ErrorLayout },
}
</script>

View File

@@ -0,0 +1,119 @@
<template>
<LoadingView
:loading="loading"
:dusk="'dashboard-' + this.name"
class="space-y-3"
>
<Head :title="label" />
<div
v-if="(label && !isHelpCard) || showRefreshButton"
class="flex items-center"
>
<Heading v-if="label && !isHelpCard">
{{ __(label) }}
</Heading>
<button
@click.stop="refreshDashboard"
type="button"
class="ml-1 hover:opacity-50 active:ring"
v-if="showRefreshButton"
tabindex="0"
>
<Icon
class="text-gray-500 dark:text-gray-400"
:solid="true"
type="refresh"
width="14"
v-tooltip="__('Refresh')"
/>
</button>
</div>
<div v-if="shouldShowCards">
<Cards v-if="cards.length > 0" :cards="cards" />
</div>
</LoadingView>
</template>
<script>
import { minimum } from '@/util'
export default {
props: {
name: {
type: String,
required: false,
default: 'main',
},
},
data: () => ({
loading: true,
label: '',
cards: [],
showRefreshButton: false,
isHelpCard: false,
}),
created() {
this.fetchDashboard()
},
methods: {
async fetchDashboard() {
this.loading = true
try {
const {
data: { label, cards, showRefreshButton, isHelpCard },
} = await minimum(
Nova.request().get(this.dashboardEndpoint, {
params: this.extraCardParams,
}),
200
)
this.loading = false
this.label = label
this.cards = cards
this.showRefreshButton = showRefreshButton
this.isHelpCard = isHelpCard
} catch (error) {
if (error.response.status == 401) {
return Nova.redirectToLogin()
}
Nova.visit('/404')
}
},
refreshDashboard() {
Nova.$emit('metric-refresh')
},
},
computed: {
/**
* Get the endpoint for this dashboard.
*/
dashboardEndpoint() {
return `/nova-api/dashboards/${this.name}`
},
/**
* Determine whether we have cards to show on the Dashboard
*/
shouldShowCards() {
return this.cards.length > 0
},
/**
* Get the extra card params to pass to the endpoint.
*/
extraCardParams() {
return null
},
},
}
</script>

View File

@@ -0,0 +1,366 @@
<template>
<LoadingView :loading="initialLoading">
<template v-if="shouldOverrideMeta && resourceInformation && title">
<Head
:title="
__(':resource Details: :title', {
resource: resourceInformation.singularLabel,
title: title,
})
"
/>
</template>
<div v-if="shouldShowCards && hasDetailOnlyCards">
<Cards
v-if="cards.length > 0"
:cards="cards"
:only-on-detail="true"
:resource="resource"
:resource-id="resourceId"
:resource-name="resourceName"
/>
</div>
<!-- Resource Detail -->
<div
:class="{
'mt-6': shouldShowCards && hasDetailOnlyCards && cards.length > 0,
}"
:dusk="resourceName + '-detail-component'"
>
<component
:is="resolveComponentName(panel)"
v-for="panel in panels"
:key="panel.id"
:panel="panel"
:resource="resource"
:resource-id="resourceId"
:resource-name="resourceName"
class="mb-8"
>
<div v-if="panel.showToolbar" class="md:flex items-center mb-3">
<div class="flex flex-auto truncate items-center">
<Heading
:level="1"
v-text="panel.name"
:dusk="`${panel.name}-detail-heading`"
/>
<Badge
v-if="resource.softDeleted"
:label="__('Soft Deleted')"
class="bg-red-100 text-red-500 dark:bg-red-400 dark:text-red-900 rounded px-2 py-0.5 ml-3"
/>
</div>
<div class="ml-auto flex items-center">
<!-- Actions Menu -->
<DetailActionDropdown
v-if="shouldShowActionDropdown"
:resource="resource"
:actions="actions"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:resource-name="resourceName"
class="mt-1 md:mt-0 md:ml-2 md:mr-2"
@actionExecuted="actionExecuted"
@resource-deleted="getResource"
@resource-restored="getResource"
/>
<Link
v-if="showViewLink"
v-tooltip="{
placement: 'bottom',
distance: 10,
skidding: 0,
content: __('View'),
}"
:href="$url(`/resources/${resourceName}/${resourceId}`)"
class="rounded hover:bg-gray-200 dark:hover:bg-gray-800 focus:outline-none focus:ring"
dusk="view-resource-button"
tabindex="1"
>
<BasicButton component="span">
<Icon type="eye" />
</BasicButton>
</Link>
<Link
v-if="resource.authorizedToUpdate"
v-tooltip="{
placement: 'bottom',
distance: 10,
skidding: 0,
content: __('Edit'),
}"
:href="$url(`/resources/${resourceName}/${resourceId}/edit`)"
class="rounded hover:bg-gray-200 dark:hover:bg-gray-800 focus:outline-none focus:ring"
dusk="edit-resource-button"
tabindex="1"
>
<BasicButton component="span">
<Icon type="pencil-alt" />
</BasicButton>
</Link>
</div>
</div>
</component>
</div>
</LoadingView>
</template>
<script>
import isNil from 'lodash/isNil'
import {
Errors,
HasCards,
InteractsWithResourceInformation,
mapProps,
} from '@/mixins'
import { minimum } from '@/util'
import { mapGetters, mapActions } from 'vuex'
export default {
props: {
shouldOverrideMeta: { type: Boolean, default: false },
showViewLink: { type: Boolean, default: false },
shouldEnableShortcut: { type: Boolean, default: false },
...mapProps([
'resourceName',
'resourceId',
'viaResource',
'viaResourceId',
'viaRelationship',
'relationshipType',
]),
},
mixins: [HasCards, InteractsWithResourceInformation],
data: () => ({
initialLoading: true,
loading: true,
title: null,
resource: null,
panels: [],
actions: [],
actionValidationErrors: new Errors(),
}),
/**
* Bind the keydown even listener when the component is created
*/
created() {
if (Nova.missingResource(this.resourceName)) return Nova.visit('/404')
if (this.shouldEnableShortcut === true) {
Nova.addShortcut('e', this.handleKeydown)
}
},
/**
* Unbind the keydown even listener when the before component is destroyed
*/
beforeUnmount() {
if (this.shouldEnableShortcut === true) {
Nova.disableShortcut('e')
}
},
/**
* Mount the component.
*/
mounted() {
this.initializeComponent()
},
methods: {
...mapActions(['startImpersonating']),
/**
* Initialize the component's data.
*/
handleResourceLoaded() {
this.loading = false
Nova.$emit('resource-loaded', {
resourceName: this.resourceName,
resourceId: this.resourceId.toString(),
mode: 'detail',
})
},
/**
* Handle the keydown event
*/
handleKeydown(e) {
if (
this.resource.authorizedToUpdate &&
e.target.tagName != 'INPUT' &&
e.target.tagName != 'TEXTAREA' &&
e.target.contentEditable != 'true'
) {
Nova.visit(`/resources/${this.resourceName}/${this.resourceId}/edit`)
}
},
/**
* Initialize the component's data.
*/
async initializeComponent() {
await this.getResource()
await this.getActions()
this.initialLoading = false
},
/**
* Get the resource information.
*/
getResource() {
this.loading = true
this.panels = null
this.resource = null
return minimum(
Nova.request().get(
'/nova-api/' + this.resourceName + '/' + this.resourceId,
{
params: {
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
viaRelationship: this.viaRelationship,
relationshipType: this.relationshipType,
},
}
)
)
.then(({ data: { title, panels, resource } }) => {
this.title = title
this.panels = panels
this.resource = resource
this.handleResourceLoaded()
})
.catch(error => {
if (error.response.status >= 500) {
Nova.$emit('error', error.response.data.message)
return
}
if (error.response.status === 404 && this.initialLoading) {
Nova.visit('/404')
return
}
if (error.response.status === 403) {
Nova.visit('/403')
return
}
if (error.response.status === 401) return Nova.redirectToLogin()
Nova.error(this.__('This resource no longer exists'))
Nova.visit(`/resources/${this.resourceName}`)
})
},
/**
* Get the available actions for the resource.
*/
async getActions() {
this.actions = []
try {
const response = await Nova.request().get(
'/nova-api/' + this.resourceName + '/actions',
{
params: {
resourceId: this.resourceId,
editing: true,
editMode: 'create',
display: 'detail',
},
}
)
this.actions = response.data?.actions
} catch (error) {
console.log(error)
Nova.error(this.__('Unable to load actions for this resource'))
}
},
/**
* Handle an action executed event.
*/
async actionExecuted() {
await this.getResource()
await this.getActions()
},
/**
* Resolve the component name.
*/
resolveComponentName(panel) {
return isNil(panel.prefixComponent) || panel.prefixComponent
? 'detail-' + panel.component
: panel.component
},
},
computed: {
...mapGetters(['currentUser']),
canBeImpersonated() {
return (
this.currentUser.canImpersonate && this.resource.authorizedToImpersonate
)
},
shouldShowActionDropdown() {
return (
this.resource && (this.actions.length > 0 || this.canModifyResource)
)
},
canModifyResource() {
return (
this.resource.authorizedToReplicate ||
this.canBeImpersonated ||
(this.resource.authorizedToDelete && !this.resource.softDeleted) ||
(this.resource.authorizedToRestore && this.resource.softDeleted) ||
this.resource.authorizedToForceDelete
)
},
/**
* Determine whether this is a detail view for an Action Event
*/
isActionDetail() {
return this.resourceName === 'action-events'
},
/**
* Get the endpoint for this resource's metrics.
*/
cardsEndpoint() {
return `/nova-api/${this.resourceName}/cards`
},
/**
* Get the extra card params to pass to the endpoint.
*/
extraCardParams() {
return {
resourceId: this.resourceId,
}
},
},
}
</script>

View File

@@ -0,0 +1,625 @@
<template>
<LoadingView
:loading="initialLoading"
:dusk="resourceName + '-index-component'"
:data-relationship="viaRelationship"
>
<template v-if="shouldOverrideMeta && resourceInformation">
<Head :title="__(`${resourceInformation.label}`)" />
</template>
<Cards
v-if="shouldShowCards"
:cards="cards"
:resource-name="resourceName"
/>
<Heading
:level="1"
class="mb-3 flex items-center"
:class="{ 'mt-6': shouldShowCards && cards.length > 0 }"
dusk="index-heading"
>
<span v-html="headingTitle" />
<button
v-if="!loading && viaRelationship"
@click="handleCollapsableChange"
class="rounded border border-transparent h-6 w-6 ml-1 inline-flex items-center justify-center focus:outline-none focus:ring ring-primary-200"
:aria-label="__('Toggle Collapsed')"
:aria-expanded="shouldBeCollapsed === false ? 'true' : 'false'"
>
<CollapseButton :collapsed="shouldBeCollapsed" />
</button>
</Heading>
<template v-if="!shouldBeCollapsed">
<div class="flex gap-2 mb-6">
<IndexSearchInput
v-if="resourceInformation && resourceInformation.searchable"
:searchable="resourceInformation && resourceInformation.searchable"
v-model:keyword="search"
@update:keyword="search = $event"
/>
<div
v-if="
availableStandaloneActions.length > 0 ||
authorizedToCreate ||
authorizedToRelate
"
class="inline-flex items-center gap-2 ml-auto"
>
<!-- Action Dropdown -->
<ActionDropdown
v-if="availableStandaloneActions.length > 0"
@actionExecuted="handleActionExecuted"
:resource-name="resourceName"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:relationship-type="relationshipType"
:actions="availableStandaloneActions"
:selected-resources="selectedResourcesForActionSelector"
trigger-dusk-attribute="index-standalone-action-dropdown"
/>
<!-- Create / Attach Button -->
<CreateResourceButton
:label="createButtonLabel"
:singular-name="singularName"
:resource-name="resourceName"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:relationship-type="relationshipType"
:authorized-to-create="authorizedToCreate"
:authorized-to-relate="authorizedToRelate"
class="shrink-0"
/>
</div>
</div>
<Card>
<ResourceTableToolbar
:action-query-string="actionQueryString"
:all-matching-resource-count="allMatchingResourceCount"
:authorized-to-delete-any-resources="authorizedToDeleteAnyResources"
:authorized-to-delete-selected-resources="
authorizedToDeleteSelectedResources
"
:authorized-to-force-delete-any-resources="
authorizedToForceDeleteAnyResources
"
:authorized-to-force-delete-selected-resources="
authorizedToForceDeleteSelectedResources
"
:authorized-to-restore-any-resources="authorizedToRestoreAnyResources"
:authorized-to-restore-selected-resources="
authorizedToRestoreSelectedResources
"
:available-actions="availableActions"
:clear-selected-filters="clearSelectedFilters"
:close-delete-modal="closeDeleteModal"
:currently-polling="currentlyPolling"
:current-page-count="resources.length"
:delete-all-matching-resources="deleteAllMatchingResources"
:delete-selected-resources="deleteSelectedResources"
:filter-changed="filterChanged"
:force-delete-all-matching-resources="forceDeleteAllMatchingResources"
:force-delete-selected-resources="forceDeleteSelectedResources"
:get-resources="getResources"
:has-filters="hasFilters"
:have-standalone-actions="haveStandaloneActions"
:lenses="lenses"
:loading="resourceResponse && loading"
:per-page-options="perPageOptions"
:per-page="perPage"
:pivot-actions="pivotActions"
:pivot-name="pivotName"
:resources="resources"
:resource-information="resourceInformation"
:resource-name="resourceName"
:restore-all-matching-resources="restoreAllMatchingResources"
:restore-selected-resources="restoreSelectedResources"
:select-all-matching-checked="selectAllMatchingResources"
@deselect="clearResourceSelections"
:selected-resources="selectedResources"
:selected-resources-for-action-selector="
selectedResourcesForActionSelector
"
:should-show-action-selector="shouldShowActionSelector"
:should-show-checkboxes="shouldShowCheckboxes"
:should-show-delete-menu="shouldShowDeleteMenu"
:should-show-polling-toggle="shouldShowPollingToggle"
:soft-deletes="softDeletes"
@start-polling="startPolling"
@stop-polling="stopPolling"
:toggle-select-all-matching="toggleSelectAllMatching"
:toggle-select-all="toggleSelectAll"
:toggle-polling="togglePolling"
:trashed-changed="trashedChanged"
:trashed-parameter="trashedParameter"
:trashed="trashed"
:update-per-page-changed="updatePerPageChanged"
:via-many-to-many="viaManyToMany"
:via-resource="viaResource"
/>
<LoadingView
:loading="loading"
:variant="!resourceResponse ? 'default' : 'overlay'"
>
<IndexErrorDialog
v-if="resourceResponseError != null"
:resource="resourceInformation"
@click="getResources"
/>
<template v-else>
<IndexEmptyDialog
v-if="!loading && !resources.length"
:create-button-label="createButtonLabel"
:singular-name="singularName"
:resource-name="resourceName"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:relationship-type="relationshipType"
:authorized-to-create="authorizedToCreate"
:authorized-to-relate="authorizedToRelate"
/>
<ResourceTable
: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"
:should-show-checkboxes="shouldShowCheckboxes"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:relationship-type="relationshipType"
:update-selection-status="updateSelectionStatus"
:sortable="sortable"
@order="orderByField"
@reset-order-by="resetOrderBy"
@delete="deleteResources"
@restore="restoreResources"
@actionExecuted="handleActionExecuted"
ref="resourceTable"
/>
<ResourcePagination
v-if="shouldShowPagination"
:pagination-component="paginationComponent"
:has-next-page="hasNextPage"
:has-previous-page="hasPreviousPage"
:load-more="loadMore"
:select-page="selectPage"
:total-pages="totalPages"
:current-page="currentPage"
:per-page="perPage"
:resource-count-label="resourceCountLabel"
:current-resource-count="currentResourceCount"
:all-matching-resource-count="allMatchingResourceCount"
/>
</template>
</LoadingView>
</Card>
</template>
</LoadingView>
</template>
<script>
// this.$refs.selectControl.selectedIndex = 0
import { CancelToken, isCancel } from 'axios'
import {
HasCards,
Paginatable,
PerPageable,
Deletable,
Collapsable,
LoadsResources,
IndexConcerns,
InteractsWithResourceInformation,
InteractsWithQueryString,
SupportsPolling,
} from '@/mixins'
import { minimum } from '@/util'
import { mapActions } from 'vuex'
export default {
name: 'ResourceIndex',
mixins: [
Collapsable,
Deletable,
HasCards,
Paginatable,
PerPageable,
LoadsResources,
IndexConcerns,
InteractsWithResourceInformation,
InteractsWithQueryString,
SupportsPolling,
],
props: {
shouldOverrideMeta: {
type: Boolean,
default: false,
},
shouldEnableShortcut: {
type: Boolean,
default: false,
},
},
data: () => ({
lenses: [],
sortable: true,
actionCanceller: null,
}),
/**
* Mount the component and retrieve its initial data.
*/
async created() {
if (!this.resourceInformation) return
// Bind the keydown event listener when the router is visited if this
// component is not a relation on a Detail page
if (this.shouldEnableShortcut === true) {
Nova.addShortcut('c', this.handleKeydown)
Nova.addShortcut('mod+a', this.toggleSelectAll)
Nova.addShortcut('mod+shift+a', this.toggleSelectAllMatching)
}
this.getLenses()
Nova.$on('refresh-resources', this.getResources)
Nova.$on('resources-detached', this.getAuthorizationToRelate)
if (this.actionCanceller !== null) this.actionCanceller()
},
/**
* Unbind the keydown even listener when the before component is destroyed
*/
beforeUnmount() {
if (this.shouldEnableShortcut) {
Nova.disableShortcut('c')
Nova.disableShortcut('mod+a')
Nova.disableShortcut('mod+shift+a')
}
Nova.$off('refresh-resources', this.getResources)
Nova.$off('resources-detached', this.getAuthorizationToRelate)
if (this.actionCanceller !== null) this.actionCanceller()
},
methods: {
...mapActions(['fetchPolicies']),
/**
* Handle the keydown event
*/
handleKeydown(e) {
// `c`
if (
this.authorizedToCreate &&
e.target.tagName !== 'INPUT' &&
e.target.tagName !== 'TEXTAREA' &&
e.target.contentEditable !== 'true'
) {
Nova.visit(`/resources/${this.resourceName}/new`)
}
},
/**
* Get the resources based on the current page, search, filters, etc.
*/
getResources() {
if (this.shouldBeCollapsed) {
this.loading = false
return
}
this.loading = true
this.resourceResponseError = null
this.$nextTick(() => {
this.clearResourceSelections()
return minimum(
Nova.request().get('/nova-api/' + this.resourceName, {
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.sortable = data.sortable
this.handleResourcesLoaded()
})
.catch(e => {
if (isCancel(e)) {
return
}
this.loading = false
this.resourceResponseError = e
throw e
})
})
},
/**
* Get the relatable authorization status for the resource.
*/
getAuthorizationToRelate() {
if (
this.shouldBeCollapsed ||
(!this.authorizedToCreate &&
this.relationshipType !== 'belongsToMany' &&
this.relationshipType !== 'morphToMany')
) {
return
}
if (!this.viaResource) {
return (this.authorizedToRelate = true)
}
return Nova.request()
.get(
'/nova-api/' +
this.resourceName +
'/relate-authorization' +
'?viaResource=' +
this.viaResource +
'&viaResourceId=' +
this.viaResourceId +
'&viaRelationship=' +
this.viaRelationship +
'&relationshipType=' +
this.relationshipType
)
.then(response => {
this.authorizedToRelate = response.data.authorized
})
},
/**
* Get the lenses available for the current resource.
*/
getLenses() {
this.lenses = []
if (this.viaResource) {
return
}
return Nova.request()
.get('/nova-api/' + this.resourceName + '/lenses')
.then(response => {
this.lenses = response.data
})
},
/**
* Get the actions available for the current resource.
*/
getActions() {
if (this.actionCanceller !== null) this.actionCanceller()
this.actions = []
this.pivotActions = null
if (this.shouldBeCollapsed) {
return
}
return Nova.request()
.get(`/nova-api/${this.resourceName}/actions`, {
params: {
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
viaRelationship: this.viaRelationship,
relationshipType: this.relationshipType,
display: 'index',
resources: this.selectAllMatchingChecked
? 'all'
: this.selectedResourceIds,
pivots: !this.selectAllMatchingChecked
? this.selectedPivotIds
: null,
},
cancelToken: new CancelToken(canceller => {
this.actionCanceller = canceller
}),
})
.then(response => {
this.actions = response.data.actions
this.pivotActions = response.data.pivotActions
this.resourceHasActions = response.data.counts.resource > 0
})
.catch(e => {
if (isCancel(e)) {
return
}
throw e
})
},
/**
* Get the count of all of the matching resources.
*/
getAllMatchingResourceCount() {
Nova.request()
.get('/nova-api/' + this.resourceName + '/count', {
params: this.resourceRequestQueryString,
})
.then(response => {
this.allMatchingResourceCount = response.data.count
})
},
/**
* 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, {
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]
if (data.total !== null) {
this.allMatchingResourceCount = data.total
} else {
this.getAllMatchingResourceCount()
}
Nova.$emit('resources-loaded', {
resourceName: this.resourceName,
mode: this.isRelation ? 'related' : 'index',
})
})
},
async handleCollapsableChange() {
this.loading = true
this.toggleCollapse()
if (!this.collapsed) {
if (!this.filterHasLoaded) {
await this.initializeFilters(null)
if (!this.hasFilters) {
await this.getResources()
}
} else {
await this.getResources()
}
await this.getAuthorizationToRelate()
await this.getActions()
this.restartPolling()
} else {
this.loading = false
}
},
},
computed: {
actionQueryString() {
return {
currentSearch: this.currentSearch,
encodedFilters: this.encodedFilters,
currentTrashed: this.currentTrashed,
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
viaRelationship: this.viaRelationship,
}
},
/**
* Determine if the index view should be collapsed.
*/
shouldBeCollapsed() {
return this.collapsed && this.viaRelationship != null
},
collapsedByDefault() {
return this.field?.collapsedByDefault ?? false
},
/**
* Get the endpoint for this resource's metrics.
*/
cardsEndpoint() {
return `/nova-api/${this.resourceName}/cards`
},
/**
* Build the resource request query string.
*/
resourceRequestQueryString() {
return {
search: this.currentSearch,
filters: this.encodedFilters,
orderBy: this.currentOrderBy,
orderByDirection: this.currentOrderByDirection,
perPage: this.currentPerPage,
trashed: this.currentTrashed,
page: this.currentPage,
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
viaRelationship: this.viaRelationship,
viaResourceRelationship: this.viaResourceRelationship,
relationshipType: this.relationshipType,
}
},
/**
* Determine whether the user is authorized to perform actions on the delete menu
*/
canShowDeleteMenu() {
return Boolean(
this.authorizedToDeleteSelectedResources ||
this.authorizedToForceDeleteSelectedResources ||
this.authorizedToRestoreSelectedResources ||
this.selectAllMatchingChecked
)
},
/**
* Return the heading for the view
*/
headingTitle() {
if (this.initialLoading) {
return '&nbsp;'
} else {
if (this.isRelation && this.field) {
return this.field.name
} else {
if (this.resourceResponse !== null) {
return this.resourceResponse.label
} else {
return this.resourceInformation.label
}
}
}
},
},
}
</script>

View File

@@ -0,0 +1,445 @@
<template>
<LoadingView :loading="initialLoading" :dusk="lens + '-lens-component'">
<Head :title="lensName" />
<Cards
v-if="shouldShowCards"
:cards="cards"
:resource-name="resourceName"
:lens="lens"
/>
<Heading
v-if="resourceResponse"
class="mb-3"
:class="{ 'mt-6': shouldShowCards }"
v-text="lensName"
dusk="lens-heading"
/>
<div
v-if="searchable || availableStandaloneActions.length > 0"
class="flex items-center mb-6"
>
<IndexSearchInput
v-if="searchable"
:searchable="searchable"
v-model:keyword="search"
@update:keyword="search = $event"
/>
<!-- Action Dropdown -->
<ActionDropdown
v-if="availableStandaloneActions.length > 0"
@actionExecuted="() => fetchPolicies()"
class="ml-auto"
:resource-name="resourceName"
:via-resource="''"
:via-resource-id="''"
:via-relationship="''"
:relationship-type="''"
:actions="availableStandaloneActions"
:selected-resources="selectedResourcesForActionSelector"
:endpoint="lensActionEndpoint"
/>
</div>
<Card>
<ResourceTableToolbar
:actions-endpoint="lensActionEndpoint"
:action-query-string="actionQueryString"
:all-matching-resource-count="allMatchingResourceCount"
:authorized-to-delete-any-resources="authorizedToDeleteAnyResources"
:authorized-to-delete-selected-resources="
authorizedToDeleteSelectedResources
"
:authorized-to-force-delete-any-resources="
authorizedToForceDeleteAnyResources
"
:authorized-to-force-delete-selected-resources="
authorizedToForceDeleteSelectedResources
"
:authorized-to-restore-any-resources="authorizedToRestoreAnyResources"
:authorized-to-restore-selected-resources="
authorizedToRestoreSelectedResources
"
:available-actions="availableActions"
:clear-selected-filters="clearSelectedFilters"
:close-delete-modal="closeDeleteModal"
:currently-polling="currentlyPolling"
:delete-all-matching-resources="deleteAllMatchingResources"
:delete-selected-resources="deleteSelectedResources"
:filter-changed="filterChanged"
:force-delete-all-matching-resources="forceDeleteAllMatchingResources"
:force-delete-selected-resources="forceDeleteSelectedResources"
:get-resources="getResources"
:has-filters="hasFilters"
:have-standalone-actions="haveStandaloneActions"
:lens="lens"
:is-lens-view="isLensView"
:per-page-options="perPageOptions"
:per-page="perPage"
:pivot-actions="pivotActions"
:pivot-name="pivotName"
:resources="resources"
:resource-information="resourceInformation"
:resource-name="resourceName"
:restore-all-matching-resources="restoreAllMatchingResources"
:restore-selected-resources="restoreSelectedResources"
:current-page-count="resources.length"
:select-all-checked="selectAllChecked"
:select-all-matching-checked="selectAllMatchingResources"
@deselect="clearResourceSelections"
:selected-resources="selectedResources"
:selected-resources-for-action-selector="
selectedResourcesForActionSelector
"
:should-show-action-selector="shouldShowActionSelector"
:should-show-checkboxes="shouldShowCheckboxes"
:should-show-delete-menu="shouldShowDeleteMenu"
:should-show-polling-toggle="shouldShowPollingToggle"
:soft-deletes="softDeletes"
@start-polling="startPolling"
@stop-polling="stopPolling"
:toggle-select-all-matching="toggleSelectAllMatching"
:toggle-select-all="toggleSelectAll"
:toggle-polling="togglePolling"
:trashed-changed="trashedChanged"
:trashed-parameter="trashedParameter"
:trashed="trashed"
:update-per-page-changed="updatePerPageChanged"
:via-many-to-many="viaManyToMany"
:via-resource="viaResource"
/>
<LoadingView
:loading="loading"
:variant="!resourceResponse ? 'default' : 'overlay'"
>
<IndexErrorDialog
v-if="resourceResponseError != null"
:resource="resourceInformation"
@click="getResources"
/>
<template v-else>
<IndexEmptyDialog
v-if="!resources.length"
:create-button-label="createButtonLabel"
:singular-name="singularName"
:resource-name="resourceName"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:relationship-type="relationshipType"
:authorized-to-create="authorizedToCreate"
:authorized-to-relate="authorizedToRelate"
/>
<ResourceTable
:authorized-to-relate="authorizedToRelate"
:resource-name="resourceName"
:resources="resources"
:singular-name="singularName"
:selected-resources="selectedResources"
:selected-resource-ids="selectedResourceIds"
:actions-are-available="actionsAreAvailable"
:actions-endpoint="lensActionEndpoint"
:should-show-checkboxes="shouldShowCheckboxes"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:relationship-type="relationshipType"
:update-selection-status="updateSelectionStatus"
:sortable="true"
@order="orderByField"
@reset-order-by="resetOrderBy"
@delete="deleteResources"
@restore="restoreResources"
@actionExecuted="getResources"
ref="resourceTable"
/>
<ResourcePagination
:pagination-component="paginationComponent"
:should-show-pagination="shouldShowPagination"
:has-next-page="hasNextPage"
:has-previous-page="hasPreviousPage"
:load-more="loadMore"
:select-page="selectPage"
:total-pages="totalPages"
:current-page="currentPage"
:per-page="perPage"
:resource-count-label="resourceCountLabel"
:current-resource-count="currentResourceCount"
:all-matching-resource-count="allMatchingResourceCount"
/>
</template>
</LoadingView>
</Card>
</LoadingView>
</template>
<script>
import {
HasCards,
Paginatable,
PerPageable,
Deletable,
IndexConcerns,
InteractsWithQueryString,
InteractsWithResourceInformation,
SupportsPolling,
} from '@/mixins'
import { CancelToken, isCancel } from 'axios'
import { minimum } from '@/util'
import { mapActions } from 'vuex'
export default {
mixins: [
HasCards,
Deletable,
Paginatable,
PerPageable,
IndexConcerns,
InteractsWithResourceInformation,
InteractsWithQueryString,
SupportsPolling,
],
name: 'Lens',
props: {
lens: {
type: String,
required: true,
},
searchable: {
type: Boolean,
required: true,
},
},
data: () => ({
actionCanceller: null,
resourceHasId: false,
}),
/**
* Mount the component and retrieve its initial data.
*/
async created() {
if (!this.resourceInformation) {
return
}
this.getActions()
Nova.$on('refresh-resources', this.getResources)
},
beforeUnmount() {
Nova.$off('refresh-resources', this.getResources)
if (this.actionCanceller !== null) this.actionCanceller()
},
methods: {
...mapActions(['fetchPolicies']),
/**
* Get the resources based on the current page, search, filters, etc.
*/
getResources() {
this.loading = true
this.resourceResponseError = null
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.resourceHasId = data.hasId
this.handleResourcesLoaded()
})
.catch(e => {
if (isCancel(e)) {
return
}
this.loading = false
this.resourceResponseError = e
throw e
})
})
},
/**
* Get the actions available for the current resource.
*/
getActions() {
if (this.actionCanceller !== null) this.actionCanceller()
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',
resources: this.selectAllMatchingChecked
? 'all'
: this.selectedResourceIds,
},
cancelToken: new CancelToken(canceller => {
this.actionCanceller = canceller
}),
})
.then(response => {
this.actions = response.data.actions
this.pivotActions = response.data.pivotActions
this.resourceHasActions = response.data.counts.resource > 0
})
.catch(e => {
if (isCancel(e)) {
return
}
throw e
})
},
/**
* 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
})
},
/**
* 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', {
resourceName: this.resourceName,
lens: this.lens,
mode: 'lens',
})
})
},
},
computed: {
actionQueryString() {
return {
currentSearch: this.currentSearch,
encodedFilters: this.encodedFilters,
currentTrashed: this.currentTrashed,
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
viaRelationship: this.viaRelationship,
}
},
actionsAreAvailable() {
return this.allActions.length > 0 && Boolean(this.resourceHasId)
},
/**
* Get the endpoint for this resource's actions.
*/
lensActionEndpoint() {
return `/nova-api/${this.resourceName}/lens/${this.lens}/action`
},
/**
* Get the endpoint for this resource's metrics.
*/
cardsEndpoint() {
return `/nova-api/${this.resourceName}/lens/${this.lens}/cards`
},
/**
* Determine whether the user is authorized to perform actions on the delete menu
*/
canShowDeleteMenu() {
return (
Boolean(this.resourceHasId) &&
Boolean(
this.authorizedToDeleteSelectedResources ||
this.authorizedToForceDeleteSelectedResources ||
this.authorizedToDeleteAnyResources ||
this.authorizedToForceDeleteAnyResources ||
this.authorizedToRestoreSelectedResources ||
this.authorizedToRestoreAnyResources
)
)
},
/**
* The Lens name.
*/
lensName() {
if (this.resourceResponse) {
return this.resourceResponse.name
}
},
},
}
</script>

View File

@@ -0,0 +1,382 @@
<template>
<LoadingView :loading="loading">
<template v-if="resourceInformation && title">
<Head
:title="
__('Update :resource: :title', {
resource: resourceInformation.singularLabel,
title: title,
})
"
/>
</template>
<form
v-if="panels"
@submit="submitViaUpdateResource"
@change="onUpdateFormStatus"
:data-form-unique-id="formUniqueId"
autocomplete="off"
ref="form"
>
<div class="mb-8 space-y-4">
<component
v-for="panel in panels"
:key="panel.id"
:is="'form-' + panel.component"
@update-last-retrieved-at-timestamp="updateLastRetrievedAtTimestamp"
@file-deleted="handleFileDeleted"
@field-changed="onUpdateFormStatus"
@file-upload-started="handleFileUploadStarted"
@file-upload-finished="handleFileUploadFinished"
:panel="panel"
:name="panel.name"
:resource-id="resourceId"
:resource-name="resourceName"
:fields="panel.fields"
:form-unique-id="formUniqueId"
mode="form"
:validation-errors="validationErrors"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:show-help-text="true"
/>
</div>
<!-- Update Button -->
<div
class="flex flex-col md:flex-row md:items-center justify-center md:justify-end space-y-2 md:space-y-0 md:space-x-3"
>
<Button
dusk="cancel-update-button"
variant="ghost"
:label="__('Cancel')"
@click="cancelUpdatingResource"
:disabled="isWorking"
/>
<Button
dusk="update-and-continue-editing-button"
@click="submitViaUpdateResourceAndContinueEditing"
:disabled="isWorking"
:loading="wasSubmittedViaUpdateResourceAndContinueEditing"
:label="__('Update & Continue Editing')"
/>
<Button
dusk="update-button"
type="submit"
:disabled="isWorking"
:loading="wasSubmittedViaUpdateResource"
:label="updateButtonLabel"
/>
</div>
</form>
</LoadingView>
</template>
<script>
import each from 'lodash/each'
import tap from 'lodash/tap'
import {
HandlesFormRequest,
HandlesUploads,
InteractsWithResourceInformation,
mapProps,
PreventsFormAbandonment,
} from '@/mixins'
import { mapActions } from 'vuex'
import { Button } from 'laravel-nova-ui'
export default {
components: {
Button,
},
mixins: [
HandlesFormRequest,
HandlesUploads,
InteractsWithResourceInformation,
PreventsFormAbandonment,
],
provide() {
return {
removeFile: this.removeFile,
}
},
props: mapProps([
'resourceName',
'resourceId',
'viaResource',
'viaResourceId',
'viaRelationship',
]),
data: () => ({
relationResponse: null,
loading: true,
submittedViaUpdateResourceAndContinueEditing: false,
submittedViaUpdateResource: false,
title: null,
fields: [],
panels: [],
lastRetrievedAt: null,
}),
async created() {
if (Nova.missingResource(this.resourceName)) return Nova.visit('/404')
// If this update is via a relation index, then let's grab the field
// and use the label for that as the one we use for the title and buttons
if (this.isRelation) {
const { data } = await Nova.request().get(
`/nova-api/${this.viaResource}/field/${this.viaRelationship}`,
{ params: { relatable: true } }
)
this.relationResponse = data
}
this.getFields()
this.updateLastRetrievedAtTimestamp()
this.allowLeavingForm()
},
methods: {
...mapActions(['fetchPolicies']),
handleFileDeleted() {
//
},
removeFile(attribute) {
const { resourceName, resourceId } = this
Nova.request().delete(
`/nova-api/${resourceName}/${resourceId}/field/${attribute}`
)
},
/**
* Handle resource loaded event.
*/
handleResourceLoaded() {
this.loading = false
Nova.$emit('resource-loaded', {
resourceName: this.resourceName,
resourceId: this.resourceId.toString(),
mode: 'update',
})
},
/**
* Get the available fields for the resource.
*/
async getFields() {
this.loading = true
this.panels = []
this.fields = []
const {
data: { title, panels, fields },
} = await Nova.request()
.get(
`/nova-api/${this.resourceName}/${this.resourceId}/update-fields`,
{
params: {
editing: true,
editMode: 'update',
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
viaRelationship: this.viaRelationship,
},
}
)
.catch(error => {
if (error.response.status == 404) {
Nova.visit('/404')
return
}
})
this.title = title
this.panels = panels
this.fields = fields
this.handleResourceLoaded()
},
async submitViaUpdateResource(e) {
e.preventDefault()
this.submittedViaUpdateResource = true
this.submittedViaUpdateResourceAndContinueEditing = false
this.allowLeavingForm()
await this.updateResource()
},
async submitViaUpdateResourceAndContinueEditing(e) {
e.preventDefault()
this.submittedViaUpdateResourceAndContinueEditing = true
this.submittedViaUpdateResource = false
this.allowLeavingForm()
await this.updateResource()
},
cancelUpdatingResource() {
this.handleProceedingToPreviousPage()
this.allowLeavingForm()
this.proceedToPreviousPage(
this.isRelation
? `/resources/${this.viaResource}/${this.viaResourceId}`
: `/resources/${this.resourceName}/${this.resourceId}`
)
},
/**
* Update the resource using the provided data.
*/
async updateResource() {
this.isWorking = true
if (this.$refs.form.reportValidity()) {
try {
const {
data: { redirect, id },
} = await this.updateRequest()
await this.fetchPolicies()
Nova.success(
this.__('The :resource was updated!', {
resource: this.resourceInformation.singularLabel.toLowerCase(),
})
)
Nova.$emit('resource-updated', {
resourceName: this.resourceName,
resourceId: id,
})
await this.updateLastRetrievedAtTimestamp()
if (this.submittedViaUpdateResource) {
Nova.visit(redirect)
} else {
if (id != this.resourceId) {
Nova.visit(`/resources/${this.resourceName}/${id}/edit`)
} else {
window.scrollTo(0, 0)
this.disableNavigateBackUsingHistory()
// Reset the form by refetching the fields
this.getFields()
this.resetErrors()
this.submittedViaUpdateResource = false
this.submittedViaUpdateResourceAndContinueEditing = false
this.isWorking = false
}
return
}
} catch (error) {
window.scrollTo(0, 0)
this.submittedViaUpdateResource = false
this.submittedViaUpdateResourceAndContinueEditing = false
this.preventLeavingForm()
this.handleOnUpdateResponseError(error)
}
}
this.submittedViaUpdateResource = false
this.submittedViaUpdateResourceAndContinueEditing = false
this.isWorking = false
},
/**
* Send an update request for this resource
*/
updateRequest() {
return Nova.request().post(
`/nova-api/${this.resourceName}/${this.resourceId}`,
this.updateResourceFormData(),
{
params: {
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
viaRelationship: this.viaRelationship,
editing: true,
editMode: 'update',
},
}
)
},
/**
* Create the form data for creating the resource.
*/
updateResourceFormData() {
return tap(new FormData(), formData => {
each(this.panels, panel => {
each(panel.fields, field => {
field.fill(formData)
})
})
formData.append('_method', 'PUT')
formData.append('_retrieved_at', this.lastRetrievedAt)
})
},
/**
* Update the last retrieved at timestamp to the current UNIX timestamp.
*/
updateLastRetrievedAtTimestamp() {
this.lastRetrievedAt = Math.floor(new Date().getTime() / 1000)
},
/**
* Prevent accidental abandonment only if form was changed.
*/
onUpdateFormStatus() {
this.updateFormStatus()
},
},
computed: {
wasSubmittedViaUpdateResourceAndContinueEditing() {
return this.isWorking && this.submittedViaUpdateResourceAndContinueEditing
},
wasSubmittedViaUpdateResource() {
return this.isWorking && this.submittedViaUpdateResource
},
singularName() {
if (this.relationResponse) {
return this.relationResponse.singularLabel
}
return this.resourceInformation.singularLabel
},
updateButtonLabel() {
return this.resourceInformation.updateButtonLabel
},
isRelation() {
return Boolean(this.viaResourceId && this.viaRelationship)
},
},
}
</script>

View File

@@ -0,0 +1,572 @@
<template>
<LoadingView :loading="initialLoading">
<template v-if="relatedResourceLabel && title">
<Head
:title="
__('Update attached :resource: :title', {
resource: relatedResourceLabel,
title: title,
})
"
/>
</template>
<Heading class="mb-3" v-if="relatedResourceLabel && title">
{{
__('Update attached :resource: :title', {
resource: relatedResourceLabel,
title: title,
})
}}
</Heading>
<form
v-if="field"
@submit.prevent="updateAttachedResource"
@change="onUpdateFormStatus"
:data-form-unique-id="formUniqueId"
autocomplete="off"
>
<Card class="mb-8">
<!-- Related Resource -->
<div
v-if="parentResource"
dusk="via-resource-field"
class="field-wrapper flex flex-col md:flex-row border-b border-gray-100 dark:border-gray-700"
>
<div class="w-1/5 px-8 py-6">
<label
:for="parentResource.name"
class="inline-block text-gray-500 pt-2 leading-tight"
>
{{ parentResource.name }}
</label>
</div>
<div class="py-6 px-8 w-1/2">
<span class="inline-block font-bold text-gray-500 pt-2">
{{ parentResource.display }}
</span>
</div>
</div>
<DefaultField
:field="field"
:errors="validationErrors"
:show-help-text="true"
>
<template #field>
<SelectControl
class="w-full"
dusk="attachable-select"
:class="{
'form-control-bordered-error': validationErrors.has(
field.attribute
),
}"
v-model:selected="selectedResourceId"
@change="selectResourceFromSelectControl"
disabled
:options="availableResources"
:label="'display'"
>
<option value="" disabled selected>
{{ __('Choose :field', { field: field.name }) }}
</option>
</SelectControl>
</template>
</DefaultField>
<LoadingView :loading="loading">
<!-- Pivot Fields -->
<div v-for="field in fields">
<component
:is="'form-' + field.component"
:resource-name="resourceName"
:resource-id="resourceId"
:field="field"
:form-unique-id="formUniqueId"
:errors="validationErrors"
:related-resource-name="relatedResourceName"
:related-resource-id="relatedResourceId"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:show-help-text="true"
/>
</div>
</LoadingView>
</Card>
<!-- Attach Button -->
<div
class="flex flex-col mt-3 md:mt-6 md:flex-row items-center justify-center md:justify-end"
>
<Button
dusk="cancel-update-attached-button"
@click="cancelUpdatingAttachedResource"
:label="__('Cancel')"
variant="ghost"
/>
<Button
class="mr-3"
dusk="update-and-continue-editing-button"
@click.prevent="updateAndContinueEditing"
:disabled="isWorking"
:loading="submittedViaUpdateAndContinueEditing"
>
{{ __('Update & Continue Editing') }}
</Button>
<Button
dusk="update-button"
type="submit"
:disabled="isWorking"
:loading="submittedViaUpdateAttachedResource"
>
{{
__('Update :resource', {
resource: relatedResourceLabel,
})
}}
</Button>
</div>
</form>
</LoadingView>
</template>
<script>
import each from 'lodash/each'
import find from 'lodash/find'
import isNil from 'lodash/isNil'
import tap from 'lodash/tap'
import {
PerformsSearches,
TogglesTrashed,
FormEvents,
PreventsFormAbandonment,
HandlesFormRequest,
} from '@/mixins'
import { mapActions } from 'vuex'
import { Button } from 'laravel-nova-ui'
export default {
components: {
Button,
},
mixins: [
FormEvents,
HandlesFormRequest,
PerformsSearches,
TogglesTrashed,
PreventsFormAbandonment,
],
provide() {
return {
removeFile: this.removeFile,
}
},
props: {
resourceName: {
type: String,
required: true,
},
resourceId: {
required: true,
},
relatedResourceName: {
type: String,
required: true,
},
relatedResourceId: {
required: true,
},
viaResource: {
default: '',
},
viaResourceId: {
default: '',
},
parentResource: {
type: Object,
},
viaRelationship: {
default: '',
},
viaPivotId: {
default: null,
},
polymorphic: {
default: false,
},
},
data: () => ({
initialLoading: true,
loading: true,
submittedViaUpdateAndContinueEditing: false,
submittedViaUpdateAttachedResource: false,
field: null,
softDeletes: false,
fields: [],
selectedResource: null,
selectedResourceId: null,
lastRetrievedAt: null,
title: null,
}),
created() {
if (Nova.missingResource(this.resourceName)) return Nova.visit('/404')
},
/**
* Mount the component.
*/
mounted() {
this.initializeComponent()
},
methods: {
...mapActions(['fetchPolicies']),
/**
* Initialize the component's data.
*/
async initializeComponent() {
this.softDeletes = false
this.disableWithTrashed()
this.clearSelection()
await this.getField()
await this.getPivotFields()
await this.getAvailableResources()
this.resetErrors()
this.selectedResourceId = this.relatedResourceId
this.selectInitialResource()
this.updateLastRetrievedAtTimestamp()
this.allowLeavingForm()
},
removeFile(attribute) {
const {
resourceName,
resourceId,
relatedResourceName,
relatedResourceId,
viaRelationship,
} = this
const uri = Nova.request().delete(
`/nova-api/${resourceName}/${resourceId}/${relatedResourceName}/${relatedResourceId}/field/${attribute}?viaRelationship=${viaRelationship}`
)
},
/**
* Handle pivot fields loaded event.
*/
handlePivotFieldsLoaded() {
this.loading = false
each(this.fields, field => {
if (field) {
field.fill = () => ''
}
})
},
/**
* Get the many-to-many relationship field.
*/
async getField() {
this.field = null
const { data: field } = await Nova.request().get(
'/nova-api/' + this.resourceName + '/field/' + this.viaRelationship,
{
params: {
relatable: true,
},
}
)
this.field = field
if (this.field.searchable) {
this.determineIfSoftDeletes()
}
this.initialLoading = false
},
/**
* Get all of the available pivot fields for the relationship.
*/
async getPivotFields() {
this.fields = []
const {
data: { title, fields },
} = await Nova.request()
.get(
`/nova-api/${this.resourceName}/${this.resourceId}/update-pivot-fields/${this.relatedResourceName}/${this.relatedResourceId}`,
{
params: {
editing: true,
editMode: 'update-attached',
viaRelationship: this.viaRelationship,
viaPivotId: this.viaPivotId,
},
}
)
.catch(error => {
if (error.response.status == 404) {
Nova.visit('/404')
return
}
})
this.title = title
this.fields = fields
this.handlePivotFieldsLoaded()
},
/**
* Get all of the available resources for the current search / trashed state.
*/
async getAvailableResources(search = '') {
try {
const response = await Nova.request().get(
`/nova-api/${this.resourceName}/${this.resourceId}/attachable/${this.relatedResourceName}`,
{
params: {
search,
current: this.relatedResourceId,
first: true,
withTrashed: this.withTrashed,
viaRelationship: this.viaRelationship,
},
}
)
this.availableResources = response.data.resources
this.withTrashed = response.data.withTrashed
this.softDeletes = response.data.softDeletes
} catch (error) {}
},
/**
* Determine if the related resource is soft deleting.
*/
determineIfSoftDeletes() {
Nova.request()
.get('/nova-api/' + this.relatedResourceName + '/soft-deletes')
.then(response => {
this.softDeletes = response.data.softDeletes
})
},
/**
* Update the attached resource.
*/
async updateAttachedResource() {
this.submittedViaUpdateAttachedResource = true
try {
await this.updateRequest()
this.submittedViaUpdateAttachedResource = false
this.allowLeavingForm()
await this.fetchPolicies(),
Nova.success(this.__('The resource was updated!'))
Nova.visit(`/resources/${this.resourceName}/${this.resourceId}`)
} catch (error) {
window.scrollTo(0, 0)
this.submittedViaUpdateAttachedResource = false
this.preventLeavingForm()
this.handleOnUpdateResponseError(error)
}
},
/**
* Update the resource and reset the form
*/
async updateAndContinueEditing() {
this.submittedViaUpdateAndContinueEditing = true
try {
await this.updateRequest()
window.scrollTo(0, 0)
this.disableNavigateBackUsingHistory()
this.allowLeavingForm()
this.submittedViaUpdateAndContinueEditing = false
Nova.success(this.__('The resource was updated!'))
// Reset the form by refetching the fields
this.initializeComponent()
} catch (error) {
this.submittedViaUpdateAndContinueEditing = false
this.handleOnUpdateResponseError(error)
}
},
cancelUpdatingAttachedResource() {
this.handleProceedingToPreviousPage()
this.allowLeavingForm()
this.proceedToPreviousPage(
`/resources/${this.resourceName}/${this.resourceId}`
)
},
/**
* Send an update request for this resource
*/
updateRequest() {
return Nova.request().post(
`/nova-api/${this.resourceName}/${this.resourceId}/update-attached/${this.relatedResourceName}/${this.relatedResourceId}`,
this.updateAttachmentFormData(),
{
params: {
editing: true,
editMode: 'update-attached',
viaPivotId: this.viaPivotId,
},
}
)
},
/*
* Get the form data for the resource attachment update.
*/
updateAttachmentFormData() {
return tap(new FormData(), formData => {
each(this.fields, field => {
field.fill(formData)
})
formData.append('viaRelationship', this.viaRelationship)
if (!this.selectedResource) {
formData.append(this.relatedResourceName, '')
} else {
formData.append(this.relatedResourceName, this.selectedResource.value)
}
formData.append(this.relatedResourceName + '_trashed', this.withTrashed)
formData.append('_retrieved_at', this.lastRetrievedAt)
})
},
/**
* Select a resource using the <select> control
*/
selectResourceFromSelectControl(value) {
this.selectedResourceId = value
this.selectInitialResource()
if (this.field) {
this.emitFieldValueChange(this.fieldAttribute, this.selectedResourceId)
}
},
/**
* Toggle the trashed state of the search
*/
toggleWithTrashed() {
this.withTrashed = !this.withTrashed
// Reload the data if the component doesn't support searching
if (!this.isSearchable) {
this.getAvailableResources()
}
},
/**
* Select the initial selected resource
*/
selectInitialResource() {
this.selectedResource = find(
this.availableResources,
r => r.value == this.selectedResourceId
)
},
/**
* Update the last retrieved at timestamp to the current UNIX timestamp.
*/
updateLastRetrievedAtTimestamp() {
this.lastRetrievedAt = Math.floor(new Date().getTime() / 1000)
},
/**
* Prevent accidental abandonment only if form was changed.
*/
onUpdateFormStatus() {
this.updateFormStatus()
},
},
computed: {
/**
* Get the attachment endpoint for the relationship type.
*/
attachmentEndpoint() {
return this.polymorphic
? '/nova-api/' +
this.resourceName +
'/' +
this.resourceId +
'/attach-morphed/' +
this.relatedResourceName
: '/nova-api/' +
this.resourceName +
'/' +
this.resourceId +
'/attach/' +
this.relatedResourceName
},
/**
* Get the label for the related resource.
*/
relatedResourceLabel() {
if (this.field) {
return this.field.singularLabel
}
},
/**
* Determine if the related resources is searchable
*/
isSearchable() {
return this.field.searchable
},
/**
* Determine if the form is being processed
*/
isWorking() {
return (
this.submittedViaUpdateAttachedResource ||
this.submittedViaUpdateAndContinueEditing
)
},
},
}
</script>