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

View File

@@ -0,0 +1,14 @@
export default {
emits: ['actionExecuted'],
props: ['resourceName', 'resourceId', 'resource', 'panel'],
methods: {
/**
* Handle the actionExecuted event and pass it up the chain.
*/
actionExecuted() {
this.$emit('actionExecuted')
},
},
}

View File

@@ -0,0 +1,40 @@
export default {
data: () => ({ collapsed: false }),
created() {
const value = localStorage.getItem(this.localStorageKey)
if (value !== 'undefined') {
this.collapsed = JSON.parse(value) ?? this.collapsedByDefault
}
},
unmounted() {
localStorage.setItem(this.localStorageKey, this.collapsed)
},
methods: {
toggleCollapse() {
this.collapsed = !this.collapsed
localStorage.setItem(this.localStorageKey, this.collapsed)
},
},
computed: {
ariaExpanded() {
return this.collapsed === false ? 'true' : 'false'
},
shouldBeCollapsed() {
return this.collapsed
},
localStorageKey() {
return `nova.navigation.${this.item.key}.collapsed`
},
collapsedByDefault() {
return false
},
},
}

View File

@@ -0,0 +1,33 @@
const mixin = {
methods: {
copyValueToClipboard(value) {
if (navigator.clipboard) {
navigator.clipboard.writeText(value)
} else if (window.clipboardData) {
window.clipboardData.setData('Text', value)
} else {
let input = document.createElement('input')
let [scrollTop, scrollLeft] = [
document.documentElement.scrollTop,
document.documentElement.scrollLeft,
]
document.body.appendChild(input)
input.value = value
input.focus()
input.select()
document.documentElement.scrollTop = scrollTop
document.documentElement.scrollLeft = scrollLeft
document.execCommand('copy')
input.remove()
}
},
},
}
export function useCopyValueToClipboard() {
return {
copyValueToClipboard: value => mixin.methods.copyValueToClipboard(value),
}
}
export default mixin

View File

@@ -0,0 +1,300 @@
import filter from 'lodash/filter'
import map from 'lodash/map'
export default {
methods: {
/**
* Open the delete menu modal.
*/
openDeleteModal() {
this.deleteModalOpen = true
},
/**
* Delete the given resources.
*/
deleteResources(resources, callback = null) {
if (this.viaManyToMany) {
return this.detachResources(resources)
}
return Nova.request({
url: '/nova-api/' + this.resourceName,
method: 'delete',
params: {
...this.deletableQueryString,
...{ resources: mapResources(resources) },
},
})
.then(
callback
? callback
: () => {
this.getResources()
}
)
.then(() => {
Nova.$emit('resources-deleted')
})
.finally(() => {
this.deleteModalOpen = false
})
},
/**
* Delete the selected resources.
*/
deleteSelectedResources() {
this.deleteResources(this.selectedResources)
},
/**
* Delete all of the matching resources.
*/
deleteAllMatchingResources() {
if (this.viaManyToMany) {
return this.detachAllMatchingResources()
}
return Nova.request({
url: this.deleteAllMatchingResourcesEndpoint,
method: 'delete',
params: {
...this.deletableQueryString,
...{ resources: 'all' },
},
})
.then(() => {
this.getResources()
})
.then(() => {
Nova.$emit('resources-deleted')
})
.finally(() => {
this.deleteModalOpen = false
})
},
/**
* Detach the given resources.
*/
detachResources(resources) {
return Nova.request({
url: '/nova-api/' + this.resourceName + '/detach',
method: 'delete',
params: {
...this.deletableQueryString,
...{ resources: mapResources(resources) },
...{ pivots: mapPivots(resources) },
},
})
.then(() => {
this.getResources()
})
.then(() => {
Nova.$emit('resources-detached')
})
.finally(() => {
this.deleteModalOpen = false
})
},
/**
* Detach all of the matching resources.
*/
detachAllMatchingResources() {
return Nova.request({
url: '/nova-api/' + this.resourceName + '/detach',
method: 'delete',
params: {
...this.deletableQueryString,
...{ resources: 'all' },
},
})
.then(() => {
this.getResources()
})
.then(() => {
Nova.$emit('resources-detached')
})
.finally(() => {
this.deleteModalOpen = false
})
},
/**
* Force delete the given resources.
*/
forceDeleteResources(resources, callback = null) {
return Nova.request({
url: '/nova-api/' + this.resourceName + '/force',
method: 'delete',
params: {
...this.deletableQueryString,
...{ resources: mapResources(resources) },
},
})
.then(
callback
? callback
: () => {
this.getResources()
}
)
.then(() => {
Nova.$emit('resources-deleted')
})
.finally(() => {
this.deleteModalOpen = false
})
},
/**
* Force delete the selected resources.
*/
forceDeleteSelectedResources() {
this.forceDeleteResources(this.selectedResources)
},
/**
* Force delete all of the matching resources.
*/
forceDeleteAllMatchingResources() {
return Nova.request({
url: this.forceDeleteSelectedResourcesEndpoint,
method: 'delete',
params: {
...this.deletableQueryString,
...{ resources: 'all' },
},
})
.then(() => {
this.getResources()
})
.then(() => {
Nova.$emit('resources-deleted')
})
.finally(() => {
this.deleteModalOpen = false
})
},
/**
* Restore the given resources.
*/
restoreResources(resources, callback = null) {
return Nova.request({
url: '/nova-api/' + this.resourceName + '/restore',
method: 'put',
params: {
...this.deletableQueryString,
...{ resources: mapResources(resources) },
},
})
.then(
callback
? callback
: () => {
this.getResources()
}
)
.then(() => {
Nova.$emit('resources-restored')
})
.finally(() => {
this.restoreModalOpen = false
})
},
/**
* Restore the selected resources.
*/
restoreSelectedResources() {
this.restoreResources(this.selectedResources)
},
/**
* Restore all of the matching resources.
*/
restoreAllMatchingResources() {
return Nova.request({
url: this.restoreAllMatchingResourcesEndpoint,
method: 'put',
params: {
...this.deletableQueryString,
...{ resources: 'all' },
},
})
.then(() => {
this.getResources()
})
.then(() => {
Nova.$emit('resources-restored')
})
.finally(() => {
this.restoreModalOpen = false
})
},
},
computed: {
/**
* Get the delete all matching resources endpoint.
*/
deleteAllMatchingResourcesEndpoint() {
if (this.lens) {
return '/nova-api/' + this.resourceName + '/lens/' + this.lens
}
return '/nova-api/' + this.resourceName
},
/**
* Get the force delete all of the matching resources endpoint.
*/
forceDeleteSelectedResourcesEndpoint() {
if (this.lens) {
return (
'/nova-api/' + this.resourceName + '/lens/' + this.lens + '/force'
)
}
return '/nova-api/' + this.resourceName + '/force'
},
/**
* Get the restore all of the matching resources endpoint.
*/
restoreAllMatchingResourcesEndpoint() {
if (this.lens) {
return (
'/nova-api/' + this.resourceName + '/lens/' + this.lens + '/restore'
)
}
return '/nova-api/' + this.resourceName + '/restore'
},
/**
* Get the query string for a deletable resource request.
*/
deletableQueryString() {
return {
search: this.currentSearch,
filters: this.encodedFilters,
trashed: this.currentTrashed,
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
viaRelationship: this.viaRelationship,
}
},
},
}
function mapResources(resources) {
return map(resources, resource => resource.id.value)
}
function mapPivots(resources) {
return filter(map(resources, resource => resource.id.pivotValue))
}

