add nova
This commit is contained in:
666
nova/resources/js/views/Attach.vue
Normal file
666
nova/resources/js/views/Attach.vue
Normal 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>
|
||||
112
nova/resources/js/views/Create.vue
Normal file
112
nova/resources/js/views/Create.vue
Normal 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>
|
||||
20
nova/resources/js/views/CustomAppError.vue
Normal file
20
nova/resources/js/views/CustomAppError.vue
Normal 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') }}…</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>
|
||||
20
nova/resources/js/views/CustomError403.vue
Normal file
20
nova/resources/js/views/CustomError403.vue
Normal 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")
|
||||
}}…
|
||||
</p>
|
||||
</ErrorLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ErrorLayout from '@/layouts/ErrorLayout'
|
||||
|
||||
export default {
|
||||
components: { ErrorLayout },
|
||||
}
|
||||
</script>
|
||||
22
nova/resources/js/views/CustomError404.vue
Normal file
22
nova/resources/js/views/CustomError404.vue
Normal 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') }}…</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>
|
||||
119
nova/resources/js/views/Dashboard.vue
Normal file
119
nova/resources/js/views/Dashboard.vue
Normal 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>
|
||||
366
nova/resources/js/views/Detail.vue
Normal file
366
nova/resources/js/views/Detail.vue
Normal 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>
|
||||
625
nova/resources/js/views/Index.vue
Normal file
625
nova/resources/js/views/Index.vue
Normal 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 ' '
|
||||
} 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>
|
||||
445
nova/resources/js/views/Lens.vue
Normal file
445
nova/resources/js/views/Lens.vue
Normal 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>
|
||||
382
nova/resources/js/views/Update.vue
Normal file
382
nova/resources/js/views/Update.vue
Normal 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>
|
||||
572
nova/resources/js/views/UpdateAttached.vue
Normal file
572
nova/resources/js/views/UpdateAttached.vue
Normal 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>
|
||||
Reference in New Issue
Block a user