Последняя версия с сервера прошлого разработчика

This commit is contained in:
2025-07-10 04:35:51 +00:00
commit c731570032
1174 changed files with 134314 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
<template>
<error-403 />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<error-404 />
</template>

View File

@@ -0,0 +1,562 @@
<template>
<loading-view :loading="loading">
<custom-attach-header
class="mb-3"
:resource-name="resourceName"
:resource-id="resourceId"
/>
<heading class="mb-3">{{
__('Attach :resource', { resource: relatedResourceLabel })
}}</heading>
<form
v-if="field"
@submit.prevent="attachResource"
@change="onUpdateFormStatus"
autocomplete="off"
>
<card class="overflow-hidden mb-8">
<!-- Related Resource -->
<div
v-if="viaResourceField"
dusk="via-resource-field"
class="flex border-b border-40"
>
<div class="w-1/5 px-8 py-6">
<label
:for="viaResourceField.name"
class="inline-block text-80 pt-2 leading-tight"
>
{{ viaResourceField.name }}
</label>
</div>
<div class="py-6 px-8 w-1/2">
<span class="inline-block font-bold text-80 pt-2">
{{ viaResourceField.display }}
</span>
</div>
</div>
<default-field
:field="field"
:errors="validationErrors"
:show-help-text="field.helpText != null"
>
<template slot="field">
<search-input
v-if="field.searchable"
:data-testid="`${field.resourceName}-search-input`"
@input="performSearch"
@clear="clearSelection"
@selected="selectResource"
:debounce="field.debounce"
:value="selectedResource"
:data="availableResources"
trackBy="value"
>
<div
slot="default"
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>
<div
slot="option"
slot-scope="{ option, selected }"
class="flex items-center"
>
<div v-if="option.avatar" class="mr-3">
<img
:src="option.avatar"
class="w-8 h-8 rounded-full block"
/>
</div>
<div>
<div
class="text-sm font-semibold leading-5 text-90"
:class="{ 'text-white': selected }"
>
{{ option.display }}
</div>
<div
v-if="field.withSubtitles"
class="mt-1 text-xs font-semibold leading-5 text-80"
:class="{ 'text-white': selected }"
>
<span v-if="option.subtitle">{{ option.subtitle }}</span>
<span v-else>{{ __('No additional information...') }}</span>
</div>
</div>
</div>
</search-input>
<select-control
v-else
dusk="attachable-select"
class="form-control form-select w-full"
:class="{
'border-danger': validationErrors.has(field.attribute),
}"
:data-testid="`${field.resourceName}-select`"
@change="selectResourceFromSelectControl"
:options="availableResources"
:label="'display'"
:selected="selectedResourceId"
>
<option value="" disabled selected>
{{
__('Choose :resource', {
resource: relatedResourceLabel,
})
}}
</option>
</select-control>
<!-- Trashed State -->
<div v-if="softDeletes" class="mt-3">
<checkbox-with-label
:dusk="field.resourceName + '-with-trashed-checkbox'"
:checked="withTrashed"
@input="toggleWithTrashed"
>
{{ __('With Trashed') }}
</checkbox-with-label>
</div>
</template>
</default-field>
<!-- Pivot Fields -->
<div v-for="field in fields">
<component
:is="'form-' + field.component"
:resource-name="resourceName"
:field="field"
:errors="validationErrors"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:show-help-text="field.helpText != null"
/>
</div>
</card>
<!-- Attach Button -->
<div class="flex items-center">
<cancel-button @click="$router.back()" />
<progress-button
class="mr-3"
dusk="attach-and-attach-another-button"
@click.native="attachAndAttachAnother"
:disabled="isWorking"
:processing="submittedViaAttachAndAttachAnother"
>
{{ __('Attach & Attach Another') }}
</progress-button>
<progress-button
dusk="attach-button"
type="submit"
:disabled="isWorking"
:processing="submittedViaAttachResource"
>
{{
__('Attach :resource', {
resource: relatedResourceLabel,
})
}}
</progress-button>
</div>
</form>
</loading-view>
</template>
<script>
import {
PerformsSearches,
TogglesTrashed,
Errors,
PreventsFormAbandonment,
} from 'laravel-nova'
export default {
mixins: [PerformsSearches, TogglesTrashed, PreventsFormAbandonment],
metaInfo() {
if (this.relatedResourceLabel) {
return {
title: this.__('Attach :resource', {
resource: this.relatedResourceLabel,
}),
}
}
},
props: {
resourceName: {
type: String,
required: true,
},
resourceId: {
required: true,
},
relatedResourceName: {
type: String,
required: true,
},
viaResource: {
default: '',
},
viaResourceId: {
default: '',
},
viaRelationship: {
default: '',
},
polymorphic: {
default: false,
},
},
data: () => ({
loading: true,
submittedViaAttachAndAttachAnother: false,
submittedViaAttachResource: false,
viaResourceField: null,
field: null,
softDeletes: false,
fields: [],
validationErrors: new Errors(),
selectedResource: null,
selectedResourceId: null,
}),
created() {
if (Nova.missingResource(this.resourceName))
return this.$router.push({ name: '404' })
},
/**
* Mount the component.
*/
mounted() {
this.initializeComponent()
},
methods: {
/**
* Initialize the component's data.
*/
initializeComponent() {
this.softDeletes = false
this.disableWithTrashed()
this.clearSelection()
this.getField()
this.getPivotFields()
this.resetErrors()
},
/**
* 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.loading = false
})
},
/**
* Get all of the available pivot fields for the relationship.
*/
getPivotFields() {
this.fields = []
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
_.each(this.fields, field => {
field.fill = () => ''
})
})
},
resetErrors() {
this.validationErrors = new Errors()
},
/**
* Get all of the available resources for the current search / trashed state.
*/
getAvailableResources(search = '') {
Nova.request()
.get(
`/nova-api/${this.resourceName}/${this.resourceId}/attachable/${this.relatedResourceName}`,
{
params: {
search,
current: this.selectedResourceId,
withTrashed: this.withTrashed,
},
}
)
.then(response => {
this.viaResourceField = response.data.viaResource
this.availableResources = response.data.resources
this.withTrashed = response.data.withTrashed
this.softDeletes = response.data.softDeletes
})
},
/**
* 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.canLeave = true
this.$router.push({
name: 'detail',
params: {
resourceName: this.resourceName,
resourceId: this.resourceId,
},
})
} catch (error) {
window.scrollTo(0, 0)
this.submittedViaAttachResource = false
if (
this.resourceInformation &&
this.resourceInformation.preventFormAbandonment
) {
this.canLeave = false
}
if (error.response.status == 422) {
this.validationErrors = new Errors(error.response.data.errors)
Nova.error(this.__('There was a problem submitting the form.'))
}
}
},
/**
* Attach a new resource and reset the form
*/
async attachAndAttachAnother() {
this.submittedViaAttachAndAttachAnother = true
try {
await this.attachRequest()
this.submittedViaAttachAndAttachAnother = false
// Reset the form by refetching the fields
this.initializeComponent()
} catch (error) {
this.submittedViaAttachAndAttachAnother = false
if (error.response.status == 422) {
this.validationErrors = new Errors(error.response.data.errors)
Nova.error(this.__('There was a problem submitting the form.'))
}
}
},
/**
* Send an attach request for this resource
*/
attachRequest() {
return Nova.request().post(
this.attachmentEndpoint,
this.attachmentFormData,
{
params: {
editing: true,
editMode: 'attach',
},
}
)
},
/**
* Select a resource using the <select> control
*/
selectResourceFromSelectControl(e) {
this.selectedResourceId = e.target.value
this.selectInitialResource()
if (this.field) {
Nova.$emit(this.field.attribute + '-change', 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() {
if (
this.resourceInformation &&
this.resourceInformation.preventFormAbandonment
) {
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 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)
})
},
/**
* 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,
})
},
},
}
</script>

View File

@@ -0,0 +1,56 @@
<template>
<create-form
@resource-created="handleResourceCreated"
@cancelled-create="handleCancelledCreate"
:mode="mode"
:resource-name="resourceName"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:update-form-status="updateFormStatus"
:should-override-meta="mode == 'form' ? true : false"
/>
</template>
<script>
import { mapProps, PreventsFormAbandonment } from 'laravel-nova'
export default {
mixins: [PreventsFormAbandonment],
props: {
mode: {
type: String,
default: 'form',
validator: val => ['modal', 'form'].includes(val),
},
...mapProps([
'resourceName',
'viaResource',
'viaResourceId',
'viaRelationship',
]),
},
methods: {
handleResourceCreated({ redirect, id }) {
this.canLeave = true
if (this.mode == 'form') {
return this.$router.push({ path: redirect })
}
return this.$emit('refresh', { redirect, id })
},
handleCancelledCreate() {
if (this.mode == 'form') {
return this.$router.back()
}
return this.$emit('cancelled-create')
},
},
}
</script>

View File

@@ -0,0 +1,100 @@
<template>
<div :dusk="'dashboard-' + this.name">
<custom-dashboard-header class="mb-3" :dashboard-name="name" />
<heading v-if="cards.length > 1" class="mb-6">{{
__('Dashboard')
}}</heading>
<div v-if="shouldShowCards">
<cards v-if="smallCards.length > 0" :cards="smallCards" class="mb-3" />
<cards v-if="largeCards.length > 0" :cards="largeCards" size="large" />
</div>
</div>
</template>
<script>
import { CardSizes } from 'laravel-nova'
export default {
metaInfo() {
return {
title: `${this.label}`,
}
},
data: () => ({ label: '', cards: '' }),
props: {
name: {
type: String,
required: false,
default: 'main',
},
},
watch: {
name() {
this.fetchDashboard()
},
},
created() {
this.fetchDashboard()
},
methods: {
async fetchDashboard() {
const {
data: { label, cards },
} = await Nova.request()
.get(this.dashboardEndpoint, {
params: this.extraCardParams,
})
.catch(e => {
this.$router.push({ name: '404' })
})
this.label = label
this.cards = cards
},
},
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
},
/**
* Return the small cards used for the Dashboard
*/
smallCards() {
return _.filter(this.cards, c => CardSizes.indexOf(c.width) !== -1)
},
/**
* Return the full-width cards used for the Dashboard
*/
largeCards() {
return _.filter(this.cards, c => c.width == 'full')
},
/**
* Get the extra card params to pass to the endpoint.
*/
extraCardParams() {
return null
},
},
}
</script>