View File

@@ -0,0 +1,267 @@
import { CancelToken, isCancel } from 'axios'
import debounce from 'lodash/debounce'
import forIn from 'lodash/forIn'
import get from 'lodash/get'
import identity from 'lodash/identity'
import isEmpty from 'lodash/isEmpty'
import isNil from 'lodash/isNil'
import pickBy from 'lodash/pickBy'
import FormField from './FormField'
import { mapProps } from './propTypes'
import filled from '../util/filled'
import { escapeUnicode } from '../util/escapeUnicode'
export default {
extends: FormField,
emits: ['field-shown', 'field-hidden'],
props: {
...mapProps([
'shownViaNewRelationModal',
'field',
'viaResource',
'viaResourceId',
'viaRelationship',
'resourceName',
'resourceId',
'relatedResourceName',
'relatedResourceId',
]),
syncEndpoint: { type: String, required: false },
},
data: () => ({
dependentFieldDebouncer: null,
canceller: null,
watchedFields: {},
watchedEvents: {},
syncedField: null,
pivot: false,
editMode: 'create',
}),
created() {
this.dependentFieldDebouncer = debounce(callback => callback(), 50)
},
mounted() {
if (this.relatedResourceName !== '' && !isNil(this.relatedResourceName)) {
this.pivot = true
if (this.relatedResourceId !== '' && !isNil(this.relatedResourceId)) {
this.editMode = 'update-attached'
} else {
this.editMode = 'attach'
}
} else {
if (this.resourceId !== '' && !isNil(this.resourceId)) {
this.editMode = 'update'
}
}
if (!isEmpty(this.dependsOn)) {
forIn(this.dependsOn, (defaultValue, dependsOn) => {
this.watchedEvents[dependsOn] = value => {
this.watchedFields[dependsOn] = value
this.dependentFieldDebouncer(() => {
this.watchedFields[dependsOn] = value
this.syncField()
})
}
this.watchedFields[dependsOn] = defaultValue
Nova.$on(
this.getFieldAttributeChangeEventName(dependsOn),
this.watchedEvents[dependsOn]
)
})
}
},
beforeUnmount() {
if (this.canceller !== null) this.canceller()
if (!isEmpty(this.watchedEvents)) {
forIn(this.watchedEvents, (event, dependsOn) => {
Nova.$off(this.getFieldAttributeChangeEventName(dependsOn), event)
})
}
},
methods: {
/*
* Set the initial value for the field
*/
setInitialValue() {
this.value = !(
this.currentField.value === undefined ||
this.currentField.value === null
)
? this.currentField.value
: this.value
},
/**
* Provide a function to fills FormData when field is visible.
*/
fillIfVisible(formData, attribute, value) {
if (this.currentlyIsVisible) {
formData.append(attribute, value)
}
},
syncField() {
if (this.canceller !== null) this.canceller()
Nova.request()
.patch(
this.syncEndpoint || this.syncFieldEndpoint,
this.dependentFieldValues,
{
params: pickBy(
{
editing: true,
editMode: this.editMode,
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
viaRelationship: this.viaRelationship,
field: this.fieldAttribute,
component: this.field.dependentComponentKey,
},
identity
),
cancelToken: new CancelToken(canceller => {
this.canceller = canceller
}),
}
)
.then(response => {
let previousValue = this.currentField.value
let wasVisible = this.currentlyIsVisible
this.syncedField = response.data
if (this.syncedField.visible !== wasVisible) {
this.$emit(
this.syncedField.visible === true
? 'field-shown'
: 'field-hidden',
this.fieldAttribute
)
}
if (isNil(this.syncedField.value)) {
this.syncedField.value = previousValue
} else {
this.setInitialValue()
}
let emitChangesEvent = !this.syncedFieldValueHasNotChanged()
this.onSyncedField()
if (
this.syncedField.dependentShouldEmitChangesEvent &&
emitChangesEvent
) {
this.emitOnSyncedFieldValueChange()
}
})
.catch(e => {
if (isCancel(e)) {
return
}
throw e
})
},
onSyncedField() {
//
},
emitOnSyncedFieldValueChange() {
this.emitFieldValueChange(this.field.attribute, this.currentField.value)
},
syncedFieldValueHasNotChanged() {
const value = this.currentField.value
if (filled(value)) {
return !filled(this.value)
}
return !isNil(value) && value?.toString() === this.value?.toString()
},
},
computed: {
/**
* Determine the current field
*/
currentField() {
return this.syncedField || this.field
},
/**
* Determine if the field is in visible mode
*/
currentlyIsVisible() {
return this.currentField.visible
},
/**
* Determine if the field is in readonly mode
*/
currentlyIsReadonly() {
if (this.syncedField !== null) {
return Boolean(
this.syncedField.readonly ||
get(this.syncedField, 'extraAttributes.readonly')
)
}
return Boolean(
this.field.readonly || get(this.field, 'extraAttributes.readonly')
)
},
dependsOn() {
return this.field.dependsOn || []
},
currentFieldValues() {
return {
[this.fieldAttribute]: this.value,
}
},
dependentFieldValues() {
return {
...this.currentFieldValues,
...this.watchedFields,
}
},
encodedDependentFieldValues() {
return btoa(escapeUnicode(JSON.stringify(this.dependentFieldValues)))
},
syncFieldEndpoint() {
if (this.editMode === 'update-attached') {
return `/nova-api/${this.resourceName}/${this.resourceId}/update-pivot-fields/${this.relatedResourceName}/${this.relatedResourceId}`
} else if (this.editMode === 'attach') {
return `/nova-api/${this.resourceName}/${this.resourceId}/creation-pivot-fields/${this.relatedResourceName}`
} else if (this.editMode === 'update') {
return `/nova-api/${this.resourceName}/${this.resourceId}/update-fields`
}
return `/nova-api/${this.resourceName}/creation-fields`
},
},
}

View File

@@ -0,0 +1,31 @@
import isNil from 'lodash/isNil'
import omitBy from 'lodash/omitBy'
export default {
computed: {
suggestionsId() {
return `${this.fieldAttribute}-list`
},
suggestions() {
let field = !isNil(this.syncedField) ? this.syncedField : this.field
if (isNil(field.suggestions)) {
return []
}
return field.suggestions
},
suggestionsAttributes() {
return {
...omitBy(
{
list: this.suggestions.length > 0 ? this.suggestionsId : null,
},
isNil
),
}
},
},
}

View File

@@ -0,0 +1,50 @@
import filled from '../util/filled'
import isArray from 'lodash/isArray'
export default {
props: ['field'],
methods: {
isEqualsToValue(value) {
if (isArray(this.field.value) && filled(value)) {
return Boolean(
this.field.value.includes(value) ||
this.field.value.includes(value.toString())
)
}
return Boolean(
this.field.value === value ||
this.field.value?.toString() === value ||
this.field.value === value?.toString() ||
this.field.value?.toString() === value?.toString()
)
},
},
computed: {
fieldAttribute() {
return this.field.attribute
},
fieldHasValue() {
return filled(this.field.value)
},
usesCustomizedDisplay() {
return this.field.usesCustomizedDisplay && filled(this.field.displayedAs)
},
fieldValue() {
if (!this.usesCustomizedDisplay && !this.fieldHasValue) {
return null
}
return String(this.field.displayedAs ?? this.field.value)
},
shouldDisplayAsHtml() {
return this.field.asHtml
},
},
}