View File

@@ -0,0 +1,548 @@
<template>
<loading-view :loading="initialLoading">
<custom-detail-header
class="mb-3"
:resource="resource"
:resource-id="resourceId"
:resource-name="resourceName"
/>
<div v-if="shouldShowCards">
<cards
v-if="smallCards.length > 0"
:cards="smallCards"
class="mb-3"
:resource="resource"
:resource-id="resourceId"
:resource-name="resourceName"
:only-on-detail="true"
/>
<cards
v-if="largeCards.length > 0"
:cards="largeCards"
size="large"
:resource="resource"
:resource-id="resourceId"
:resource-name="resourceName"
:only-on-detail="true"
/>
</div>
<!-- Resource Detail -->
<div
v-for="panel in availablePanels"
:dusk="resourceName + '-detail-component'"
class="mb-8"
:key="panel.id"
>
<component
:is="panel.component"
:resource-name="resourceName"
:resource-id="resourceId"
:resource="resource"
:panel="panel"
>
<div v-if="panel.showToolbar" class="flex items-center mb-3">
<heading :level="1" class="flex-auto truncate">{{
panel.name
}}</heading>
<div class="ml-3 flex items-center">
<custom-detail-toolbar
:resource="resource"
:resource-name="resourceName"
:resource-id="resourceId"
/>
<!-- Actions -->
<action-selector
v-if="resource"
:resource-name="resourceName"
:actions="actions"
:pivot-actions="{ actions: [] }"
:selected-resources="selectedResources"
:query-string="{
currentSearch,
encodedFilters,
currentTrashed,
viaResource,
viaResourceId,
viaRelationship,
}"
@actionExecuted="actionExecuted"
class="ml-3"
/>
<button
v-if="resource.authorizedToDelete && !resource.softDeleted"
data-testid="open-delete-modal"
dusk="open-delete-modal-button"
@click="openDeleteModal"
class="btn btn-default btn-icon btn-white mr-3"
:title="__('Delete')"
>
<icon type="delete" class="text-80" />
</button>
<button
v-if="resource.authorizedToRestore && resource.softDeleted"
data-testid="open-restore-modal"
dusk="open-restore-modal-button"
@click="openRestoreModal"
class="btn btn-default btn-icon btn-white mr-3"
:title="__('Restore')"
>
<icon type="restore" class="text-80" />
</button>
<button
v-if="resource.authorizedToForceDelete"
data-testid="open-force-delete-modal"
dusk="open-force-delete-modal-button"
@click="openForceDeleteModal"
class="btn btn-default btn-icon btn-white mr-3"
:title="__('Force Delete')"
>
<icon type="force-delete" class="text-80" />
</button>
<portal
to="modals"
v-if="deleteModalOpen || restoreModalOpen || forceDeleteModalOpen"
>
<delete-resource-modal
v-if="deleteModalOpen"
@confirm="confirmDelete"
@close="closeDeleteModal"
mode="delete"
/>
<restore-resource-modal
v-if="restoreModalOpen"
@confirm="confirmRestore"
@close="closeRestoreModal"
/>
<delete-resource-modal
v-if="forceDeleteModalOpen"
@confirm="confirmForceDelete"
@close="closeForceDeleteModal"
mode="force delete"
/>
</portal>
<router-link
v-if="resource.authorizedToUpdate"
data-testid="edit-resource"
dusk="edit-resource-button"
:to="{ name: 'edit', params: { id: resource.id } }"
class="btn btn-default btn-icon bg-primary"
:title="__('Edit')"
>
<icon
type="edit"
class="text-white"
style="margin-top: -2px; margin-left: 3px"
/>
</router-link>
</div>
</div>
</component>
</div>
</loading-view>
</template>
<script>
import {
InteractsWithResourceInformation,
Errors,
Deletable,
Minimum,
mapProps,
HasCards,
} from 'laravel-nova'
export default {
metaInfo() {
if (this.resourceInformation && this.title) {
return {
title: this.__(':resource Details: :title', {
resource: this.resourceInformation.singularLabel,
title: this.title,
}),
}
}
},
props: mapProps(['resourceName', 'resourceId']),
mixins: [Deletable, HasCards, InteractsWithResourceInformation],
data: () => ({
initialLoading: true,
loading: true,
title: null,
resource: null,
panels: [],
actions: [],
actionValidationErrors: new Errors(),
deleteModalOpen: false,
restoreModalOpen: false,
forceDeleteModalOpen: false,
}),
watch: {
resourceId: function (newResourceId, oldResourceId) {
if (newResourceId != oldResourceId) {
this.initializeComponent()
this.fetchCards()
}
},
},
/**
* Bind the keydown even listener when the component is created
*/
created() {
if (Nova.missingResource(this.resourceName))
return this.$router.push({ name: '404' })
Nova.addShortcut('e', this.handleKeydown)
},
/**
* Unbind the keydown even listener when the before component is destroyed
*/
beforeDestroy() {
Nova.disableShortcut('e')
},
/**
* Mount the component.
*/
mounted() {
this.initializeComponent()
},
methods: {
/**
* Handle the keydown event
*/
handleKeydown(e) {
if (
this.resource.authorizedToUpdate &&
e.target.tagName != 'INPUT' &&
e.target.tagName != 'TEXTAREA' &&
e.target.contentEditable != 'true'
) {
this.$router.push({
name: 'edit',
params: { id: this.resource.id },
})
}
},
/**
* Initialize the compnent's data.
*/
async initializeComponent() {
await this.getResource()
await this.getActions()
this.initialLoading = false
},
/**
* Get the resource information.
*/
getResource() {
this.resource = null
return Minimum(
Nova.request().get(
'/nova-api/' + this.resourceName + '/' + this.resourceId
)
)
.then(({ data: { title, panels, resource } }) => {
this.title = title
this.panels = panels
this.resource = resource
this.loading = false
})
.catch(error => {
if (error.response.status >= 500) {
Nova.$emit('error', error.response.data.message)
return
}
if (error.response.status === 404 && this.initialLoading) {
this.$router.push({ name: '404' })
return
}
if (error.response.status === 403) {
this.$router.push({ name: '403' })
return
}
Nova.error(this.__('This resource no longer exists'))
this.$router.push({
name: 'index',
params: { resourceName: this.resourceName },
})
})
},
/**
* Get the available actions for the resource.
*/
getActions() {
this.actions = []
return Nova.request()
.get('/nova-api/' + this.resourceName + '/actions', {
params: {
resourceId: this.resourceId,
editing: true,
editMode: 'create',
display: 'detail',
},
})
.then(response => {
this.actions = response.data.actions
})
},
/**
* Handle an action executed event.
*/
async actionExecuted() {
await this.getResource()
await this.getActions()
},
/**
* Create a new panel for the given field.
*/
createPanelForField(field) {
return _.tap(
_.find(this.panels, panel => panel.name == field.panel),
panel => {
panel.fields = [field]
}
)
},
/**
* Create a new panel for the given relationship field.
*/
createPanelForRelationship(field) {
return {
component: 'relationship-panel',
prefixComponent: true,
name: field.name,
fields: [field],
}
},
/**
* Show the confirmation modal for deleting or detaching a resource
*/
async confirmDelete() {
this.deleteResources([this.resource], response => {
Nova.success(
this.__('The :resource was deleted!', {
resource: this.resourceInformation.singularLabel.toLowerCase(),
})
)
if (response && response.data && response.data.redirect) {
this.$router.push({ path: response.data.redirect }, () => {
window.scrollTo(0, 0)
})
return
}
if (!this.resource.softDeletes) {
this.$router.push(
{
name: 'index',
params: { resourceName: this.resourceName },
},
() => {
window.scrollTo(0, 0)
}
)
return
}
this.closeDeleteModal()
this.getResource()
})
},
/**
* Open the delete modal
*/
openDeleteModal() {
this.deleteModalOpen = true
},
/**
* Close the delete modal
*/
closeDeleteModal() {
this.deleteModalOpen = false
},
/**
* Show the confirmation modal for restoring a resource
*/
async confirmRestore() {
this.restoreResources([this.resource], () => {
Nova.success(
this.__('The :resource was restored!', {
resource: this.resourceInformation.singularLabel.toLowerCase(),
})
)
this.closeRestoreModal()
this.getResource()
})
},
/**
* Open the restore modal
*/
openRestoreModal() {
this.restoreModalOpen = true
},
/**
* Close the restore modal
*/
closeRestoreModal() {
this.restoreModalOpen = false
},
/**
* Show the confirmation modal for force deleting
*/
async confirmForceDelete() {
this.forceDeleteResources([this.resource], response => {
Nova.success(
this.__('The :resource was deleted!', {
resource: this.resourceInformation.singularLabel.toLowerCase(),
})
)
if (response && response.data && response.data.redirect) {
this.$router.push({ path: response.data.redirect })
return
}
this.$router.push({
name: 'index',
params: { resourceName: this.resourceName },
})
})
},
/**
* Open the force delete modal
*/
openForceDeleteModal() {
this.forceDeleteModalOpen = true
},
/**
* Close the force delete modal
*/
closeForceDeleteModal() {
this.forceDeleteModalOpen = false
},
},
computed: {
/**
* Get the available field panels.
*/
availablePanels() {
if (this.resource) {
var panels = {}
var fields = _.toArray(JSON.parse(JSON.stringify(this.resource.fields)))
fields.forEach(field => {
if (field.listable) {
return (panels[field.name] = this.createPanelForRelationship(field))
} else if (panels[field.panel]) {
return panels[field.panel].fields.push(field)
}
panels[field.panel] = this.createPanelForField(field)
})
return _.toArray(panels)
}
},
/**
* These are here to satisfy the parameter requirements for deleting the resource
*/
currentSearch() {
return ''
},
encodedFilters() {
return []
},
currentTrashed() {
return ''
},
viaResource() {
return ''
},
viaResourceId() {
return ''
},
viaRelationship() {
return ''
},
selectedResources() {
return [this.resourceId]
},
/**
* 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,287 @@
<template>
<div
class="fixed pin bg-40 z-50 flex items-center justify-center min-w-site p-6"
>
<div class="flex items-center w-error">
<div class="flex-no-shrink illustration">
<svg
xmlns="http://www.w3.org/2000/svg"
width="523"
height="541"
viewBox="0 0 530 560"
>
<g fill="none" fill-rule="evenodd" transform="translate(4 10)">
<path
fill="#DDE4EB"
d="M0 185a19.4 19.4 0 0 1 19.4-19.4h37.33a19.4 19.4 0 0 0 0-38.8H45.08a19.4 19.4 0 1 1 0-38.8h170.84a19.4 19.4 0 0 1 0 38.8h-6.87a19.4 19.4 0 0 0 0 38.8h42.55a19.4 19.4 0 0 1 0 38.8H19.4A19.4 19.4 0 0 1 0 185z"
/>
<g
stroke-width="2"
transform="rotate(-30 383.9199884 -24.79114317)"
>
<rect
width="32.4"
height="9.19"
x="12.47"
y="3.8"
fill="#FFF"
stroke="#0D2B3E"
rx="4.6"
/>
<rect
width="32.4"
height="14.79"
x="1"
y="1"
fill="#FFF"
stroke="#0D2B3E"
rx="7.39"
/>
<ellipse
cx="8.6"
cy="8.39"
stroke="#4A90E2"
rx="7.6"
ry="7.39"
style="mix-blend-mode: multiply"
/>
</g>
<path
fill="#E0EEFF"
d="M94 198.256L106.6 191l22.4 16.744L116.4 215zM48 164.256L60.6 157 83 173.744 70.4 181z"
opacity=".58"
/>
<path
stroke="#0D2B3E"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M88 188l9 7-9-7zm-15-11l5 3-5-3z"
/>
<path
stroke="#4A90E2"
stroke-width="2"
d="M92.82 198.36l20.65 15.44 10.71-6.16-20.65-15.44-10.71 6.16zM119 211l-22-17 22 17zm-72.18-46.64l20.65 15.44 10.71-6.16-20.65-15.44-10.71 6.16zM73 178l-22-17 22 17z"
/>
<path
stroke="#8DDCFF"
stroke-linecap="round"
stroke-width="2"
d="M117 176a14 14 0 0 0-14-14m10 15a10 10 0 0 0-10-10"
/>
<ellipse cx="258" cy="441" fill="#FFF" rx="250" ry="90" />
<path
fill="#FFF"
fill-rule="nonzero"
stroke="#0D2B3E"
stroke-width="2"
d="M195.95992276 433.88207738c-.7613033-1.55811337-1.97677352-5.39619.01107483-6.1324365 1.97685786-.72734656 2.77032762 2.34241006 4.31210683 4.22387675 2.92231431 3.57504952 6.28818967 5.22592295 11.14145652 5.73602185 1.77024897.18606067 3.51532102.0376574 5.19229942-.41955529a3.17 3.17 0 0 1 3.89461497 2.16898002 3.12 3.12 0 0 1-2.19463454 3.85169823c-2.43329264.66931826-4.97971626.88432232-7.54558275.61463889-7.06110546-.7421521-11.79595772-3.81390631-14.81133528-10.04322395z"
/>
<g stroke="#0D2B3E" stroke-width="2">
<path
fill="#FFF"
fill-rule="nonzero"
d="M228.66635404 453.35751889l3.48444585 6.7411525a11.71 11.71 0 0 0-3.36066168 18.19840799l3.157266 3.1573203-8.52104352 8.55618006-.29468882-6.6673277a19.31 19.31 0 0 1 5.53468217-29.98573315z"
/>
<path d="M221.75370493 481.33823157l5.9097851-4.56727928" />
</g>
<g stroke="#0D2B3E" stroke-width="2">
<path
fill="#FFF"
fill-rule="nonzero"
d="M217.43675157 454.38903415l-.38056208 7.58726384a10.25 10.25 0 0 0-10.62036709 8.5642456l.04580558 4.00318647-11.36366293-.10613565 3.84834642-5.16425501a17.82 17.82 0 0 1 18.46098491-14.88104957z"
/>
<path d="M199.40986905 468.0735658l7.07551171 1.72015522" />
</g>
<path
fill="#E5F7FF"
d="M233.41788355 435.98904264l3.14268919.33030994-3.01041974 28.64223059-3.1426892-.33030995z"
/>
<path
stroke="#7ED7FF"
stroke-width="2"
d="M218.1633805 433.70198413l13.07796292 1.37454929 1.09127716-10.38280859a6.575 6.575 0 0 0-13.07796293-1.37454929l-1.09127715 10.38280859z"
/>
<path
fill="#FFF"
stroke="#0D2B3E"
stroke-width="2"
d="M221.02136188 434.25374714l.64389533-6.12625487a3.59 3.59 0 1 1 7.130722.74946908l-.64389534 6.12625488"
/>
<path
stroke="#0D2B3E"
stroke-width="2"
d="M235.80327328 436.92350283l-20.28824667-2.13238065-2.86721575 27.27973559 20.28824667 2.13238065 2.86721575-27.2797356z"
/>
<path
fill="#FFF"
stroke="#0D2B3E"
stroke-width="2"
d="M215.51502661 434.79112218l-2.86721575 27.27973559 17.1555027 1.80311599 2.86721575-27.2797356-17.1555027-1.80311598z"
/>
<path
fill="#FFF"
stroke="#0D2B3E"
stroke-width="2"
d="M214.36589556 440.07997818l-1.09905036.88999343-1.17489993 11.1784261 11.15853567 1.17280937 1.09905036-.88999344 1.17385464-11.16848088-11.16848088-1.17385464z"
/>
<path
fill="#FFF"
fill-rule="nonzero"
stroke="#0D2B3E"
stroke-width="2"
d="M245.62684398 462.24908175c-.41742893 1.6755456-1.95466376 5.39523768-3.94116941 4.68369338-1.99645087-.71258958-.63076284-3.56546466-.5955535-6.00514913.06313174-4.61870267-1.45795198-8.03642184-4.8492445-11.55015704-1.23234204-1.2858589-2.67505657-2.29217634-4.24858182-3.01059006a3.17 3.17 0 0 1-1.5730205-4.16725407 3.12 3.12 0 0 1 4.14422777-1.54527542c2.29328456 1.04544055 4.3804078 2.52169139 6.1770892 4.36961887 4.93145874 5.12354512 6.58580412 10.52606688 4.87526226 17.2340134z"
/>
<path
stroke="#233242"
stroke-width="2"
d="M518 372.93A1509.66 1509.66 0 0 0 261 351c-87.62 0-173.5 7.51-257 21.93"
/>
<circle cx="51" cy="107" r="6" fill="#9AC2F0" />
<path
stroke="#031836"
stroke-linecap="round"
stroke-width="2"
d="M48 116a6 6 0 1 0-6-6"
/>
<circle cx="501" cy="97" r="6" fill="#9AC2F0" />
<path
stroke="#031836"
stroke-linecap="round"
stroke-width="2"
d="M498 106a6 6 0 1 0-6-6"
/>
<path
fill="#031836"
d="M305.75 0h.5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-.5a1 1 0 0 1-1-1V1a1 1 0 0 1 1-1zM321 14.75v.5a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1v-.5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1zM306.25 30h-.5a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1h.5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1zM291 15.25v-.5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v.5a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1z"
/>
<path
fill="#DDE4EB"
d="M446 107.5a16.5 16.5 0 0 0 16.5 16.5h44a16.5 16.5 0 0 1 0 33h-143a16.5 16.5 0 0 1 0-33 16.5 16.5 0 0 0 0-33h-66a16.5 16.5 0 0 1 0-33h165a16.5 16.5 0 0 1 0 33 16.5 16.5 0 0 0-16.5 16.5z"
/>
<circle cx="458" cy="186" r="4" fill="#031836" />
<circle cx="138" cy="16" r="4" fill="#031836" />
<path
stroke="#233242"
stroke-width="2"
d="M58 364.86l67.93-67.93a10 10 0 0 1 14.14 0L196 352.86m139-18l36.93-36.93a10 10 0 0 1 14.14 0L451 362.86"
/>
<path
stroke="#233242"
stroke-width="2"
d="M176 332.86l70.93-71.84a10 10 0 0 1 14.19-.05L345 344.86"
/>
<g stroke-width="2" transform="rotate(-87 355.051 43.529)">
<ellipse
cx="10.28"
cy="27.49"
fill="#FFF"
stroke="#0D2B3E"
rx="9.21"
ry="19.26"
/>
<path
fill="#FFF"
stroke="#0D2B3E"
d="M25.66 54.03c-7.52 0-13.62-12.1-13.62-27.02S18.14 0 25.66 0H96.1c7.22 0 14.15 2.85 19.26 7.91l19.26 19.1-19.26 19.1a27.35 27.35 0 0 1-19.26 7.92H25.66z"
/>
<path
fill="#FFF"
stroke="#4A90E2"
d="M98.09 54.22c-7.52 0-13.62-12.1-13.62-27.02s6.1-27 13.62-27"
/>
<ellipse
cx="59.59"
cy="27.27"
stroke="#4A90E2"
rx="16.34"
ry="16.21"
/>
<ellipse
cx="59.59"
cy="27.27"
fill="#FFF"
stroke="#0D2B3E"
rx="12.26"
ry="12.16"
/>
</g>
<g stroke="#233242" stroke-width="2" transform="translate(456 396)">
<ellipse cx="30" cy="10" rx="20" ry="10" />
<path
d="M0 15c0 8.28 13.43 15 30 15m12.39-1.33C52.77 26.3 60 21.07 60 15"
/>
</g>
<g stroke="#233242" stroke-width="2" transform="translate(276 520)">
<ellipse cx="20" cy="6.67" rx="13.33" ry="6.67" />
<path
d="M0 10c0 5.52 8.95 10 20 10m8.26-.89C35.18 17.54 40 14.05 40 10"
/>
</g>
<g stroke="#233242" stroke-width="2" transform="translate(186 370)">
<ellipse cx="15" cy="5" rx="10" ry="5" />
<path
d="M0 7.5C0 11.64 6.72 15 15 15m6.2-.67c5.19-1.18 8.8-3.8 8.8-6.83"
/>
</g>
<ellipse cx="58" cy="492" fill="#202C3A" rx="3" ry="2" />
<ellipse cx="468" cy="492" fill="#202C3A" rx="3" ry="2" />
<ellipse cx="388" cy="392" fill="#202C3A" rx="3" ry="2" />
<ellipse cx="338" cy="452" fill="#202C3A" rx="3" ry="2" />
<g stroke="#233242" stroke-width="2" transform="translate(46 406)">
<ellipse cx="40" cy="13.33" rx="26.67" ry="13.33" />
<path
d="M0 20c0 11.05 17.9 20 40 20m16.51-1.78C70.37 35.08 80 28.1 80 20"
/>
</g>
<g stroke="#0D2B3E" stroke-width="2">
<path
d="M299 378l-21 42m35-36l-21 42m4-42l14 6m-17 0l14 6m-17 0l14 6m-17 0l14 6m-17 0l14 6m-17 0l14 6"
/>
</g>
<circle
cx="341"
cy="155"
r="25"
stroke="#233242"
stroke-width="2"
/>
<circle cx="342" cy="156" r="20" fill="#FFF" />
<path
stroke="#233242"
stroke-width="2"
d="M321.56 140.5c-7.66.32-13 2.37-13.97 6-1.78 6.66 11.9 16.12 30.58 21.13 18.67 5 35.25 3.65 37.04-3.02.96-3.58-2.54-7.96-8.88-12.03"
/>
</g>
</svg>
</div>
<div>
<h1 class="text-error-title font-normal mb-1">403</h1>
<p class="text-error-subtitle mb-6">{{ __('Hold Up!') }}</p>
<p class="text-error-message mb-8 leading-normal">
{{
__(
"The government won't let us show you what's behind these doors"
)
}}&hellip;
</p>
<router-link
:to="{ name: 'dashboard' }"
class="dim btn btn-lg btn-default btn-white text-90 no-text-shadow tracking-wide uppercase"
>
{{ __('Go Home') }}
</router-link>
</div>
</div>
</div>
</template>
<script>
export default {
metaInfo() {
return {
title: 'Forbidden',
}
},
}
</script>

View File

@@ -0,0 +1,334 @@
<template>
<div
class="fixed pin bg-40 z-50 flex items-center justify-center min-w-site p-6"
>
<div class="flex items-center w-error">
<div class="flex-no-shrink illustration">
<svg
xmlns="http://www.w3.org/2000/svg"
width="520"
height="560"
viewBox="0 0 520 560"
>
<g fill="none" fill-rule="evenodd" transform="translate(3 73)">
<g stroke-width="2" transform="rotate(-30 140.579 22.242)">
<path
fill="#FFF"
stroke="#0D2B3E"
d="M26.03 0h76.9c7.9 0 15.46 3.16 21.04 8.79L145 30l-21.03 21.21A29.62 29.62 0 0 1 102.94 60H26.03V0z"
/>
<path
fill="#FFF"
stroke="#4A90E2"
d="M102.62 60c8.2 0 14.87-13.43 14.87-30s-6.66-30-14.87-30"
/>
<path
stroke="#4A90E2"
d="M33.46 60c8.22 0 14.87-13.43 14.87-30S41.68 0 33.46 0"
/>
<ellipse
cx="26.03"
cy="30"
fill="#FFF"
stroke="#0D2B3E"
rx="14.87"
ry="30"
/>
<path
fill="#FFF"
stroke="#0D2B3E"
d="M12.15 8.92v42.16c6.35-2.3 10.75-4.58 13.2-6.82l1.53-1.4C30.94 39 32.46 36 32.46 30c0-6.42-1.69-9.3-6.8-13.98l-.31-.28c-2.44-2.24-6.84-4.52-13.2-6.82z"
/>
<ellipse
cx="11.15"
cy="30"
fill="#FFF"
stroke="#0D2B3E"
rx="10.15"
ry="21.5"
/>
<ellipse cx="79.56" cy="30" stroke="#4A90E2" rx="17.85" ry="18" />
<ellipse
cx="79.56"
cy="30"
fill="#FFF"
stroke="#0D2B3E"
rx="13.38"
ry="13.5"
/>
</g>
<path
fill="#DDE4EB"
d="M425 74.5A16.5 16.5 0 0 0 441.5 91h44a16.5 16.5 0 0 1 0 33h-143a16.5 16.5 0 0 1 0-33 16.5 16.5 0 0 0 0-33h-66a16.5 16.5 0 0 1 0-33h165a16.5 16.5 0 0 1 0 33A16.5 16.5 0 0 0 425 74.5z"
/>
<g transform="translate(424 130)">
<circle
cx="45"
cy="45"
r="45"
stroke="#4A90E2"
stroke-width="2"
/>
<circle cx="45.83" cy="45.83" r="39.17" fill="#FFF" />
<circle
cx="47.5"
cy="15.83"
r="4.17"
stroke="#4A90E2"
stroke-width="2"
/>
<path
stroke="#4A90E2"
stroke-linecap="round"
stroke-width="2"
d="M48.33 25c4.6 0 8.34-3.73 8.34-8.33"
/>
<circle
cx="70"
cy="40"
r="6.67"
stroke="#4A90E2"
stroke-width="2"
/>
<circle
cx="19.17"
cy="42.5"
r="2.5"
stroke="#4A90E2"
stroke-width="2"
/>
<path
stroke="#4A90E2"
stroke-linecap="round"
stroke-width="2"
d="M26.67 42.5a7.5 7.5 0 0 0-7.5-7.5m-7.5 7.5a7.5 7.5 0 0 0 7.5 7.5"
/>
<circle
cx="53.33"
cy="66.67"
r="3.33"
stroke="#4A90E2"
stroke-width="2"
/>
<path
stroke="#4A90E2"
stroke-linecap="round"
stroke-width="2"
d="M45 66.67c0 4.6 3.73 8.33 8.33 8.33"
/>
<circle cx="19.17" cy="65.83" r="2.5" fill="#4A90E2" />
<circle cx="32.5" cy="10.83" r="2.5" fill="#4A90E2" />
</g>
<path
fill="#DDE4EB"
d="M309.1 302.8a19.4 19.4 0 0 0-19.4-19.4H177.14a19.4 19.4 0 0 0 0 38.8h11.65a19.4 19.4 0 1 1 0 38.8H63.63a19.4 19.4 0 0 1-19.4-19.4v-.48a18.92 18.92 0 0 1 18.92-18.92 18.92 18.92 0 0 0 18.91-18.92v-.48a19.4 19.4 0 0 0-19.4-19.4H38.4a19.4 19.4 0 0 1 0-38.8h87.33a19.4 19.4 0 0 0 0-38.8h-11.65a19.4 19.4 0 1 1 0-38.8h200.84a19.4 19.4 0 0 1 0 38.8h-36.87a19.4 19.4 0 0 0 0 38.8H390.6a19.4 19.4 0 0 1 0 38.8h-11.65a19.4 19.4 0 0 0-19.4 19.4v.48a18.92 18.92 0 0 0 18.92 18.92 18.92 18.92 0 0 1 18.92 18.92v.48a19.4 19.4 0 0 1-19.4 19.4h-99.94a19.4 19.4 0 0 1 0-38.8h11.65a19.4 19.4 0 0 0 19.4-19.4z"
/>
<g
stroke-width="2"
transform="rotate(-30 693.38531629 74.76561894)"
>
<rect
width="72.05"
height="22.36"
x="25.68"
y="7.09"
fill="#FFF"
stroke="#0D2B3E"
rx="11.18"
/>
<rect
width="72.05"
height="34.54"
x="1"
y="1"
fill="#FFF"
stroke="#0D2B3E"
rx="17.27"
/>
<ellipse
cx="18.51"
cy="18.27"
stroke="#4A90E2"
rx="17.51"
ry="17.27"
style="mix-blend-mode: multiply"
/>
</g>
<path
fill="#E0EEFF"
d="M120.056 377.079l27.34-15.999L196 398.001 168.66 414zM21.329 303.999L48.669 288l48.604 36.921-27.34 15.999z"
opacity=".58"
/>
<path
stroke="#0D2B3E"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M107.4 354.78l20.25 15.12-20.25-15.12zM75.76 332.1l10.12 7.56-10.12-7.56z"
/>
<path
stroke="#4A90E2"
stroke-width="2"
d="M115.52 377.19l46.88 35.6 25.48-14.9-46.89-35.6-25.47 14.9zm58.96 27.99l-46.83-36.54 46.83 36.54zM16.8 304.11l46.88 35.6 25.47-14.9-46.88-35.6L16.8 304.1zm58.96 29.25l-46.84-36.54 46.84 36.54z"
/>
<path
stroke="#8DDCFF"
stroke-linecap="round"
stroke-width="2"
d="M169.42 329.58a30.3 30.3 0 0 0-30.38-30.24m21.52 31.5a21.47 21.47 0 0 0-21.52-21.42"
/>
<path
fill="#FFF"
fill-rule="nonzero"
stroke="#0D2B3E"
stroke-width="2"
d="M363.8119003 126.44094699c2.51661541-6.60148066 6.09613872-22.6187686-2.2109135-24.895539-8.3168685-2.2748623-10.42540197 10.4614671-16.03584328 18.70341761-10.59995244 15.53804391-23.66683084 23.41606326-43.2306606 27.21888653-7.11679709 1.38336522-14.27332024 1.40937203-21.21489804.130389-6.98465907-1.29098322-13.7264579 3.27938314-15.03870592 10.20705267a12.7 12.7 0 0 0 10.24523714 14.86828894 82.91 82.91 0 0 0 30.8739962-.17423743c28.42792323-5.5258285 46.57886712-19.68941689 56.611788-46.05825832z"
/>
<path
stroke="#7ED7FF"
stroke-width="2"
d="M361.7030833 130.52842676l-17.8049339-7.7449551"
/>
<path
fill="#FFF"
fill-rule="nonzero"
stroke="#0D2B3E"
stroke-width="2"
d="M266.54686983 241.69757868l16.77256314 26.0176618a41.79 41.79 0 0 1 54.9786567 8.5873516l8.01291943 14.232555 39.8983953-23.55575834-24.11546673-10.38943521a72.65 72.65 0 0 0-95.54897593-14.90219112z"
/>
<path
fill="#FFF"
fill-rule="nonzero"
stroke="#0D2B3E"
stroke-width="2"
d="M237.8794903 217.16687444l-11.75143341 28.63844816a47.76 47.76 0 0 1 29.336647 40.02772592 47.59 47.59 0 0 1-9.23738334 32.6626813l-11.68590493 13.9867518 37.64817267 31.6885988-1.18280452-27.15319085a78.6 78.6 0 0 0 15.2180959-53.88374576 78.74 78.74 0 0 0-48.34538937-65.96726937z"
/>
<path
fill="#E5F7FF"
d="M212.38792337 148.28534705l-12.63354185 2.45571177 22.40860842 115.28229643 12.63354185-2.45571177z"
/>
<path
stroke="#7ED7FF"
stroke-width="2"
d="M277.14421373 136.00361432l-58.67185675 11.40465366-8.69325783-44.72293448a29.885 29.885 0 1 1 58.67185675-11.40465366l8.69325783 44.72293448z"
/>
<path
fill="#FFF"
stroke="#0D2B3E"
stroke-width="2"
d="M266.45521183 136.46158627l-5.90935459-30.40099387c-1.90427377-9.79663929-11.36964991-16.19817461-21.13684039-14.2996251s-16.13355271 11.38765018-14.23118703 21.1744732l5.90935459 30.40099388"
/>
<path
stroke="#0D2B3E"
stroke-width="2"
d="M199.41437503 150.51172155l87.71820511-17.05069183 22.52118573 115.86145646-87.71820512 17.05069183-22.52118572-115.86145646z"
/>
<path
fill="#FFF"
stroke="#0D2B3E"
stroke-width="2"
d="M287.13258014 133.46102972l22.52118573 115.86145646-75.09447954 14.59688815-22.52118572-115.86145646 75.09447953-14.59688815z"
/>
<path
fill="#FFF"
stroke="#0D2B3E"
stroke-width="2"
d="M291.89999763 154.8951691l6.86065974 4.63610266 9.58051965 49.28750088-49.1402568 9.55189831-6.86065974-4.63610267-9.58242774-49.29731715 49.1402568-9.55189831z"
/>
<path
fill="#FFF"
stroke="#0D2B3E"
stroke-width="2"
d="M298.87323467 160.1104318l-48.56109676 9.439321 9.46794235 48.70834085 48.56109676-9.439321z"
/>
<path
stroke="#7ED7FF"
stroke-width="2"
d="M280.23508038 323.43753826l-27.22702477-17.43517197"
/>
<path
fill="#FFF"
fill-rule="nonzero"
stroke="#0D2B3E"
stroke-width="2"
d="M172.12419639 259.31801112c2.31538206 6.67076501 9.8636495 21.23813123 17.69252677 17.64835676 7.83678545-3.60149884 1.26419427-14.7216987.27446604-24.63498464-1.87754554-18.72579298 3.06823431-33.16477708 15.6036019-48.65116657a56.95 56.95 0 0 1 16.17568934-13.73888912 12.91 12.91 0 0 0 4.954421-17.48662668 12.7 12.7 0 0 0-17.40510779-4.79708482 82.91 82.91 0 0 0-23.5305555 19.97687308c-18.2294437 22.51194976-23.0325701 45.02200255-13.76504176 71.68352199z"
/>
<path
stroke="#7ED7FF"
stroke-width="2"
d="M189.24009043 249.42029566l-19.63254367 3.8161799"
/>
<path
stroke="#0D2B3E"
stroke-width="2"
d="M275.89003883 328.33662123l-25.60278694-16.41637296m107.79044764-58.64485824l-21.48773566 20.47625982"
/>
<path
stroke="#7ED7FF"
stroke-width="2"
d="M352.8061952 248.16739216l-21.48773565 20.47625982"
/>
<path
stroke="#4A90E2"
stroke-width="2"
d="M293.81436735 168.98883038l-10.79789902 2.09889895 2.09889895 10.79789902 10.79789901-2.09889895z"
/>
<path
stroke="#7ED7FF"
stroke-width="2"
d="M297.24892926 186.65811968l-4.90813592.95404498.95404498 4.90813592 4.90813592-.95404498zM285.46940306 188.94782763l-4.90813592.95404498.95404498 4.90813591 4.90813592-.95404497zM265.34717903 174.52229125l-4.90813592.95404498 6.48750584 33.37532423 4.90813592-.95404497zM276.14507804 172.4233923l-3.92650873.76323598.38161799 1.96325437 3.92650873-.76323598z"
/>
<circle cx="401" cy="44" r="6" fill="#9AC2F0" />
<path
stroke="#031836"
stroke-linecap="round"
stroke-width="2"
d="M398 53a6 6 0 1 0-6-6"
/>
<circle cx="90" cy="164" r="6" fill="#9AC2F0" />
<path
stroke="#031836"
stroke-linecap="round"
stroke-width="2"
d="M87 173a6 6 0 1 0-6-6"
/>
<path
fill="#031836"
d="M400.75 335h.5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-.5a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1zM416 349.75v.5a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1v-.5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1zM401.25 365h-.5a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1h.5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1zM386 350.25v-.5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v.5a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1zM14.75 202h.5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-.5a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1zM30 216.75v.5a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1v-.5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1zM15.25 232h-.5a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1h.5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1zM0 217.25v-.5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v.5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1zM224.75 7h.5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-.5a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1zM240 21.75v.5a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1v-.5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1zM225.25 37h-.5a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1h.5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1zM210 22.25v-.5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v.5a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1z"
/>
<circle cx="377" cy="173" r="4" fill="#031836" />
<circle cx="255" cy="403" r="4" fill="#031836" />
</g>
</svg>
</div>
<div>
<h1 class="text-error-title font-normal mb-1">404</h1>
<p class="text-error-subtitle mb-6">{{ __('Whoops') }}&hellip;</p>
<p class="text-error-message mb-8 leading-normal">
{{
__(
"We're lost in space. The page you were trying to view does not exist."
)
}}
</p>
<router-link
:to="{ name: 'dashboard' }"
class="dim btn btn-lg btn-default btn-white text-90 no-text-shadow tracking-wide uppercase"
>
{{ __('Go Home') }}
</router-link>
</div>
</div>
</div>
</template>
<script>
export default {
metaInfo() {
return {
title: 'Page Not Found',
}
},
}
</script>

1283
nova/resources/js/views/Index.vue Executable file

File diff suppressed because it is too large Load Diff

994
nova/resources/js/views/Lens.vue Executable file
View File

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

View File

@@ -0,0 +1,357 @@
<template>
<loading-view :loading="loading">
<custom-update-header
class="mb-3"
:resource-name="resourceName"
:resource-id="resourceId"
/>
<form
v-if="panels"
@submit="submitViaUpdateResource"
@change="onUpdateFormStatus"
autocomplete="off"
ref="form"
>
<form-panel
v-for="panel in panelsWithFields"
@update-last-retrieved-at-timestamp="updateLastRetrievedAtTimestamp"
@field-changed="onUpdateFormStatus"
@file-upload-started="handleFileUploadStarted"
@file-upload-finished="handleFileUploadFinished"
:panel="panel"
:name="panel.name"
:key="panel.name"
:resource-id="resourceId"
:resource-name="resourceName"
:fields="panel.fields"
mode="form"
class="mb-8"
:validation-errors="validationErrors"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
/>
<!-- Update Button -->
<div class="flex items-center">
<cancel-button @click="$router.back()" />
<progress-button
class="mr-3"
dusk="update-and-continue-editing-button"
@click.native="submitViaUpdateResourceAndContinueEditing"
:disabled="isWorking"
:processing="wasSubmittedViaUpdateResourceAndContinueEditing"
>
{{ __('Update & Continue Editing') }}
</progress-button>
<progress-button
dusk="update-button"
type="submit"
:disabled="isWorking"
:processing="wasSubmittedViaUpdateResource"
>
{{ updateButtonLabel }}
</progress-button>
</div>
</form>
</loading-view>
</template>
<script>
import {
mapProps,
Errors,
InteractsWithResourceInformation,
PreventsFormAbandonment,
} from 'laravel-nova'
import HandlesUploads from '@/mixins/HandlesUploads'
export default {
mixins: [
InteractsWithResourceInformation,
HandlesUploads,
PreventsFormAbandonment,
],
metaInfo() {
if (this.resourceInformation && this.title) {
return {
title: this.__('Update :resource: :title', {
resource: this.resourceInformation.singularLabel,
title: this.title,
}),
}
}
},
props: mapProps([
'resourceName',
'resourceId',
'viaResource',
'viaResourceId',
'viaRelationship',
]),
data: () => ({
relationResponse: null,
loading: true,
submittedViaUpdateResourceAndContinueEditing: false,
submittedViaUpdateResource: false,
title: null,
fields: [],
panels: [],
validationErrors: new Errors(),
lastRetrievedAt: null,
}),
async created() {
if (Nova.missingResource(this.resourceName))
return this.$router.push({ name: '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(
`/nova-api/${this.viaResource}/field/${this.viaRelationship}`
)
this.relationResponse = data
}
this.getFields()
this.updateLastRetrievedAtTimestamp()
},
watch: {
$route(to, from) {
if (
from.params.resourceName === to.params.resourceName &&
from.params.resourceId !== to.params.resourceId
) {
this.getFields()
this.validationErrors = new Errors()
this.submittedViaUpdateResource = false
this.submittedViaUpdateResourceAndContinueEditing = false
this.isWorking = false
}
},
},
methods: {
/**
* 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) {
this.$router.push({ name: '404' })
return
}
})
this.title = title
this.panels = panels
this.fields = fields
this.loading = false
Nova.$emit('resource-loaded')
},
async submitViaUpdateResource(e) {
e.preventDefault()
this.submittedViaUpdateResource = true
this.submittedViaUpdateResourceAndContinueEditing = false
this.canLeave = true
await this.updateResource()
},
async submitViaUpdateResourceAndContinueEditing() {
this.submittedViaUpdateResourceAndContinueEditing = true
this.submittedViaUpdateResource = false
this.canLeave = true
await this.updateResource()
},
/**
* 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()
Nova.success(
this.__('The :resource was updated!', {
resource: this.resourceInformation.singularLabel.toLowerCase(),
})
)
await this.updateLastRetrievedAtTimestamp()
if (this.submittedViaUpdateResource) {
this.$router.push({ path: redirect }, () => {
window.scrollTo(0, 0)
})
} else {
if (id != this.resourceId) {
this.$router.push({
name: 'edit',
params: {
resourceId: id,
resourceName: this.resourceName,
},
})
} else {
// Reset the form by refetching the fields
this.getFields()
this.validationErrors = new Errors()
this.submittedViaUpdateResource = false
this.submittedViaUpdateResourceAndContinueEditing = false
this.isWorking = false
}
return
}
} catch (error) {
window.scrollTo(0, 0)
this.submittedViaUpdateResource = false
this.submittedViaUpdateResourceAndContinueEditing = false
if (this.resourceInformation.preventFormAbandonment) {
this.canLeave = false
}
if (error.response.status == 422) {
this.validationErrors = new Errors(error.response.data.errors)
Nova.error(this.__('There was a problem submitting the form.'))
}
if (error.response.status == 409) {
Nova.error(
this.__(
'Another user has updated this resource since this page was loaded. Please refresh the page and try again.'
)
)
}
}
}
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',
},
}
)
},
/**
* 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() {
if (this.resourceInformation.preventFormAbandonment) {
this.updateFormStatus()
}
},
},
computed: {
wasSubmittedViaUpdateResourceAndContinueEditing() {
return this.isWorking && this.submittedViaUpdateResourceAndContinueEditing
},
wasSubmittedViaUpdateResource() {
return this.isWorking && this.submittedViaUpdateResource
},
/**
* Create the form data for creating the resource.
*/
updateResourceFormData() {
return _.tap(new FormData(), formData => {
_(this.fields).each(field => {
field.fill(formData)
})
formData.append('_method', 'PUT')
formData.append('_retrieved_at', this.lastRetrievedAt)
})
},
singularName() {
if (this.relationResponse) {
return this.relationResponse.singularLabel
}
return this.resourceInformation.singularLabel
},
updateButtonLabel() {
return this.resourceInformation.updateButtonLabel
},
isRelation() {
return Boolean(this.viaResourceId && this.viaRelationship)
},
panelsWithFields() {
return _.map(this.panels, panel => {
return {
...panel,
fields: _.filter(this.fields, field => field.panel == panel.name),
}
})
},
},
}
</script>