View File

@@ -0,0 +1,115 @@
import identity from 'lodash/identity'
import pickBy from 'lodash/pickBy'
export default {
data: () => ({
filterHasLoaded: false,
filterIsActive: false,
}),
watch: {
encodedFilters(value) {
Nova.$emit('filter-changed', [value])
},
},
methods: {
/**
* Clear filters and reset the resource table
*/
async clearSelectedFilters(lens) {
if (lens) {
await this.$store.dispatch(`${this.resourceName}/resetFilterState`, {
resourceName: this.resourceName,
lens,
})
} else {
await this.$store.dispatch(`${this.resourceName}/resetFilterState`, {
resourceName: this.resourceName,
})
}
this.updateQueryString({
[this.pageParameter]: 1,
[this.filterParameter]: '',
})
Nova.$emit('filter-reset')
},
/**
* Handle a filter state change.
*/
filterChanged() {
let filtersAreApplied =
this.$store.getters[`${this.resourceName}/filtersAreApplied`]
if (filtersAreApplied || this.filterIsActive) {
this.filterIsActive = true
this.updateQueryString({
[this.pageParameter]: 1,
[this.filterParameter]: this.encodedFilters,
})
}
},
/**
* Set up filters for the current view
*/
async initializeFilters(lens) {
if (this.filterHasLoaded === true) {
return
}
// Clear out the filters from the store first
this.$store.commit(`${this.resourceName}/clearFilters`)
await this.$store.dispatch(
`${this.resourceName}/fetchFilters`,
pickBy(
{
resourceName: this.resourceName,
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
viaRelationship: this.viaRelationship,
relationshipType: this.relationshipType,
lens,
},
identity
)
)
await this.initializeState(lens)
this.filterHasLoaded = true
},
/**
* Initialize the filter state
*/
async initializeState(lens) {
this.initialEncodedFilters
? await this.$store.dispatch(
`${this.resourceName}/initializeCurrentFilterValuesFromQueryString`,
this.initialEncodedFilters
)
: await this.$store.dispatch(`${this.resourceName}/resetFilterState`, {
resourceName: this.resourceName,
lens,
})
},
},
computed: {
/**
* Get the name of the filter query string variable.
*/
filterParameter() {
return this.resourceName + '_filter'
},
encodedFilters() {
return this.$store.getters[`${this.resourceName}/currentEncodedFilters`]
},
},
}

View File

@@ -0,0 +1,75 @@
import isNil from 'lodash/isNil'
export default {
props: {
formUniqueId: {
type: String,
},
},
methods: {
emitFieldValue(attribute, value) {
Nova.$emit(`${attribute}-value`, value)
if (this.hasFormUniqueId === true) {
Nova.$emit(`${this.formUniqueId}-${attribute}-value`, value)
}
},
emitFieldValueChange(attribute, value) {
Nova.$emit(`${attribute}-change`, value)
if (this.hasFormUniqueId === true) {
Nova.$emit(`${this.formUniqueId}-${attribute}-change`, value)
}
},
/**
* Get field attribute value event name.
*/
getFieldAttributeValueEventName(attribute) {
return this.hasFormUniqueId === true
? `${this.formUniqueId}-${attribute}-value`
: `${attribute}-value`
},
/**
* Get field attribue value event name.
*/
getFieldAttributeChangeEventName(attribute) {
return this.hasFormUniqueId === true
? `${this.formUniqueId}-${attribute}-change`
: `${attribute}-change`
},
},
computed: {
/**
* Return the field attribute.
*/
fieldAttribute() {
return this.field.attribute
},
/**
* Determine if the field has Form Unique ID.
*/
hasFormUniqueId() {
return !isNil(this.formUniqueId) && this.formUniqueId !== ''
},
/**
* Get field attribue value event name.
*/
fieldAttributeValueEventName() {
return this.getFieldAttributeValueEventName(this.fieldAttribute)
},
/**
* Get field attribue value event name.
*/
fieldAttributeChangeEventName() {
return this.getFieldAttributeChangeEventName(this.fieldAttribute)
},
},
}

View File

@@ -0,0 +1,152 @@
import get from 'lodash/get'
import { mapProps } from './propTypes'
import FormEvents from './FormEvents'
export default {
extends: FormEvents,
props: {
...mapProps([
'nested',
'shownViaNewRelationModal',
'field',
'viaResource',
'viaResourceId',
'viaRelationship',
'resourceName',
'resourceId',
'showHelpText',
'mode',
]),
},
emits: ['field-changed'],
data() {
return {
value: this.fieldDefaultValue(),
}
},
created() {
this.setInitialValue()
},
mounted() {
// Add a default fill method for the field
this.field.fill = this.fill
// Register a global event for setting the field's value
Nova.$on(this.fieldAttributeValueEventName, this.listenToValueChanges)
},
beforeUnmount() {
Nova.$off(this.fieldAttributeValueEventName, this.listenToValueChanges)
},
methods: {
/*
* Set the initial value for the field
*/
setInitialValue() {
this.value = !(
this.field.value === undefined || this.field.value === null
)
? this.field.value
: this.fieldDefaultValue()
},
/**
* Return the field default value.
*/
fieldDefaultValue() {
return ''
},
/**
* Provide a function that fills a passed FormData object with the
* field's internal value attribute
*/
fill(formData) {
this.fillIfVisible(formData, this.fieldAttribute, String(this.value))
},
/**
* Provide a function to fills FormData when field is visible.
*/
fillIfVisible(formData, attribute, value) {
if (this.isVisible) {
formData.append(attribute, value)
}
},
/**
* Update the field's internal value
*/
handleChange(event) {
this.value = event.target.value
if (this.field) {
this.emitFieldValueChange(this.fieldAttribute, this.value)
this.$emit('field-changed')
}
},
/**
* Clean up any side-effects when removing this field dynamically (Repeater).
*/
beforeRemove() {
//
},
listenToValueChanges(value) {
this.value = value
},
},
computed: {
/**
* Determine the current field.
*/
currentField() {
return this.field
},
/**
* Determine if the field should use all the available white-space.
*/
fullWidthContent() {
return this.currentField.fullWidth || this.field.fullWidth
},
/**
* Return the placeholder text for the field.
*/
placeholder() {
return this.currentField.placeholder || this.field.name
},
/**
* Determine if the field is in visible mode
*/
isVisible() {
return this.field.visible
},
/**
* Determine if the field is in readonly mode
*/
isReadonly() {
return Boolean(
this.field.readonly || get(this.field, 'extraAttributes.readonly')
)
},
/**
* Determine if the field is accessed from Action
*/
isActionRequest() {
return ['action-fullscreen', 'action-modal'].includes(this.mode)
},
},
}

View File

@@ -0,0 +1,164 @@
import { Errors } from 'form-backend-validation'
import isNil from 'lodash/isNil'
import { mapProps } from './propTypes'
export default {
emits: ['file-upload-started', 'file-upload-finished'],
props: mapProps(['resourceName']),
async created() {
if (this.field.withFiles) {
const {
data: { draftId },
} = await Nova.request().get(
`/nova-api/${this.resourceName}/field-attachment/${this.fieldAttribute}/draftId`
)
this.draftId = draftId
}
},
data: () => ({
draftId: null,
files: [],
filesToRemove: [],
}),
methods: {
/**
* Upload an attachment
*/
uploadAttachment(file, { onUploadProgress, onCompleted, onFailure }) {
const data = new FormData()
data.append('Content-Type', file.type)
data.append('attachment', file)
data.append('draftId', this.draftId)
if (isNil(onUploadProgress)) {
onUploadProgress = () => {}
}
if (isNil(onFailure)) {
onFailure = () => {}
}
if (isNil(onCompleted)) {
throw 'Missing onCompleted parameter'
}
this.$emit('file-upload-started')
Nova.request()
.post(
`/nova-api/${this.resourceName}/field-attachment/${this.fieldAttribute}`,
data,
{ onUploadProgress }
)
.then(({ data: { path, url } }) => {
this.files.push({ path, url })
const response = onCompleted(path, url)
this.$emit('file-upload-finished')
return response
})
.catch(error => {
onFailure(error)
if (error.response.status == 422) {
const validationErrors = new Errors(error.response.data.errors)
Nova.error(
this.__('An error occurred while uploading the file: :error', {
error: validationErrors.first('attachment'),
})
)
} else {
Nova.error(this.__('An error occurred while uploading the file.'))
}
})
},
/**
* Remove an attachment from the server
*/
flagFileForRemoval(url) {
const fileIndex = this.files.findIndex(file => file.url === url)
if (fileIndex !== -1) {
this.filesToRemove.push(this.files[fileIndex])
return
}
// Case of deleting a file which was added prior to this draft
this.filesToRemove.push({ url })
},
unflagFileForRemoval(url) {
const fileIndex = this.filesToRemove.findIndex(file => file.url === url)
if (fileIndex === -1) {
return
}
this.filesToRemove.splice(fileIndex, 1)
},
/**
* Purge pending attachments for the draft
*/
clearAttachments() {
if (this.field.withFiles) {
Nova.request()
.delete(
`/nova-api/${this.resourceName}/field-attachment/${this.fieldAttribute}/${this.draftId}`
)
.then(response => {})
.catch(error => {})
}
},
clearFilesMarkedForRemoval() {
if (this.field.withFiles) {
this.filesToRemove.forEach(file => {
console.log('deleting', file)
Nova.request()
.delete(
`/nova-api/${this.resourceName}/field-attachment/${this.fieldAttribute}`,
{
params: {
attachment: file.path,
attachmentUrl: file.url,
draftId: this.draftId,
},
}
)
.then(response => {})
.catch(error => {})
})
}
},
/**
* Fill draft id for the field
*/
fillAttachmentDraftId(formData) {
let attribute = this.fieldAttribute
let [name, ...nested] = attribute.split('[')
if (!isNil(nested) && nested.length > 0) {
let last = nested.pop()
if (nested.length > 0) {
attribute = `${name}[${nested.join('[')}[${last.slice(0, -1)}DraftId]`
} else {
attribute = `${name}[${last.slice(0, -1)}DraftId]`
}
} else {
attribute = `${attribute}DraftId`
}
this.fillIfVisible(formData, attribute, this.draftId)
},
},
}

View File

@@ -0,0 +1,63 @@
import { Errors } from 'form-backend-validation'
export default {
props: {
formUniqueId: {
type: String,
},
},
data: () => ({
validationErrors: new Errors(),
}),
methods: {
/**
* Handle all response error.
*/
handleResponseError(error) {
if (error.response === undefined || error.response.status == 500) {
Nova.error(this.__('There was a problem submitting the form.'))
} else if (error.response.status == 422) {
this.validationErrors = new Errors(error.response.data.errors)
Nova.error(this.__('There was a problem submitting the form.'))
} else {
Nova.error(
this.__('There was a problem submitting the form.') +
' "' +
error.response.statusText +
'"'
)
}
},
/**
* Handle creating response error.
*/
handleOnCreateResponseError(error) {
this.handleResponseError(error)
},
/**
* Handle updating response error.
*/
handleOnUpdateResponseError(error) {
if (error.response && 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.'
)
)
} else {
this.handleResponseError(error)
}
},
/**
* Reset validation errors.
*/
resetErrors() {
this.validationErrors = new Errors()
},
},
}

View File

@@ -0,0 +1,36 @@
import each from 'lodash/each'
import filter from 'lodash/filter'
export default {
emits: ['field-shown', 'field-hidden'],
data: () => ({
visibleFieldsForPanel: {},
}),
created() {
each(this.panel.fields, field => {
this.visibleFieldsForPanel[field.attribute] = field.visible
})
},
methods: {
handleFieldShown(field) {
this.visibleFieldsForPanel[field] = true
this.$emit('field-shown', field)
},
handleFieldHidden(field) {
this.visibleFieldsForPanel[field] = false
this.$emit('field-hidden', field)
},
},
computed: {
visibleFieldsCount() {
return Object.entries(
filter(this.visibleFieldsForPanel, visible => visible === true)
).length
},
},
}

View File

@@ -0,0 +1,25 @@
export default {
data: () => ({ isWorking: false, fileUploadsCount: 0 }),
methods: {
/**
* Handle file upload finishing
*/
handleFileUploadFinished() {
this.fileUploadsCount--
if (this.fileUploadsCount < 1) {
this.fileUploadsCount = 0
this.isWorking = false
}
},
/**
* Handle file upload starting
*/
handleFileUploadStarted() {
this.isWorking = true
this.fileUploadsCount++
},
},
}

View File

@@ -0,0 +1,49 @@
import { Errors } from 'form-backend-validation'
export default {
props: {
errors: { default: () => new Errors() },
},
inject: { index: { default: null }, viaParent: { default: null } },
data: () => ({
errorClass: 'form-control-bordered-error',
}),
computed: {
errorClasses() {
return this.hasError ? [this.errorClass] : []
},
fieldAttribute() {
return this.field.attribute
},
validationKey() {
return this.nestedValidationKey || this.field.validationKey
},
hasError() {
return this.errors.has(this.validationKey)
},
firstError() {
if (this.hasError) {
return this.errors.first(this.validationKey)
}
},
nestedAttribute() {
if (this.viaParent) {
return `${this.viaParent}[${this.index}][${this.field.attribute}]`
}
},
nestedValidationKey() {
if (this.viaParent) {
return `${this.viaParent}.${this.index}.fields.${this.field.attribute}`
}
},
},
}

View File

@@ -0,0 +1 @@
export default {}

View File

@@ -0,0 +1,61 @@
import filter from 'lodash/filter'
export default {
props: {
loadCards: {
type: Boolean,
default: true,
},
},
data: () => ({ cards: [] }),
/**
* Fetch all of the metrics panels for this view
*/
created() {
this.fetchCards()
},
watch: {
cardsEndpoint() {
this.fetchCards()
},
},
methods: {
async fetchCards() {
// We disable fetching of cards when the component is being show
// on a resource detail view to avoid extra network requests
if (this.loadCards) {
const { data: cards } = await Nova.request().get(this.cardsEndpoint, {
params: this.extraCardParams,
})
this.cards = cards
}
},
},
computed: {
/**
* Determine whether we have cards to show on the Dashboard.
*/
shouldShowCards() {
return this.cards.length > 0
},
/**
* Determine if the cards array contains some detail-only cards.
*/
hasDetailOnlyCards() {
return filter(this.cards, c => c.onlyOnDetail == true).length > 0
},
/**
* Get the extra card params to pass to the endpoint.
*/
extraCardParams() {
return null
},
},
}