View File

@@ -0,0 +1,541 @@
<template>
<loading-view :loading="loading">
<custom-update-attached-header
class="mb-3"
:resource-name="resourceName"
:resource-id="resourceId"
/>
<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"
autocomplete="off"
>
<card class="overflow-hidden mb-8">
<!-- Related Resource -->
<div
v-if="viaResourceField"
dusk="via-resource-field"
class="flex border-b border-40"
>
<div class="w-1/5 px-8 py-6">
<label
:for="viaResourceField.name"
class="inline-block text-80 pt-2 leading-tight"
>
{{ viaResourceField.name }}
</label>
</div>
<div class="py-6 px-8 w-1/2">
<span class="inline-block font-bold text-80 pt-2">
{{ viaResourceField.display }}
</span>
</div>
</div>
<default-field
:field="field"
:errors="validationErrors"
:show-help-text="field.helpText != null"
>
<template slot="field">
<select-control
class="form-control form-select w-full"
dusk="attachable-select"
:class="{
'border-danger': validationErrors.has(field.attribute),
}"
:data-testid="`${field.resourceName}-select`"
@change="selectResourceFromSelectControl"
disabled
:options="availableResources"
:label="'display'"
:selected="selectedResourceId"
>
<option value="" disabled selected>
{{ __('Choose :field', { field: field.name }) }}
</option>
</select-control>
</template>
</default-field>
<!-- Pivot Fields -->
<div v-for="field in fields">
<component
:is="'form-' + field.component"
:resource-name="resourceName"
:resource-id="resourceId"
:field="field"
:errors="validationErrors"
:related-resource-name="relatedResourceName"
:related-resource-id="relatedResourceId"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:show-help-text="field.helpText != null"
/>
</div>
</card>
<!-- Attach Button -->
<div class="flex items-center">
<cancel-button @click="$router.back()" />
<progress-button
class="mr-3"
dusk="update-and-continue-editing-button"
@click.native="updateAndContinueEditing"
:disabled="isWorking"
:processing="submittedViaUpdateAndContinueEditing"
>
{{ __('Update & Continue Editing') }}
</progress-button>
<progress-button
dusk="update-button"
type="submit"
:disabled="isWorking"
:processing="submittedViaUpdateAttachedResource"
>
{{
__('Update :resource', {
resource: relatedResourceLabel,
})
}}
</progress-button>
</div>
</form>
</loading-view>
</template>
<script>
import _ from 'lodash'
import {
PerformsSearches,
TogglesTrashed,
Errors,
PreventsFormAbandonment,
} from 'laravel-nova'
export default {
mixins: [PerformsSearches, TogglesTrashed, PreventsFormAbandonment],
metaInfo() {
if (this.relatedResourceLabel && this.title) {
return {
title: this.__('Update attached :resource: :title', {
resource: this.relatedResourceLabel,
title: this.title,
}),
}
}
},
props: {
resourceName: {
type: String,
required: true,
},
resourceId: {
required: true,
},
relatedResourceName: {
type: String,
required: true,
},
relatedResourceId: {
required: true,
},
viaResource: {
default: '',
},
viaResourceId: {
default: '',
},
viaRelationship: {
default: '',
},
viaPivotId: {
default: null,
},
polymorphic: {
default: false,
},
},
data: () => ({
loading: true,
submittedViaUpdateAndContinueEditing: false,
submittedViaUpdateAttachedResource: false,
viaResourceField: null,
field: null,
softDeletes: false,
fields: [],
validationErrors: new Errors(),
selectedResource: null,
selectedResourceId: null,
lastRetrievedAt: null,
title: null,
}),
created() {
if (Nova.missingResource(this.resourceName))
return this.$router.push({ name: '404' })
},
/**
* Mount the component.
*/
mounted() {
this.initializeComponent()
},
methods: {
/**
* 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()
},
/**
* 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.loading = 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) {
this.$router.push({ name: '404' })
return
}
})
this.title = title
this.fields = fields
_.each(this.fields, field => {
if (field) {
field.fill = () => ''
}
})
},
resetErrors() {
this.validationErrors = new Errors()
},
/**
* 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,
},
}
)
this.viaResourceField = response.data.viaResource
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.canLeave = true
Nova.success(this.__('The resource was updated!'))
this.$router.push({
name: 'detail',
params: {
resourceName: this.resourceName,
resourceId: this.resourceId,
},
})
} catch (error) {
window.scrollTo(0, 0)
this.submittedViaUpdateAttachedResource = false
if (
this.resourceInformation &&
this.resourceInformation.preventFormAbandonment
) {
this.canLeave = false
}
if (error.response.status == 422) {
this.validationErrors = new Errors(error.response.data.errors)
Nova.error(this.__('There was a problem submitting the form.'))
}
if (error.response.status == 409) {
Nova.error(
this.__(
'Another user has updated this resource since this page was loaded. Please refresh the page and try again.'
)
)
}
}
},
/**
* Update the resource and reset the form
*/
async updateAndContinueEditing() {
this.submittedViaUpdateAndContinueEditing = true
try {
await this.updateRequest()
this.submittedViaUpdateAndContinueEditing = false
Nova.success(this.__('The resource was updated!'))
// Reset the form by refetching the fields
this.initializeComponent()
} catch (error) {
this.submittedViaUpdateAndContinueEditing = false
if (error.response.status == 422) {
this.validationErrors = new Errors(error.response.data.errors)
Nova.error(this.__('There was a problem submitting the form.'))
}
if (error.response.status == 409) {
Nova.error(
this.__(
'Another user has updated this resource since this page was loaded. Please refresh the page and try again.'
)
)
}
}
},
/**
* 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,
},
}
)
},
/**
* Select a resource using the <select> control
*/
selectResourceFromSelectControl(e) {
this.selectedResourceId = e.target.value
this.selectInitialResource()
if (this.field) {
Nova.$emit(this.field.attribute + '-change', 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() {
if (
this.resourceInformation &&
this.resourceInformation.preventFormAbandonment
) {
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 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)
})
},
/**
* 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>