View File

@@ -0,0 +1,846 @@
import debounce from 'lodash/debounce'
import find from 'lodash/find'
import includes from 'lodash/includes'
import isNull from 'lodash/isNull'
import map from 'lodash/map'
import { Filterable, InteractsWithQueryString, mapProps } from './index'
import { capitalize } from '@/util'
import { computed } from 'vue'
import filter from 'lodash/filter'
export default {
mixins: [Filterable, InteractsWithQueryString],
props: {
...mapProps([
'resourceName',
'viaResource',
'viaResourceId',
'viaRelationship',
'relationshipType',
'disablePagination',
]),
field: { type: Object },
initialPerPage: { type: Number, required: false },
},
provide() {
return {
resourceHasId: computed(() => this.resourceHasId),
authorizedToViewAnyResources: computed(
() => this.authorizedToViewAnyResources
),
authorizedToUpdateAnyResources: computed(
() => this.authorizedToUpdateAnyResources
),
authorizedToDeleteAnyResources: computed(
() => this.authorizedToDeleteAnyResources
),
authorizedToRestoreAnyResources: computed(
() => this.authorizedToRestoreAnyResources
),
selectedResourcesCount: computed(() => this.selectedResources.length),
selectAllChecked: computed(() => this.selectAllChecked),
selectAllMatchingChecked: computed(() => this.selectAllMatchingChecked),
selectAllOrSelectAllMatchingChecked: computed(
() => this.selectAllOrSelectAllMatchingChecked
),
selectAllAndSelectAllMatchingChecked: computed(
() => this.selectAllAndSelectAllMatchingChecked
),
selectAllIndeterminate: computed(() => this.selectAllIndeterminate),
orderByParameter: computed(() => this.orderByParameter),
orderByDirectionParameter: computed(() => this.orderByDirectionParameter),
}
},
data: () => ({
actions: [],
allMatchingResourceCount: 0,
authorizedToRelate: false,
canceller: null,
currentPageLoadMore: null,
deleteModalOpen: false,
initialLoading: true,
loading: true,
orderBy: '',
orderByDirection: '',
pivotActions: null,
resourceHasId: true,
resourceHasActions: false,
resourceResponse: null,
resourceResponseError: null,
resources: [],
search: '',
selectAllMatchingResources: false,
selectedResources: [],
softDeletes: false,
trashed: '',
}),
async created() {
if (Nova.missingResource(this.resourceName)) return Nova.visit('/404')
const debouncer = debounce(
callback => callback(),
this.resourceInformation.debounce
)
this.initializeSearchFromQueryString()
this.initializePerPageFromQueryString()
this.initializeTrashedFromQueryString()
this.initializeOrderingFromQueryString()
await this.initializeFilters(this.lens || null)
await this.getResources()
if (!this.isLensView) {
await 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()
if (this.currentPage === 1) {
this.currentPageLoadMore = null
}
this.getResources()
}
)
this.$watch('search', newValue => {
this.search = newValue
debouncer(() => this.performSearch())
})
},
beforeUnmount() {
if (this.canceller !== null) this.canceller()
},
methods: {
/**
* Handle resources loaded event.
*/
handleResourcesLoaded() {
this.loading = false
if (!this.isLensView && this.resourceResponse.total !== null) {
this.allMatchingResourceCount = this.resourceResponse.total
} else {
this.getAllMatchingResourceCount()
}
Nova.$emit(
'resources-loaded',
this.isLensView
? {
resourceName: this.resourceName,
lens: this.lens,
mode: 'lens',
}
: {
resourceName: this.resourceName,
mode: this.isRelation ? 'related' : 'index',
}
)
this.initializePolling()
},
/**
* Select all of the available resources
*/
selectAllResources() {
this.selectedResources = this.resources.slice(0)
},
/**
* Toggle the selection of all resources
*/
toggleSelectAll(e) {
if (e) {
e.preventDefault()
}
if (this.selectAllChecked) {
this.clearResourceSelections()
} else {
this.selectAllResources()
}
this.getActions()
},
/**
* Toggle the selection of all matching resources in the database
*/
toggleSelectAllMatching(e) {
if (e) {
e.preventDefault()
}
if (!this.selectAllMatchingResources) {
this.selectAllResources()
this.selectAllMatchingResources = true
} else {
this.selectAllMatchingResources = false
}
this.getActions()
},
/*
* Update the resource selection status
*/
updateSelectionStatus(resource) {
if (!includes(this.selectedResources, resource)) {
this.selectedResources.push(resource)
} else {
const index = this.selectedResources.indexOf(resource)
if (index > -1) this.selectedResources.splice(index, 1)
}
this.selectAllMatchingResources = false
this.getActions()
},
/**
* Clear the selected resouces and the "select all" states.
*/
clearResourceSelections() {
this.selectAllMatchingResources = false
this.selectedResources = []
},
/**
* 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()
},
/**
* Select the next page.
*/
selectPage(page) {
this.updateQueryString({ [this.pageParameter]: page })
},
/**
* Sync the per page values from the query string.
*/
initializePerPageFromQueryString() {
this.perPage =
this.queryStringParams[this.perPageParameter] ||
this.initialPerPage ||
this.resourceInformation?.perPageOptions[0] ||
null
},
/**
* Close the delete modal.
*/
closeDeleteModal() {
this.deleteModalOpen = false
},
/**
* Execute a search against the resource.
*/
performSearch() {
this.updateQueryString({
[this.pageParameter]: 1,
[this.searchParameter]: this.search,
})
},
handleActionExecuted() {
this.fetchPolicies()
this.getResources()
},
},
computed: {
/**
* Determine if the resource has any filters
*/
hasFilters() {
return this.$store.getters[`${this.resourceName}/hasFilters`]
},
/**
* Get the name of the page query string variable.
*/
pageParameter() {
return this.viaRelationship
? this.viaRelationship + '_page'
: this.resourceName + '_page'
},
/**
* Determine if all resources are selected on the page.
*/
selectAllChecked() {
return this.selectedResources.length == this.resources.length
},
/**
* Determine if Select All Dropdown state is indeterminate.
*/
selectAllIndeterminate() {
return (
Boolean(this.selectAllChecked || this.selectAllMatchingChecked) &&
Boolean(!this.selectAllAndSelectAllMatchingChecked)
)
},
selectAllAndSelectAllMatchingChecked() {
return this.selectAllChecked && this.selectAllMatchingChecked
},
selectAllOrSelectAllMatchingChecked() {
return this.selectAllChecked || this.selectAllMatchingChecked
},
/**
* Determine if all matching resources are selected.
*/
selectAllMatchingChecked() {
return this.selectAllMatchingResources
},
/**
* Get the IDs for the selected resources.
*/
selectedResourceIds() {
return map(this.selectedResources, resource => resource.id.value)
},
/**
* Get the Pivot IDs for the selected resources.
*/
selectedPivotIds() {
return map(
this.selectedResources,
resource => resource.id.pivotValue ?? null
)
},
/**
* Get the current search value from the query string.
*/
currentSearch() {
return this.queryStringParams[this.searchParameter] || ''
},
/**
* Get the current order by value from the query string.
*/
currentOrderBy() {
return this.queryStringParams[this.orderByParameter] || ''
},
/**
* Get the current order by direction from the query string.
*/
currentOrderByDirection() {
return this.queryStringParams[this.orderByDirectionParameter] || null
},
/**
* Get the current trashed constraint value from the query string.
*/
currentTrashed() {
return this.queryStringParams[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 index is a relation field
*/
isRelation() {
return Boolean(this.viaResourceId && this.viaRelationship)
},
/**
* Get the singular name for the resource
*/
singularName() {
if (this.isRelation && this.field) {
return capitalize(this.field.singularLabel)
}
if (this.resourceInformation) {
return capitalize(this.resourceInformation.singularLabel)
}
},
/**
* Determine if there are any resources for the view
*/
hasResources() {
return Boolean(this.resources.length > 0)
},
/**
* Determine if there any lenses for this resource
*/
hasLenses() {
return Boolean(this.lenses.length > 0)
},
/**
* Determine if the resource should show any cards
*/
shouldShowCards() {
// Don't show cards if this resource is beings shown via a relations
return Boolean(this.cards.length > 0 && !this.isRelation)
},
/**
* Determine whether to show the selection checkboxes for resources
*/
shouldShowCheckboxes() {
return (
Boolean(this.hasResources) &&
Boolean(this.resourceHasId) &&
Boolean(
this.resourceHasActions ||
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 view any listed resource.
*/
authorizedToViewAnyResources() {
return (
this.resources.length > 0 &&
Boolean(this.resourceHasId) &&
Boolean(find(this.resources, resource => resource.authorizedToView))
)
},
/**
* Determine if the user is authorized to view any listed resource.
*/
authorizedToUpdateAnyResources() {
return (
this.resources.length > 0 &&
Boolean(this.resourceHasId) &&
Boolean(find(this.resources, resource => resource.authorizedToUpdate))
)
},
/**
* Determine if the user is authorized to delete any listed resource.
*/
authorizedToDeleteAnyResources() {
return (
this.resources.length > 0 &&
Boolean(this.resourceHasId) &&
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(this.resourceHasId) &&
Boolean(
find(this.resources, resource => resource.authorizedToForceDelete)
)
)
},
/**
* Determine if any selected resources may be restored.
*/
authorizedToRestoreSelectedResources() {
return (
Boolean(this.resourceHasId) &&
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(this.resourceHasId) &&
Boolean(find(this.resources, resource => resource.authorizedToRestore))
)
},
/**
* 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.queryStringParams[this.filterParameter] || ''
},
/**
* Return the pagination component for the resource.
*/
paginationComponent() {
return `pagination-${Nova.config('pagination') || 'links'}`
},
/**
* Determine if the resources has a next page.
*/
hasNextPage() {
return Boolean(
this.resourceResponse && this.resourceResponse.next_page_url
)
},
/**
* Determine if the resources has a previous page.
*/
hasPreviousPage() {
return Boolean(
this.resourceResponse && this.resourceResponse.prev_page_url
)
},
/**
* Return the total pages for the resource.
*/
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 &&
`${Nova.formatNumber(first + 1)}-${Nova.formatNumber(
first + this.resources.length
)} ${this.__('of')} ${Nova.formatNumber(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
}
},
/**
* Get the default label for the create button
*/
createButtonLabel() {
if (this.resourceInformation)
return this.resourceInformation.createButtonLabel
return this.__('Create')
},
/**
* Build the resource request query string.
*/
resourceRequestQueryString() {
const queryString = {
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,
}
if (!this.lensName) {
queryString['viaRelationship'] = this.viaRelationship
}
return queryString
},
/**
* Determine if the action selector should be shown.
*/
shouldShowActionSelector() {
return this.selectedResources.length > 0 || this.haveStandaloneActions
},
/**
* Determine if the view is a resource index or a lens.
*/
isLensView() {
return this.lens !== '' && this.lens != undefined && this.lens != null
},
/**
* Determine whether the pagination component should be shown.
*/
shouldShowPagination() {
return (
this.disablePagination !== true &&
this.resourceResponse &&
(this.hasResources || this.hasPreviousPage)
)
},
/**
* Return the current count of all resources
*/
currentResourceCount() {
return this.resources.length
},
/**
* Get the name of the search query string variable.
*/
searchParameter() {
return this.viaRelationship
? this.viaRelationship + '_search'
: this.resourceName + '_search'
},
/**
* Get the name of the order by query string variable.
*/
orderByParameter() {
return this.viaRelationship
? this.viaRelationship + '_order'
: this.resourceName + '_order'
},
/**
* Get the name of the order by direction query string variable.
*/
orderByDirectionParameter() {
return this.viaRelationship
? this.viaRelationship + '_direction'
: this.resourceName + '_direction'
},
/**
* Get the name of the trashed constraint query string variable.
*/
trashedParameter() {
return this.viaRelationship
? this.viaRelationship + '_trashed'
: this.resourceName + '_trashed'
},
/**
* Get the name of the per page query string variable.
*/
perPageParameter() {
return this.viaRelationship
? this.viaRelationship + '_per_page'
: this.resourceName + '_per_page'
},
/**
* Determine whether there are any standalone actions.
*/
haveStandaloneActions() {
return filter(this.allActions, a => a.standalone === true).length > 0
},
/**
* Return the available actions.
*/
availableActions() {
return this.actions
},
/**
* Determine if the resource has any pivot actions available.
*/
hasPivotActions() {
return this.pivotActions && this.pivotActions.actions.length > 0
},
/**
* Get the name of the pivot model for the resource.
*/
pivotName() {
return this.pivotActions ? this.pivotActions.name : ''
},
/**
* Determine if the resource has any actions available.
*/
actionsAreAvailable() {
return this.allActions.length > 0
},
/**
* Get all of the actions available to the resource.
*/
allActions() {
return this.hasPivotActions
? this.actions.concat(this.pivotActions.actions)
: this.actions
},
availableStandaloneActions() {
return this.allActions.filter(a => a.standalone === true)
},
/**
* Get the selected resources for the action selector.
*/
selectedResourcesForActionSelector() {
return this.selectAllMatchingChecked ? 'all' : this.selectedResources
},
},
}

View File

@@ -0,0 +1,21 @@
import { hourCycle } from '@/util'
export default {
computed: {
/**
* Get the user's local timezone.
*/
userTimezone() {
return Nova.config('userTimezone') || Nova.config('timezone')
},
/**
* Determine if the user is used to 12 hour time.
*/
usesTwelveHourTime() {
let locale = new Intl.DateTimeFormat().resolvedOptions().locale
return hourCycle(locale) === 12
},
},
}

View File

@@ -0,0 +1,10 @@
import { mapActions, mapGetters } from 'vuex'
export default {
async created() {
this.syncQueryString()
},
methods: mapActions(['syncQueryString', 'updateQueryString']),
computed: mapGetters(['queryStringParams']),
}

View File

@@ -0,0 +1,40 @@
import find from 'lodash/find'
export default {
computed: {
/**
* Get the resource information object for the current resource.
*/
resourceInformation() {
return find(Nova.config('resources'), resource => {
return resource.uriKey === this.resourceName
})
},
/**
* Get the resource information object for the current resource.
*/
viaResourceInformation() {
if (!this.viaResource) {
return
}
return find(Nova.config('resources'), resource => {
return resource.uriKey === this.viaResource
})
},
/**
* Determine if the user is authorized to create the current resource.
*/
authorizedToCreate() {
if (
['hasOneThrough', 'hasManyThrough'].indexOf(this.relationshipType) >= 0
) {
return false
}
return this.resourceInformation?.authorizedToCreate || false
},
},
}

View File

@@ -0,0 +1,17 @@
import { mapProps } from './propTypes'
export default {
props: mapProps(['resourceName', 'viaRelationship']),
computed: {
localStorageKey() {
let name = this.resourceName
if (this.viaRelationship) {
name = `${name}.${this.viaRelationship}`
}
return `nova.resources.${name}.collapsed`
},
},
}

View File

@@ -0,0 +1,12 @@
import __ from '../util/localization'
export default {
methods: {
/**
* Translate the given key.
*/
__(key, replace) {
return __(key, replace)
},
},
}

View File

@@ -0,0 +1,21 @@
export default {
created() {
Nova.$on('metric-refresh', this.fetch)
Nova.$on('resources-deleted', this.fetch)
Nova.$on('resources-detached', this.fetch)
Nova.$on('resources-restored', this.fetch)
if (this.card.refreshWhenActionRuns) {
Nova.$on('action-executed', this.fetch)
}
},
beforeUnmount() {
Nova.$off('metric-refresh', this.fetch)
Nova.$off('resources-deleted', this.fetch)
Nova.$off('resources-detached', this.fetch)
Nova.$off('resources-restored', this.fetch)
Nova.$off('action-executed', this.fetch)
},
}

View File

@@ -0,0 +1,26 @@
export default {
methods: {
/**
* Select the previous page.
*/
selectPreviousPage() {
this.updateQueryString({ [this.pageParameter]: this.currentPage - 1 })
},
/**
* Select the next page.
*/
selectNextPage() {
this.updateQueryString({ [this.pageParameter]: this.currentPage + 1 })
},
},
computed: {
/**
* Get the current page from the query string.
*/
currentPage() {
return parseInt(this.queryStringParams[this.pageParameter] || 1)
},
},
}

View File

@@ -0,0 +1,28 @@
export default {
data: () => ({ perPage: 25 }),
methods: {
/**
* Sync the per page values from the query string.
*/
initializePerPageFromQueryString() {
this.perPage = this.currentPerPage
},
/**
* Update the desired amount of resources per page.
*/
perPageChanged() {
this.updateQueryString({ [this.perPageParameter]: this.perPage })
},
},
computed: {
/**
* Get the current per page value from the query string.
*/
currentPerPage() {
return this.queryStringParams[this.perPageParameter] || 25
},
},
}

View File

@@ -0,0 +1,79 @@
import debounce from 'lodash/debounce'
export default {
data: () => ({
search: '',
selectedResource: null,
selectedResourceId: null,
availableResources: [],
}),
methods: {
/**
* Set the currently selected resource
*/
selectResource(resource) {
this.selectedResource = resource
this.selectedResourceId = resource.value
if (this.field) {
if (typeof this['emitFieldValueChange'] == 'function') {
this.emitFieldValueChange(
this.fieldAttribute,
this.selectedResourceId
)
} else {
Nova.$emit(this.fieldAttribute + '-change', this.selectedResourceId)
}
}
},
/**
* Handle the search box being cleared.
*/
handleSearchCleared() {
this.availableResources = []
},
/**
* Clear the selected resource and availableResources
*/
clearSelection() {
this.selectedResource = null
this.selectedResourceId = null
this.availableResources = []
if (this.field) {
if (typeof this['emitFieldValueChange'] == 'function') {
this.emitFieldValueChange(this.fieldAttribute, null)
} else {
Nova.$emit(this.fieldAttribute + '-change', null)
}
}
},
/**
* Perform a search to get the relatable resources.
*/
performSearch(search) {
this.search = search
const trimmedSearch = search.trim()
// If the user performs an empty search, it will load all the results
// so let's just set the availableResources to an empty array to avoid
// loading a huge result set
if (trimmedSearch == '') {
return
}
this.searchDebouncer(() => {
this.getAvailableResources(trimmedSearch)
}, 500)
},
/**
* Debounce function for the search handler
*/
searchDebouncer: debounce(callback => callback(), 500),
},
}

View File

@@ -0,0 +1,161 @@
import { mapGetters, mapMutations } from 'vuex'
import { Inertia } from '@inertiajs/inertia'
import filled from '../util/filled'
export default {
created() {
this.removeOnNavigationChangesEvent = Inertia.on('before', event => {
this.removeOnNavigationChangesEvent()
this.handlePreventFormAbandonmentOnInertia(event)
})
window.addEventListener(
'beforeunload',
this.handlePreventFormAbandonmentOnInertia
)
this.removeOnBeforeUnloadEvent = () => {
window.removeEventListener(
'beforeunload',
this.handlePreventFormAbandonmentOnInertia
)
this.removeOnBeforeUnloadEvent = () => {}
}
},
mounted() {
window.onpopstate = event => {
this.handlePreventFormAbandonmentOnPopState(event)
}
},
beforeUnmount() {
this.removeOnBeforeUnloadEvent()
},
unmounted() {
this.removeOnNavigationChangesEvent()
this.resetPushState()
},
data() {
return {
removeOnNavigationChangesEvent: null,
removeOnBeforeUnloadEvent: null,
navigateBackUsingHistory: true,
}
},
methods: {
...mapMutations([
'allowLeavingForm',
'preventLeavingForm',
'triggerPushState',
'resetPushState',
]),
/**
* Prevent accidental abandonment only if form was changed.
*/
updateFormStatus() {
if (this.canLeaveForm === true) {
this.triggerPushState()
}
this.preventLeavingForm()
},
enableNavigateBackUsingHistory() {
this.navigateBackUsingHistory = false
},
disableNavigateBackUsingHistory() {
this.navigateBackUsingHistory = false
},
handlePreventFormAbandonment(proceed, revert) {
if (this.canLeaveForm) {
proceed()
return
}
const answer = window.confirm(
this.__('Do you really want to leave? You have unsaved changes.')
)
if (answer) {
proceed()
return
}
revert()
},
handlePreventFormAbandonmentOnInertia(event) {
this.handlePreventFormAbandonment(
() => {
this.handleProceedingToNextPage()
this.allowLeavingForm()
},
() => {
Inertia.ignoreHistoryState = true
event.preventDefault()
event.returnValue = ''
this.removeOnNavigationChangesEvent = Inertia.on('before', event => {
this.removeOnNavigationChangesEvent()
this.handlePreventFormAbandonmentOnInertia(event)
})
}
)
},
handlePreventFormAbandonmentOnPopState(event) {
event.stopImmediatePropagation()
event.stopPropagation()
this.handlePreventFormAbandonment(
() => {
this.handleProceedingToPreviousPage()
this.allowLeavingForm()
},
() => {
this.triggerPushState()
}
)
},
handleProceedingToPreviousPage() {
window.onpopstate = null
Inertia.ignoreHistoryState = false
this.removeOnBeforeUnloadEvent()
if (!this.canLeaveFormToPreviousPage && this.navigateBackUsingHistory) {
window.history.back()
}
},
handleProceedingToNextPage() {
window.onpopstate = null
Inertia.ignoreHistoryState = false
this.removeOnBeforeUnloadEvent()
},
proceedToPreviousPage(url) {
if (this.navigateBackUsingHistory && window.history.length > 1) {
window.history.back()
} else if (!this.navigateBackUsingHistory && filled(url)) {
Nova.visit(url, { replace: true })
} else {
Nova.visit('/')
}
},
},
computed: {
...mapGetters(['canLeaveForm', 'canLeaveFormToPreviousPage']),
},
}

View File

@@ -0,0 +1,41 @@
import { mapGetters, mapMutations } from 'vuex'
export default {
props: {
show: { type: Boolean, default: false },
},
methods: {
...mapMutations(['allowLeavingModal', 'preventLeavingModal']),
/**
* Prevent accidental abandonment only if form was changed.
*/
updateModalStatus() {
this.preventLeavingModal()
},
handlePreventModalAbandonment(proceed, revert) {
if (this.canLeaveModal) {
proceed()
return
}
if (
window.confirm(
this.__('Do you really want to leave? You have unsaved changes.')
)
) {
this.allowLeavingModal()
proceed()
return
}
revert()
},
},
computed: {
...mapGetters(['canLeaveModal']),
},
}

View File

@@ -0,0 +1,95 @@
export default {
data: () => ({
pollingListener: null,
currentlyPolling: false,
}),
/**
* Unbind the polling listener before the component is destroyed.
*/
beforeUnmount() {
this.stopPolling()
},
methods: {
initializePolling() {
this.currentlyPolling =
this.currentlyPolling || this.resourceResponse.polling
if (this.currentlyPolling && this.pollingListener === null) {
return this.startPolling()
}
},
/**
* Toggle polling for new resources.
*/
togglePolling() {
if (this.currentlyPolling) {
this.stopPolling()
} else {
this.startPolling()
}
},
/**
* Pause polling for new resources.
*/
stopPolling() {
if (this.pollingListener) {
clearInterval(this.pollingListener)
this.pollingListener = null
}
this.currentlyPolling = false
},
/**
* Start polling for new resources.
*/
startPolling() {
this.pollingListener = setInterval(() => {
let selectedResources = this.selectedResources ?? []
if (
document.hasFocus() &&
document.querySelectorAll('[data-modal-open]').length < 1 &&
selectedResources.length < 1
) {
this.getResources()
}
}, this.pollingInterval)
this.currentlyPolling = true
},
/**
* Restart polling for the resource.
*/
restartPolling() {
if (this.currentlyPolling === true) {
this.stopPolling()
this.startPolling()
}
},
},
computed: {
initiallyPolling() {
return this.resourceResponse.polling
},
pollingInterval() {
return this.resourceResponse.pollingInterval
},
/**
* Determine if the polling toggle button should be shown.
*/
shouldShowPollingToggle() {
if (!this.resourceResponse) return false
return this.resourceResponse.showPollingToggle || false
},
},
}

View File

@@ -0,0 +1,28 @@
export default {
data: () => ({
withTrashed: false,
}),
methods: {
/**
* Toggle the trashed state of the search
*/
toggleWithTrashed() {
this.withTrashed = !this.withTrashed
},
/**
* Enable searching for trashed resources
*/
enableWithTrashed() {
this.withTrashed = true
},
/**
* Disable searching for trashed resources
*/
disableWithTrashed() {
this.withTrashed = false
},
},
}

View File

@@ -0,0 +1,32 @@
export { mapProps } from './propTypes'
export { default as BehavesAsPanel } from './BehavesAsPanel'
export { default as CopiesToClipboard } from './CopiesToClipboard'
export { default as PreventsFormAbandonment } from './PreventsFormAbandonment'
export { default as PreventsModalAbandonment } from './PreventsModalAbandonment'
export { default as Deletable } from './Deletable'
export { default as DependentFormField } from './DependentFormField'
export { default as HandlesFormRequest } from './HandlesFormRequest'
export { default as HandlesUploads } from './HandlesUploads'
export { default as InteractsWithDates } from './InteractsWithDates'
export { default as InteractsWithQueryString } from './InteractsWithQueryString'
export { default as InteractsWithResourceInformation } from './InteractsWithResourceInformation'
export { default as Localization } from './Localization'
export { default as Collapsable } from './Collapsable'
export { default as MetricBehavior } from './MetricBehavior'
export { default as FormEvents } from './FormEvents'
export { default as FormField } from './FormField'
export { default as HandlesFieldAttachments } from './HandlesFieldAttachments'
export { default as HandlesValidationErrors } from './HandlesValidationErrors'
export { default as LoadsResources } from './LoadsResources'
export { default as TogglesTrashed } from './TogglesTrashed'
export { default as PerformsSearches } from './PerformsSearches'
export { default as HasCards } from './HasCards'
export { default as FieldSuggestions } from './FieldSuggestions'
export { default as FieldValue } from './FieldValue'
export { default as Filterable } from './Filterable'
export { default as HandlesPanelVisibility } from './HandlesPanelVisibility'
export { default as Paginatable } from './Paginatable'
export { default as PerPageable } from './PerPageable'
export { default as SupportsPolling } from './SupportsPolling'
export { default as IndexConcerns } from './IndexConcerns'
export { Errors } from 'form-backend-validation'

View File

@@ -0,0 +1,21 @@
export { mapProps } from './propTypes'
export {
default as CopiesToClipboard,
useCopyValueToClipboard,
} from './CopiesToClipboard'
export { default as PreventsFormAbandonment } from './PreventsFormAbandonment'
export { default as PreventsModalAbandonment } from './PreventsModalAbandonment'
export { default as DependentFormField } from './DependentFormField'
export { default as HandlesFormRequest } from './HandlesFormRequest'
export { default as HandlesUploads } from './HandlesUploads'
export { default as Localization } from './Localization'
export { default as MetricBehavior } from './MetricBehavior'
export { default as FieldValue } from './FieldValue'
export { default as FormEvents } from './FormEvents'
export { default as FormField } from './FormField'
export { default as HandlesFieldAttachments } from './HandlesFieldAttachments'
export { default as HandlesValidationErrors } from './HandlesValidationErrors'
export { default as HasCards } from './HasCards'
export { default as HandlesPanelVisibility } from './HandlesPanelVisibility'
export { Errors } from 'form-backend-validation'
export { useLocalization } from '../composables/useLocalization'

View File

@@ -0,0 +1,83 @@
import pick from 'lodash/pick'
const propTypes = {
nested: {
type: Boolean,
default: false,
},
preventInitialLoading: {
type: Boolean,
default: false,
},
showHelpText: {
type: Boolean,
default: false,
},
shownViaNewRelationModal: {
type: Boolean,
default: false,
},
resourceId: { type: [Number, String] },
resourceName: { type: String },
relatedResourceId: { type: [Number, String] },
relatedResourceName: { type: String },
field: {
type: Object,
required: true,
},
viaResource: {
type: String,
required: false,
},
viaResourceId: {
type: [String, Number],
required: false,
},
viaRelationship: {
type: String,
required: false,
},
relationshipType: {
type: String,
default: '',
},
shouldOverrideMeta: {
type: Boolean,
default: false,
},
disablePagination: {
type: Boolean,
default: false,
},
clickAction: {
type: String,
default: 'view',
validator: val => ['edit', 'select', 'ignore', 'detail'].includes(val),
},
mode: {
type: String,
default: 'form',
validator: v =>
['form', 'modal', 'action-modal', 'action-fullscreen'].includes(v),
},
}
export function mapProps(attributes) {
return pick(propTypes, attributes)
}