add nova
This commit is contained in:
16
nova/resources/js/fields/Form/AudioField.vue
Normal file
16
nova/resources/js/fields/Form/AudioField.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import FileField from '@/fields/Form/FileField'
|
||||
|
||||
export default {
|
||||
extends: FileField,
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Determining if the field is a Vapor field.
|
||||
*/
|
||||
isVaporField() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
546
nova/resources/js/fields/Form/BelongsToField.vue
Normal file
546
nova/resources/js/fields/Form/BelongsToField.vue
Normal file
@@ -0,0 +1,546 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<div class="flex items-center">
|
||||
<SearchInput
|
||||
v-if="useSearchInput"
|
||||
:dusk="`${field.resourceName}-search-input`"
|
||||
:disabled="currentlyIsReadonly"
|
||||
@input="performResourceSearch"
|
||||
@clear="clearResourceSelection"
|
||||
@selected="selectResource"
|
||||
:has-error="hasError"
|
||||
:debounce="currentField.debounce"
|
||||
:value="selectedResource"
|
||||
:data="filteredResources"
|
||||
:clearable="
|
||||
currentField.nullable ||
|
||||
editingExistingResource ||
|
||||
viaRelatedResource ||
|
||||
createdViaRelationModal
|
||||
"
|
||||
trackBy="value"
|
||||
class="w-full"
|
||||
:mode="mode"
|
||||
>
|
||||
<div v-if="selectedResource" class="flex items-center">
|
||||
<div v-if="selectedResource.avatar" class="mr-3">
|
||||
<img
|
||||
:src="selectedResource.avatar"
|
||||
class="w-8 h-8 rounded-full block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{ selectedResource.display }}
|
||||
</div>
|
||||
|
||||
<template #option="{ selected, option }">
|
||||
<SearchInputResult
|
||||
:option="option"
|
||||
:selected="selected"
|
||||
:with-subtitles="currentField.withSubtitles"
|
||||
/>
|
||||
</template>
|
||||
</SearchInput>
|
||||
|
||||
<SelectControl
|
||||
v-else
|
||||
class="w-full"
|
||||
:has-error="hasError"
|
||||
:dusk="`${field.resourceName}-select`"
|
||||
:disabled="currentlyIsReadonly"
|
||||
:options="availableResources"
|
||||
v-model:selected="selectedResourceId"
|
||||
@change="selectResourceFromSelectControl"
|
||||
label="display"
|
||||
>
|
||||
<option value="" selected :disabled="!currentField.nullable">
|
||||
{{ placeholder }}
|
||||
</option>
|
||||
</SelectControl>
|
||||
|
||||
<CreateRelationButton
|
||||
v-if="canShowNewRelationModal"
|
||||
v-tooltip="__('Create :resource', { resource: field.singularLabel })"
|
||||
@click="openRelationModal"
|
||||
:dusk="`${field.attribute}-inline-create`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CreateRelationModal
|
||||
:show="canShowNewRelationModal && relationModalOpen"
|
||||
:size="field.modalSize"
|
||||
@set-resource="handleSetResource"
|
||||
@create-cancelled="closeRelationModal"
|
||||
:resource-name="field.resourceName"
|
||||
:resource-id="resourceId"
|
||||
:via-relationship="viaRelationship"
|
||||
:via-resource="viaResource"
|
||||
:via-resource-id="viaResourceId"
|
||||
/>
|
||||
|
||||
<TrashedCheckbox
|
||||
v-if="shouldShowTrashed"
|
||||
class="mt-3"
|
||||
:resource-name="field.resourceName"
|
||||
:checked="withTrashed"
|
||||
@input="toggleWithTrashed"
|
||||
/>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import find from 'lodash/find'
|
||||
import isNil from 'lodash/isNil'
|
||||
import storage from '@/storage/BelongsToFieldStorage'
|
||||
import {
|
||||
DependentFormField,
|
||||
HandlesValidationErrors,
|
||||
InteractsWithQueryString,
|
||||
PerformsSearches,
|
||||
TogglesTrashed,
|
||||
} from '@/mixins'
|
||||
import filled from '@/util/filled'
|
||||
import findIndex from 'lodash/findIndex'
|
||||
|
||||
export default {
|
||||
mixins: [
|
||||
DependentFormField,
|
||||
HandlesValidationErrors,
|
||||
InteractsWithQueryString,
|
||||
PerformsSearches,
|
||||
TogglesTrashed,
|
||||
],
|
||||
|
||||
props: {
|
||||
resourceId: {},
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
availableResources: [],
|
||||
initializingWithExistingResource: false,
|
||||
createdViaRelationModal: false,
|
||||
selectedResource: null,
|
||||
selectedResourceId: null,
|
||||
softDeletes: false,
|
||||
withTrashed: false,
|
||||
search: '',
|
||||
relationModalOpen: false,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*/
|
||||
mounted() {
|
||||
this.initializeComponent()
|
||||
},
|
||||
|
||||
methods: {
|
||||
initializeComponent() {
|
||||
this.withTrashed = false
|
||||
|
||||
this.selectedResourceId = this.currentField.value
|
||||
|
||||
if (this.editingExistingResource) {
|
||||
// If a user is editing an existing resource with this relation
|
||||
// we'll have a belongsToId on the field, and we should prefill
|
||||
// that resource in this field
|
||||
this.initializingWithExistingResource = true
|
||||
this.selectedResourceId = this.currentField.belongsToId
|
||||
} else if (this.viaRelatedResource) {
|
||||
// If the user is creating this resource via a related resource's index
|
||||
// page we'll have a viaResource and viaResourceId in the params and
|
||||
// should prefill the resource in this field with that information
|
||||
this.initializingWithExistingResource = true
|
||||
this.selectedResourceId = this.viaResourceId
|
||||
}
|
||||
|
||||
if (this.shouldSelectInitialResource) {
|
||||
if (this.useSearchInput) {
|
||||
// If we should select the initial resource and the field is
|
||||
// searchable, we won't load all the resources but we will select
|
||||
// the initial option.
|
||||
this.getAvailableResources().then(() => this.selectInitialResource())
|
||||
} else {
|
||||
// If we should select the initial resource but the field is not
|
||||
// searchable we should load all of the available resources into the
|
||||
// field first and select the initial option.
|
||||
this.initializingWithExistingResource = false
|
||||
|
||||
this.getAvailableResources().then(() => this.selectInitialResource())
|
||||
}
|
||||
} else if (!this.isSearchable && this.currentlyIsVisible) {
|
||||
// If we don't need to select an initial resource because the user
|
||||
// came to create a resource directly and there's no parent resource,
|
||||
// and the field is searchable we'll just load all of the resources.
|
||||
this.getAvailableResources()
|
||||
}
|
||||
|
||||
this.determineIfSoftDeletes()
|
||||
|
||||
this.field.fill = this.fill
|
||||
},
|
||||
|
||||
/**
|
||||
* Select a resource using the <select> control
|
||||
*/
|
||||
selectResourceFromSelectControl(value) {
|
||||
this.selectedResourceId = value
|
||||
this.selectInitialResource()
|
||||
|
||||
if (this.field) {
|
||||
this.emitFieldValueChange(this.fieldAttribute, this.selectedResourceId)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fill the forms formData with details from this field
|
||||
*/
|
||||
fill(formData) {
|
||||
this.fillIfVisible(
|
||||
formData,
|
||||
this.fieldAttribute,
|
||||
this.selectedResource ? this.selectedResource.value : ''
|
||||
)
|
||||
this.fillIfVisible(
|
||||
formData,
|
||||
`${this.fieldAttribute}_trashed`,
|
||||
this.withTrashed
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the resources that may be related to this resource.
|
||||
*/
|
||||
getAvailableResources() {
|
||||
Nova.$progress.start()
|
||||
|
||||
return storage
|
||||
.fetchAvailableResources(this.resourceName, this.fieldAttribute, {
|
||||
params: this.queryParams,
|
||||
})
|
||||
.then(({ data: { resources, softDeletes, withTrashed } }) => {
|
||||
Nova.$progress.done()
|
||||
|
||||
if (this.initializingWithExistingResource || !this.isSearchable) {
|
||||
this.withTrashed = withTrashed
|
||||
}
|
||||
|
||||
if (this.viaRelatedResource) {
|
||||
let selectedResource = find(resources, r =>
|
||||
this.isSelectedResourceId(r.value)
|
||||
)
|
||||
|
||||
if (
|
||||
isNil(selectedResource) &&
|
||||
!this.shouldIgnoreViaRelatedResource
|
||||
) {
|
||||
return Nova.visit('/404')
|
||||
}
|
||||
}
|
||||
|
||||
// Turn off initializing the existing resource after the first time
|
||||
if (this.useSearchInput) {
|
||||
this.initializingWithExistingResource = false
|
||||
}
|
||||
this.availableResources = resources
|
||||
this.softDeletes = softDeletes
|
||||
})
|
||||
.catch(e => {
|
||||
Nova.$progress.done()
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine if the relatd resource is soft deleting.
|
||||
*/
|
||||
determineIfSoftDeletes() {
|
||||
return storage
|
||||
.determineIfSoftDeletes(this.field.resourceName)
|
||||
.then(response => {
|
||||
this.softDeletes = response.data.softDeletes
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine if the given value is numeric.
|
||||
*/
|
||||
isNumeric(value) {
|
||||
return !isNaN(parseFloat(value)) && isFinite(value)
|
||||
},
|
||||
|
||||
/**
|
||||
* Select the initial selected resource
|
||||
*/
|
||||
selectInitialResource() {
|
||||
this.selectedResource = find(this.availableResources, r =>
|
||||
this.isSelectedResourceId(r.value)
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle the trashed state of the search
|
||||
*/
|
||||
toggleWithTrashed() {
|
||||
let currentlySelectedResource
|
||||
let currentlySelectedResourceId
|
||||
|
||||
if (filled(this.selectedResource)) {
|
||||
currentlySelectedResource = this.selectedResource
|
||||
currentlySelectedResourceId = this.selectedResource.value
|
||||
}
|
||||
|
||||
this.withTrashed = !this.withTrashed
|
||||
|
||||
this.selectedResource = null
|
||||
this.selectedResourceId = null
|
||||
|
||||
if (!this.useSearchInput) {
|
||||
this.getAvailableResources().then(() => {
|
||||
let index = findIndex(this.availableResources, r => {
|
||||
return r.value === currentlySelectedResourceId
|
||||
})
|
||||
|
||||
if (index > -1) {
|
||||
this.selectedResource = this.availableResources[index]
|
||||
this.selectedResourceId = currentlySelectedResourceId
|
||||
} else {
|
||||
// We didn't find the resource anymore, so let's remove the selection...
|
||||
this.selectedResource = null
|
||||
this.selectedResourceId = null
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
openRelationModal() {
|
||||
Nova.$emit('create-relation-modal-opened')
|
||||
this.relationModalOpen = true
|
||||
},
|
||||
|
||||
closeRelationModal() {
|
||||
this.relationModalOpen = false
|
||||
Nova.$emit('create-relation-modal-closed')
|
||||
},
|
||||
|
||||
handleSetResource({ id }) {
|
||||
this.closeRelationModal()
|
||||
this.selectedResourceId = id
|
||||
this.initializingWithExistingResource = true
|
||||
this.createdViaRelationModal = true
|
||||
this.getAvailableResources().then(() => {
|
||||
this.selectInitialResource()
|
||||
|
||||
this.emitFieldValueChange(this.fieldAttribute, this.selectedResourceId)
|
||||
})
|
||||
},
|
||||
|
||||
performResourceSearch(search) {
|
||||
if (this.useSearchInput) {
|
||||
this.performSearch(search)
|
||||
} else {
|
||||
this.search = search
|
||||
}
|
||||
},
|
||||
|
||||
clearResourceSelection() {
|
||||
const id = this.selectedResourceId
|
||||
|
||||
this.clearSelection()
|
||||
|
||||
if (this.viaRelatedResource && !this.createdViaRelationModal) {
|
||||
this.updateQueryString({
|
||||
viaResource: null,
|
||||
viaResourceId: null,
|
||||
viaRelationship: null,
|
||||
relationshipType: null,
|
||||
}).then(() => {
|
||||
Nova.$router.reload({
|
||||
onSuccess: () => {
|
||||
this.initializingWithExistingResource = false
|
||||
this.initializeComponent()
|
||||
},
|
||||
})
|
||||
})
|
||||
} else {
|
||||
if (this.createdViaRelationModal) {
|
||||
this.selectedResourceId = id
|
||||
this.createdViaRelationModal = false
|
||||
this.initializingWithExistingResource = true
|
||||
} else if (this.editingExistingResource) {
|
||||
this.initializingWithExistingResource = false
|
||||
}
|
||||
|
||||
if (
|
||||
(!this.isSearchable || this.shouldLoadFirstResource) &&
|
||||
this.currentlyIsVisible
|
||||
) {
|
||||
this.getAvailableResources()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onSyncedField() {
|
||||
if (this.viaRelatedResource) {
|
||||
return
|
||||
}
|
||||
|
||||
this.initializeComponent()
|
||||
|
||||
if (isNil(this.syncedField.value) && isNil(this.selectedResourceId)) {
|
||||
this.selectInitialResource()
|
||||
}
|
||||
},
|
||||
|
||||
emitOnSyncedFieldValueChange() {
|
||||
if (this.viaRelatedResource) {
|
||||
return
|
||||
}
|
||||
|
||||
this.emitFieldValueChange(this.fieldAttribute, this.selectedResourceId)
|
||||
},
|
||||
|
||||
syncedFieldValueHasNotChanged() {
|
||||
return this.isSelectedResourceId(this.currentField.value)
|
||||
},
|
||||
|
||||
isSelectedResourceId(value) {
|
||||
return (
|
||||
!isNil(value) &&
|
||||
value?.toString() === this.selectedResourceId?.toString()
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Determine if we are editing and existing resource
|
||||
*/
|
||||
editingExistingResource() {
|
||||
return filled(this.field.belongsToId)
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine if we are creating a new resource via a parent relation
|
||||
*/
|
||||
viaRelatedResource() {
|
||||
return Boolean(
|
||||
this.viaResource === this.field.resourceName &&
|
||||
this.field.reverse &&
|
||||
this.viaResourceId
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine if we should select an initial resource when mounting this field
|
||||
*/
|
||||
shouldSelectInitialResource() {
|
||||
return Boolean(
|
||||
this.editingExistingResource ||
|
||||
this.viaRelatedResource ||
|
||||
this.currentField.value
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine if the related resources is searchable
|
||||
*/
|
||||
isSearchable() {
|
||||
return Boolean(this.currentField.searchable)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the query params for getting available resources
|
||||
*/
|
||||
queryParams() {
|
||||
return {
|
||||
current: this.selectedResourceId,
|
||||
first: this.shouldLoadFirstResource,
|
||||
search: this.search,
|
||||
withTrashed: this.withTrashed,
|
||||
resourceId: this.resourceId,
|
||||
viaResource: this.viaResource,
|
||||
viaResourceId: this.viaResourceId,
|
||||
viaRelationship: this.viaRelationship,
|
||||
component: this.field.dependentComponentKey,
|
||||
dependsOn: this.encodedDependentFieldValues,
|
||||
editing: true,
|
||||
editMode:
|
||||
isNil(this.resourceId) || this.resourceId === ''
|
||||
? 'create'
|
||||
: 'update',
|
||||
}
|
||||
},
|
||||
|
||||
shouldLoadFirstResource() {
|
||||
return (
|
||||
(this.initializingWithExistingResource &&
|
||||
!this.shouldIgnoreViaRelatedResource) ||
|
||||
Boolean(this.currentlyIsReadonly && this.selectedResourceId)
|
||||
)
|
||||
},
|
||||
|
||||
shouldShowTrashed() {
|
||||
return (
|
||||
this.softDeletes &&
|
||||
!this.viaRelatedResource &&
|
||||
!this.currentlyIsReadonly &&
|
||||
this.currentField.displaysWithTrashed
|
||||
)
|
||||
},
|
||||
|
||||
authorizedToCreate() {
|
||||
return find(Nova.config('resources'), resource => {
|
||||
return resource.uriKey === this.field.resourceName
|
||||
}).authorizedToCreate
|
||||
},
|
||||
|
||||
canShowNewRelationModal() {
|
||||
return (
|
||||
this.currentField.showCreateRelationButton &&
|
||||
!this.shownViaNewRelationModal &&
|
||||
!this.viaRelatedResource &&
|
||||
!this.currentlyIsReadonly &&
|
||||
this.authorizedToCreate
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the placeholder text for the field.
|
||||
*/
|
||||
placeholder() {
|
||||
return this.currentField.placeholder || this.__('—')
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the field options filtered by the search string.
|
||||
*/
|
||||
filteredResources() {
|
||||
if (!this.isSearchable) {
|
||||
return this.availableResources.filter(option => {
|
||||
return (
|
||||
option.display.toLowerCase().indexOf(this.search.toLowerCase()) >
|
||||
-1 || new String(option.value).indexOf(this.search) > -1
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return this.availableResources
|
||||
},
|
||||
|
||||
shouldIgnoreViaRelatedResource() {
|
||||
return this.viaRelatedResource && filled(this.search)
|
||||
},
|
||||
|
||||
useSearchInput() {
|
||||
return this.isSearchable || this.viaRelatedResource
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
75
nova/resources/js/fields/Form/BooleanField.vue
Normal file
75
nova/resources/js/fields/Form/BooleanField.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<Checkbox
|
||||
:disabled="currentlyIsReadonly"
|
||||
:dusk="currentField.uniqueKey"
|
||||
:id="currentField.uniqueKey"
|
||||
:model-value="checked"
|
||||
:name="field.name"
|
||||
@change="toggle"
|
||||
class="mt-2"
|
||||
/>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Checkbox } from 'laravel-nova-ui'
|
||||
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Checkbox,
|
||||
},
|
||||
|
||||
mixins: [HandlesValidationErrors, DependentFormField],
|
||||
|
||||
methods: {
|
||||
/*
|
||||
* Set the initial value for the field
|
||||
*/
|
||||
setInitialValue() {
|
||||
this.value = this.currentField.value ?? this.value
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the field default value.
|
||||
*/
|
||||
fieldDefaultValue() {
|
||||
return false
|
||||
},
|
||||
|
||||
/**
|
||||
* Provide a function that fills a passed FormData object with the
|
||||
* field's internal value attribute
|
||||
*/
|
||||
fill(formData) {
|
||||
this.fillIfVisible(formData, this.fieldAttribute, this.trueValue)
|
||||
},
|
||||
|
||||
toggle() {
|
||||
this.value = !this.value
|
||||
|
||||
if (this.field) {
|
||||
this.emitFieldValueChange(this.fieldAttribute, this.value)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
checked() {
|
||||
return Boolean(this.value)
|
||||
},
|
||||
|
||||
trueValue() {
|
||||
return +this.checked
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
97
nova/resources/js/fields/Form/BooleanGroupField.vue
Normal file
97
nova/resources/js/fields/Form/BooleanGroupField.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<div class="space-y-2">
|
||||
<CheckboxWithLabel
|
||||
v-for="option in value"
|
||||
:key="option.name"
|
||||
:name="option.name"
|
||||
:checked="option.checked"
|
||||
@input="toggle($event, option)"
|
||||
:disabled="currentlyIsReadonly"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
</CheckboxWithLabel>
|
||||
</div>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import find from 'lodash/find'
|
||||
import isNil from 'lodash/isNil'
|
||||
import fromPairs from 'lodash/fromPairs'
|
||||
import map from 'lodash/map'
|
||||
import merge from 'lodash/merge'
|
||||
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
|
||||
|
||||
export default {
|
||||
mixins: [HandlesValidationErrors, DependentFormField],
|
||||
|
||||
data: () => ({
|
||||
value: {},
|
||||
}),
|
||||
|
||||
methods: {
|
||||
/*
|
||||
* Set the initial value for the field
|
||||
*/
|
||||
setInitialValue() {
|
||||
let values = merge(this.finalPayload, this.currentField.value || {})
|
||||
|
||||
this.value = map(this.currentField.options, o => {
|
||||
return {
|
||||
name: o.name,
|
||||
label: o.label,
|
||||
checked: values[o.name] || false,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Provide a function that fills a passed FormData object with the
|
||||
* field's internal value attribute.
|
||||
*/
|
||||
fill(formData) {
|
||||
this.fillIfVisible(
|
||||
formData,
|
||||
this.fieldAttribute,
|
||||
JSON.stringify(this.finalPayload)
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle the option's value.
|
||||
*/
|
||||
toggle(event, option) {
|
||||
const firstOption = find(this.value, o => o.name == option.name)
|
||||
firstOption.checked = event.target.checked
|
||||
|
||||
if (this.field) {
|
||||
this.emitFieldValueChange(
|
||||
this.fieldAttribute,
|
||||
JSON.stringify(this.finalPayload)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
onSyncedField() {
|
||||
this.setInitialValue()
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Return the final filtered json object
|
||||
*/
|
||||
finalPayload() {
|
||||
return fromPairs(map(this.value, o => [o.name, o.checked]))
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
86
nova/resources/js/fields/Form/CodeField.vue
Normal file
86
nova/resources/js/fields/Form/CodeField.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:full-width-content="fullWidthContent"
|
||||
:show-help-text="showHelpText"
|
||||
>
|
||||
<template #field>
|
||||
<textarea
|
||||
ref="theTextarea"
|
||||
:id="currentField.uniqueKey"
|
||||
class="w-full form-control form-input form-control-bordered py-3 h-auto"
|
||||
/>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CodeMirror from 'codemirror'
|
||||
|
||||
// Modes
|
||||
|
||||
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
|
||||
|
||||
export default {
|
||||
mixins: [HandlesValidationErrors, DependentFormField],
|
||||
|
||||
codemirror: null,
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*/
|
||||
mounted() {
|
||||
this.setInitialValue()
|
||||
|
||||
if (this.isVisible) {
|
||||
this.handleShowingComponent()
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
currentlyIsVisible(current, previous) {
|
||||
if (current === true && previous === false) {
|
||||
this.$nextTick(() => this.handleShowingComponent())
|
||||
} else if (current === false && previous === true) {
|
||||
this.handleHidingComponent()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleShowingComponent() {
|
||||
const config = {
|
||||
tabSize: 4,
|
||||
indentWithTabs: true,
|
||||
lineWrapping: true,
|
||||
lineNumbers: true,
|
||||
theme: 'dracula',
|
||||
...{ readOnly: this.currentlyIsReadonly },
|
||||
...this.currentField.options,
|
||||
}
|
||||
|
||||
this.codemirror = CodeMirror.fromTextArea(this.$refs.theTextarea, config)
|
||||
this.codemirror.getDoc().setValue(this.value ?? this.currentField.value)
|
||||
this.codemirror.setSize('100%', this.currentField.height)
|
||||
this.codemirror.getDoc().on('change', (cm, changeObj) => {
|
||||
this.value = cm.getValue()
|
||||
|
||||
if (this.field) {
|
||||
this.emitFieldValueChange(this.fieldAttribute, this.value)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
handleHidingComponent() {
|
||||
this.codemirror = null
|
||||
},
|
||||
|
||||
onSyncedField() {
|
||||
if (this.codemirror) {
|
||||
this.codemirror.getDoc().setValue(this.currentField.value)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
50
nova/resources/js/fields/Form/ColorField.vue
Normal file
50
nova/resources/js/fields/Form/ColorField.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<input
|
||||
v-bind="defaultAttributes"
|
||||
class="bg-white form-control form-input form-control-bordered p-2"
|
||||
type="color"
|
||||
@input="handleChange"
|
||||
:value="value"
|
||||
:id="currentField.uniqueKey"
|
||||
:dusk="field.attribute"
|
||||
:disabled="currentlyIsReadonly"
|
||||
/>
|
||||
|
||||
<datalist v-if="suggestions.length > 0" :id="suggestionsId">
|
||||
<option
|
||||
:key="suggestion"
|
||||
v-for="suggestion in suggestions"
|
||||
:value="suggestion"
|
||||
/>
|
||||
</datalist>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
DependentFormField,
|
||||
FieldSuggestions,
|
||||
HandlesValidationErrors,
|
||||
} from '@/mixins'
|
||||
|
||||
export default {
|
||||
mixins: [DependentFormField, FieldSuggestions, HandlesValidationErrors],
|
||||
|
||||
computed: {
|
||||
defaultAttributes() {
|
||||
return {
|
||||
class: this.errorClasses,
|
||||
...this.suggestionsAttributes,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
65
nova/resources/js/fields/Form/CurrencyField.vue
Normal file
65
nova/resources/js/fields/Form/CurrencyField.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<div class="flex flex-wrap items-stretch w-full relative">
|
||||
<div class="flex -mr-px">
|
||||
<span
|
||||
class="flex items-center leading-normal rounded rounded-r-none border border-r-0 border-gray-300 dark:border-gray-700 px-3 whitespace-nowrap bg-gray-100 dark:bg-gray-800 text-gray-500 text-sm font-bold"
|
||||
>
|
||||
{{ currentField.currency }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
class="flex-shrink flex-grow flex-auto leading-normal w-px flex-1 rounded-l-none form-control form-input form-control-bordered"
|
||||
:id="currentField.uniqueKey"
|
||||
:dusk="field.attribute"
|
||||
v-bind="extraAttributes"
|
||||
:disabled="currentlyIsReadonly"
|
||||
@input="handleChange"
|
||||
:value="value"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
|
||||
|
||||
export default {
|
||||
mixins: [HandlesValidationErrors, DependentFormField],
|
||||
|
||||
props: ['resourceName', 'resourceId', 'field'],
|
||||
|
||||
computed: {
|
||||
defaultAttributes() {
|
||||
return {
|
||||
type: 'number',
|
||||
min: this.currentField.min,
|
||||
max: this.currentField.max,
|
||||
step: this.currentField.step,
|
||||
pattern: this.currentField.pattern,
|
||||
placeholder: this.currentField.placeholder || this.field.name,
|
||||
class: this.errorClasses,
|
||||
}
|
||||
},
|
||||
extraAttributes() {
|
||||
const attrs = this.currentField.extraAttributes
|
||||
|
||||
return {
|
||||
// Leave the default attributes even though we can now specify
|
||||
// whatever attributes we like because the old number field still
|
||||
// uses the old field attributes
|
||||
...this.defaultAttributes,
|
||||
...attrs,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
72
nova/resources/js/fields/Form/DateField.vue
Normal file
72
nova/resources/js/fields/Form/DateField.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="date"
|
||||
class="form-control form-input form-control-bordered"
|
||||
ref="dateTimePicker"
|
||||
:id="currentField.uniqueKey"
|
||||
:dusk="field.attribute"
|
||||
:name="field.name"
|
||||
:value="value"
|
||||
:class="errorClasses"
|
||||
:disabled="currentlyIsReadonly"
|
||||
@change="handleChange"
|
||||
:min="currentField.min"
|
||||
:max="currentField.max"
|
||||
:step="currentField.step"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import isNil from 'lodash/isNil'
|
||||
import { DateTime } from 'luxon'
|
||||
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
|
||||
import filled from '@/util/filled'
|
||||
|
||||
export default {
|
||||
mixins: [HandlesValidationErrors, DependentFormField],
|
||||
|
||||
methods: {
|
||||
/*
|
||||
* Set the initial value for the field
|
||||
*/
|
||||
setInitialValue() {
|
||||
if (!isNil(this.currentField.value)) {
|
||||
this.value = DateTime.fromISO(
|
||||
this.currentField.value || this.value
|
||||
).toISODate()
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* On save, populate our form data
|
||||
*/
|
||||
fill(formData) {
|
||||
if (this.currentlyIsVisible) {
|
||||
this.fillIfVisible(formData, this.fieldAttribute, this.value)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the field's internal value
|
||||
*/
|
||||
handleChange(event) {
|
||||
this.value = event?.target?.value ?? event
|
||||
|
||||
if (this.field) {
|
||||
this.emitFieldValueChange(this.fieldAttribute, this.value)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
114
nova/resources/js/fields/Form/DateTimeField.vue
Normal file
114
nova/resources/js/fields/Form/DateTimeField.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="datetime-local"
|
||||
class="form-control form-input form-control-bordered"
|
||||
ref="dateTimePicker"
|
||||
:id="currentField.uniqueKey"
|
||||
:dusk="field.attribute"
|
||||
:name="field.name"
|
||||
:value="formattedDate"
|
||||
:class="errorClasses"
|
||||
:disabled="currentlyIsReadonly"
|
||||
@change="handleChange"
|
||||
:min="currentField.min"
|
||||
:max="currentField.max"
|
||||
:step="currentField.step"
|
||||
/>
|
||||
|
||||
<span class="ml-3">
|
||||
{{ timezone }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import isNil from 'lodash/isNil'
|
||||
import { DateTime } from 'luxon'
|
||||
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
|
||||
import filled from '@/util/filled'
|
||||
|
||||
export default {
|
||||
mixins: [HandlesValidationErrors, DependentFormField],
|
||||
|
||||
data: () => ({
|
||||
formattedDate: '',
|
||||
}),
|
||||
|
||||
methods: {
|
||||
/*
|
||||
* Set the initial value for the field
|
||||
*/
|
||||
setInitialValue() {
|
||||
if (!isNil(this.currentField.value)) {
|
||||
let isoDate = DateTime.fromISO(this.currentField.value || this.value, {
|
||||
zone: Nova.config('timezone'),
|
||||
})
|
||||
|
||||
this.value = isoDate.toString()
|
||||
|
||||
isoDate = isoDate.setZone(this.timezone)
|
||||
|
||||
this.formattedDate = [
|
||||
isoDate.toISODate(),
|
||||
isoDate.toFormat(this.timeFormat),
|
||||
].join('T')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* On save, populate our form data
|
||||
*/
|
||||
fill(formData) {
|
||||
this.fillIfVisible(formData, this.fieldAttribute, this.value || '')
|
||||
|
||||
if (this.currentlyIsVisible && filled(this.value)) {
|
||||
let isoDate = DateTime.fromISO(this.value, { zone: this.timezone })
|
||||
|
||||
this.formattedDate = [
|
||||
isoDate.toISODate(),
|
||||
isoDate.toFormat(this.timeFormat),
|
||||
].join('T')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the field's internal value
|
||||
*/
|
||||
handleChange(event) {
|
||||
let value = event?.target?.value ?? event
|
||||
|
||||
if (filled(value)) {
|
||||
let isoDate = DateTime.fromISO(value, { zone: this.timezone })
|
||||
|
||||
this.value = isoDate.setZone(Nova.config('timezone')).toString()
|
||||
} else {
|
||||
this.value = this.fieldDefaultValue()
|
||||
}
|
||||
|
||||
if (this.field) {
|
||||
this.emitFieldValueChange(this.fieldAttribute, this.value)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
timeFormat() {
|
||||
return this.currentField.step % 60 === 0 ? 'HH:mm' : 'HH:mm:ss'
|
||||
},
|
||||
|
||||
timezone() {
|
||||
return Nova.config('userTimezone') || Nova.config('timezone')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
43
nova/resources/js/fields/Form/EmailField.vue
Normal file
43
nova/resources/js/fields/Form/EmailField.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<input
|
||||
v-bind="extraAttributes"
|
||||
class="w-full form-control form-input form-control-bordered"
|
||||
@input="handleChange"
|
||||
:value="value"
|
||||
:id="currentField.uniqueKey"
|
||||
:dusk="field.attribute"
|
||||
:disabled="currentlyIsReadonly"
|
||||
/>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
|
||||
|
||||
export default {
|
||||
mixins: [HandlesValidationErrors, DependentFormField],
|
||||
|
||||
computed: {
|
||||
extraAttributes() {
|
||||
return {
|
||||
// Leave the default attributes even though we can now specify
|
||||
// whatever attributes we like because the old number field still
|
||||
// uses the old field attributes
|
||||
type: this.currentField.type || 'email',
|
||||
pattern: this.currentField.pattern,
|
||||
placeholder: this.currentField.placeholder || this.field.name,
|
||||
class: this.errorClasses,
|
||||
...this.currentField.extraAttributes,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
318
nova/resources/js/fields/Form/FileField.vue
Normal file
318
nova/resources/js/fields/Form/FileField.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:label-for="labelFor"
|
||||
:errors="errors"
|
||||
:show-help-text="!isReadonly && showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<!-- Existing Image -->
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="hasValue && previewFile && files.length === 0"
|
||||
class="grid grid-cols-4 gap-x-6 gap-y-2"
|
||||
>
|
||||
<FilePreviewBlock
|
||||
v-if="previewFile"
|
||||
:file="previewFile"
|
||||
:removable="shouldShowRemoveButton"
|
||||
@removed="confirmRemoval"
|
||||
:rounded="field.rounded"
|
||||
:dusk="`${field.attribute}-delete-link`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Upload Removal Modal -->
|
||||
<ConfirmUploadRemovalModal
|
||||
:show="removeModalOpen"
|
||||
@confirm="removeUploadedFile"
|
||||
@close="closeRemoveModal"
|
||||
/>
|
||||
|
||||
<!-- DropZone -->
|
||||
<DropZone
|
||||
v-if="shouldShowField"
|
||||
:files="files"
|
||||
@file-changed="handleFileChange"
|
||||
@file-removed="file = null"
|
||||
:rounded="field.rounded"
|
||||
:accepted-types="field.acceptedTypes"
|
||||
:disabled="file?.processing"
|
||||
:dusk="`${field.attribute}-delete-link`"
|
||||
:input-dusk="field.attribute"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { DependentFormField, Errors, HandlesValidationErrors } from '@/mixins'
|
||||
import InlineFormData from './InlineFormData'
|
||||
|
||||
import Vapor from 'laravel-vapor'
|
||||
|
||||
function createFile(file) {
|
||||
return {
|
||||
name: file.name,
|
||||
extension: file.name.split('.').pop(),
|
||||
type: file.type,
|
||||
originalFile: file,
|
||||
vapor: false,
|
||||
processing: false,
|
||||
progress: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
emits: ['file-upload-started', 'file-upload-finished', 'file-deleted'],
|
||||
|
||||
mixins: [HandlesValidationErrors, DependentFormField],
|
||||
|
||||
inject: ['removeFile'],
|
||||
|
||||
expose: ['beforeRemove'],
|
||||
|
||||
data: () => ({
|
||||
previewFile: null,
|
||||
file: null,
|
||||
removeModalOpen: false,
|
||||
missing: false,
|
||||
deleted: false,
|
||||
uploadErrors: new Errors(),
|
||||
vaporFile: {
|
||||
key: '',
|
||||
uuid: '',
|
||||
filename: '',
|
||||
extension: '',
|
||||
},
|
||||
uploadProgress: 0,
|
||||
startedDrag: false,
|
||||
|
||||
uploadModalShown: false,
|
||||
}),
|
||||
|
||||
async mounted() {
|
||||
this.preparePreviewImage()
|
||||
|
||||
this.field.fill = formData => {
|
||||
let attribute = this.fieldAttribute
|
||||
|
||||
if (this.file && !this.isVaporField) {
|
||||
formData.append(attribute, this.file.originalFile, this.file.name)
|
||||
}
|
||||
|
||||
if (this.file && this.isVaporField) {
|
||||
formData.append(attribute, this.file.name)
|
||||
|
||||
this.fillVaporFilePayload(formData, attribute)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
preparePreviewImage() {
|
||||
if (this.hasValue && this.imageUrl) {
|
||||
this.fetchPreviewImage()
|
||||
}
|
||||
|
||||
if (this.hasValue && !this.imageUrl) {
|
||||
this.previewFile = createFile({
|
||||
name: this.currentField.value,
|
||||
type: this.currentField.value.split('.').pop(),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
async fetchPreviewImage() {
|
||||
let response = await fetch(this.imageUrl)
|
||||
let data = await response.blob()
|
||||
|
||||
this.previewFile = createFile(
|
||||
new File([data], this.currentField.value, { type: data.type })
|
||||
)
|
||||
},
|
||||
|
||||
handleFileChange(newFiles) {
|
||||
this.file = createFile(newFiles[0])
|
||||
|
||||
if (this.isVaporField) {
|
||||
this.file.vapor = true
|
||||
this.uploadVaporFiles()
|
||||
}
|
||||
},
|
||||
|
||||
uploadVaporFiles() {
|
||||
this.file.processing = true
|
||||
this.$emit('file-upload-started')
|
||||
|
||||
Vapor.store(this.file.originalFile, {
|
||||
progress: progress => {
|
||||
this.file.progress = Math.round(progress * 100)
|
||||
},
|
||||
})
|
||||
.then(response => {
|
||||
this.vaporFile.key = response.key
|
||||
this.vaporFile.uuid = response.uuid
|
||||
this.vaporFile.filename = this.file.name
|
||||
this.vaporFile.extension = this.file.extension
|
||||
this.file.processing = false
|
||||
this.file.progress = 100
|
||||
this.$emit('file-upload-finished')
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.response.status === 403) {
|
||||
Nova.error(
|
||||
this.__('Sorry! You are not authorized to perform this action.')
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
confirmRemoval() {
|
||||
this.removeModalOpen = true
|
||||
},
|
||||
|
||||
closeRemoveModal() {
|
||||
this.removeModalOpen = false
|
||||
},
|
||||
|
||||
beforeRemove() {
|
||||
this.removeUploadedFile()
|
||||
},
|
||||
|
||||
async removeUploadedFile() {
|
||||
// this.uploadErrors = new Errors()
|
||||
try {
|
||||
await this.removeFile(this.fieldAttribute)
|
||||
this.$emit('file-deleted')
|
||||
this.deleted = true
|
||||
this.file = null
|
||||
Nova.success(this.__('The file was deleted!'))
|
||||
} catch (error) {
|
||||
if (error.response?.status === 422) {
|
||||
this.uploadErrors = new Errors(error.response.data.errors)
|
||||
}
|
||||
} finally {
|
||||
this.closeRemoveModal()
|
||||
}
|
||||
},
|
||||
|
||||
fillVaporFilePayload(formData, attribute) {
|
||||
const vaporAttribute =
|
||||
formData instanceof InlineFormData
|
||||
? formData.slug(attribute)
|
||||
: attribute
|
||||
|
||||
const vaporFormData =
|
||||
formData instanceof InlineFormData ? formData.formData : formData
|
||||
|
||||
vaporFormData.append(
|
||||
`vaporFile[${vaporAttribute}][key]`,
|
||||
this.vaporFile.key
|
||||
)
|
||||
vaporFormData.append(
|
||||
`vaporFile[${vaporAttribute}][uuid]`,
|
||||
this.vaporFile.uuid
|
||||
)
|
||||
vaporFormData.append(
|
||||
`vaporFile[${vaporAttribute}][filename]`,
|
||||
this.vaporFile.filename
|
||||
)
|
||||
vaporFormData.append(
|
||||
`vaporFile[${vaporAttribute}][extension]`,
|
||||
this.vaporFile.extension
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
files() {
|
||||
return this.file ? [this.file] : []
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine if the field has an upload error.
|
||||
*/
|
||||
hasError() {
|
||||
return this.uploadErrors.has(this.fieldAttribute)
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the first error for the field.
|
||||
*/
|
||||
firstError() {
|
||||
if (this.hasError) {
|
||||
return this.uploadErrors.first(this.fieldAttribute)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The ID attribute to use for the file field.
|
||||
*/
|
||||
idAttr() {
|
||||
return this.labelFor
|
||||
},
|
||||
|
||||
/**
|
||||
* The label attribute to use for the file field.
|
||||
*/
|
||||
labelFor() {
|
||||
let name = this.resourceName
|
||||
|
||||
if (this.relatedResourceName) {
|
||||
name += '-' + this.relatedResourceName
|
||||
}
|
||||
|
||||
return `file-${name}-${this.fieldAttribute}`
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine whether the field has a value.
|
||||
*/
|
||||
hasValue() {
|
||||
return (
|
||||
Boolean(this.field.value || this.imageUrl) &&
|
||||
!Boolean(this.deleted) &&
|
||||
!Boolean(this.missing)
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine whether the field should show the loader component.
|
||||
*/
|
||||
shouldShowLoader() {
|
||||
return !Boolean(this.deleted) && Boolean(this.imageUrl)
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine whether the file field input should be shown.
|
||||
*/
|
||||
shouldShowField() {
|
||||
return Boolean(!this.currentlyIsReadonly)
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine whether the field should show the remove button.
|
||||
*/
|
||||
shouldShowRemoveButton() {
|
||||
return Boolean(this.currentField.deletable && !this.currentlyIsReadonly)
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the preview or thumbnail URL for the field.
|
||||
*/
|
||||
imageUrl() {
|
||||
return this.currentField.previewUrl || this.currentField.thumbnailUrl
|
||||
},
|
||||
|
||||
/**
|
||||
* Determining if the field is a Vapor field.
|
||||
*/
|
||||
isVaporField() {
|
||||
return this.currentField.component === 'vapor-file-field'
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
236
nova/resources/js/fields/Form/HasOneField.vue
Normal file
236
nova/resources/js/fields/Form/HasOneField.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<Card>
|
||||
<LoadingView :loading="loading">
|
||||
<template v-if="isEditing">
|
||||
<component
|
||||
v-for="(field, index) in availableFields"
|
||||
:index="index"
|
||||
:key="index"
|
||||
:is="`form-${field.component}`"
|
||||
:errors="errors"
|
||||
:resource-id="resourceId"
|
||||
:resource-name="resourceName"
|
||||
:field="field"
|
||||
:via-resource="viaResource"
|
||||
:via-resource-id="viaResourceId"
|
||||
:via-relationship="viaRelationship"
|
||||
:shown-via-new-relation-modal="false"
|
||||
:form-unique-id="formUniqueId"
|
||||
@field-changed="$emit('field-changed')"
|
||||
@file-deleted="handleFileDeleted"
|
||||
@file-upload-started="$emit('file-upload-started')"
|
||||
@file-upload-finished="$emit('file-upload-finished')"
|
||||
:show-help-text="showHelpText"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="flex flex-col justify-center items-center px-6 py-8">
|
||||
<button
|
||||
class="focus:outline-none focus:ring rounded border-2 border-primary-300 dark:border-gray-500 hover:border-primary-500 active:border-primary-400 dark:hover:border-gray-400 dark:active:border-gray-300 bg-white dark:bg-transparent text-primary-500 dark:text-gray-400 px-3 h-9 inline-flex items-center font-bold shrink-0"
|
||||
:dusk="`create-${field.attribute}-relation-button`"
|
||||
@click.prevent="showEditForm"
|
||||
type="button"
|
||||
>
|
||||
<span class="hidden md:inline-block">
|
||||
{{ __('Create :resource', { resource: field.singularLabel }) }}
|
||||
</span>
|
||||
<span class="inline-block md:hidden">
|
||||
{{ __('Create') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</LoadingView>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import each from 'lodash/each'
|
||||
import map from 'lodash/map'
|
||||
import tap from 'lodash/tap'
|
||||
import reject from 'lodash/reject'
|
||||
import {
|
||||
TogglesTrashed,
|
||||
PerformsSearches,
|
||||
FormField,
|
||||
HandlesValidationErrors,
|
||||
mapProps,
|
||||
} from '@/mixins'
|
||||
import InlineFormData from './InlineFormData'
|
||||
|
||||
export default {
|
||||
emits: [
|
||||
'field-changed',
|
||||
'update-last-retrieved-at-timestamp',
|
||||
'file-upload-started',
|
||||
'file-upload-finished',
|
||||
],
|
||||
|
||||
mixins: [HandlesValidationErrors, FormField],
|
||||
|
||||
provide() {
|
||||
return {
|
||||
removeFile: this.removeFile,
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
...mapProps([
|
||||
'resourceName',
|
||||
'resourceId',
|
||||
'viaResource',
|
||||
'viaResourceId',
|
||||
'viaRelationship',
|
||||
]),
|
||||
|
||||
field: {
|
||||
type: Object,
|
||||
},
|
||||
|
||||
formUniqueId: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
errors: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
isEditing: this.field.hasOneId !== null || this.field.required === true,
|
||||
fields: [],
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*/
|
||||
mounted() {
|
||||
this.initializeComponent()
|
||||
},
|
||||
|
||||
methods: {
|
||||
initializeComponent() {
|
||||
this.getFields()
|
||||
|
||||
this.field.fill = this.fill
|
||||
},
|
||||
|
||||
removeFile(attribute) {
|
||||
const { resourceName, resourceId } = this
|
||||
|
||||
Nova.request().delete(
|
||||
`/nova-api/${resourceName}/${resourceId}/field/${attribute}`
|
||||
)
|
||||
},
|
||||
|
||||
fill(formData) {
|
||||
if (this.isEditing && this.isVisible) {
|
||||
tap(new InlineFormData(this.fieldAttribute, formData), form => {
|
||||
each(this.availableFields, field => {
|
||||
field.fill(form)
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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(this.getFieldsEndpoint, {
|
||||
params: {
|
||||
editing: true,
|
||||
editMode: this.editMode,
|
||||
viaResource: this.viaResource,
|
||||
viaResourceId: this.viaResourceId,
|
||||
viaRelationship: this.viaRelationship,
|
||||
relationshipType: this.field.relationshipType,
|
||||
},
|
||||
})
|
||||
.catch(error => {
|
||||
if ([403, 404].includes(error.response.status)) {
|
||||
Nova.error(this.__('There was a problem fetching the resource.'))
|
||||
}
|
||||
})
|
||||
|
||||
this.fields = map(fields, field => {
|
||||
if (
|
||||
field.resourceName === this.field.from.viaResource &&
|
||||
field.relationshipType === 'belongsTo' &&
|
||||
(this.editMode === 'create' ||
|
||||
field.belongsToId.toString() ===
|
||||
this.field.from.viaResourceId.toString())
|
||||
) {
|
||||
field.visible = false
|
||||
field.fill = () => {}
|
||||
} else if (
|
||||
field.relationshipType === 'morphTo' &&
|
||||
(this.editMode === 'create' ||
|
||||
(field.resourceName === this.field.from.viaResource &&
|
||||
field.morphToId.toString() ===
|
||||
this.field.from.viaResourceId.toString()))
|
||||
) {
|
||||
field.visible = false
|
||||
field.fill = () => {}
|
||||
}
|
||||
|
||||
field.validationKey = `${this.fieldAttribute}.${field.validationKey}`
|
||||
|
||||
return field
|
||||
})
|
||||
|
||||
this.loading = false
|
||||
|
||||
Nova.$emit('resource-loaded', {
|
||||
resourceName: this.resourceName,
|
||||
resourceId: this.resourceId ? this.resourceId.toString() : null,
|
||||
mode: this.editMode,
|
||||
})
|
||||
},
|
||||
|
||||
showEditForm() {
|
||||
this.isEditing = true
|
||||
},
|
||||
|
||||
handleFileDeleted() {
|
||||
this.$emit('update-last-retrieved-at-timestamp')
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
availableFields() {
|
||||
return reject(this.fields, field => {
|
||||
return (
|
||||
(['relationship-panel'].includes(field.component) &&
|
||||
['hasOne', 'morphOne'].includes(
|
||||
field.fields[0].relationshipType
|
||||
)) ||
|
||||
field.readonly
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
getFieldsEndpoint() {
|
||||
if (this.editMode === 'update') {
|
||||
return `/nova-api/${this.resourceName}/${this.resourceId}/update-fields`
|
||||
}
|
||||
|
||||
return `/nova-api/${this.resourceName}/creation-fields`
|
||||
},
|
||||
|
||||
editMode() {
|
||||
return this.field.hasOneId === null ? 'create' : 'update'
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
50
nova/resources/js/fields/Form/HeadingField.vue
Normal file
50
nova/resources/js/fields/Form/HeadingField.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<FieldWrapper v-if="currentField.visible">
|
||||
<!-- :class="{ 'rounded-t-lg': index === 0 }"-->
|
||||
<div
|
||||
v-if="shouldDisplayAsHtml"
|
||||
v-html="currentField.value"
|
||||
:class="classes"
|
||||
/>
|
||||
<div v-else :class="classes">
|
||||
<Heading :level="3">{{ currentField.value }}</Heading>
|
||||
</div>
|
||||
</FieldWrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { DependentFormField } from '@/mixins'
|
||||
|
||||
export default {
|
||||
mixins: [DependentFormField],
|
||||
|
||||
props: {
|
||||
index: { type: Number },
|
||||
resourceName: { type: String, require: true },
|
||||
field: { type: Object, require: true },
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Provide a function to fills FormData when field is visible.
|
||||
*/
|
||||
fillIfVisible(formData, attribute, value) {
|
||||
//
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
classes: () => [
|
||||
'remove-last-margin-bottom',
|
||||
'leading-normal',
|
||||
'w-full',
|
||||
'py-4',
|
||||
'px-8',
|
||||
],
|
||||
|
||||
shouldDisplayAsHtml() {
|
||||
return this.currentField.asHtml || false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
13
nova/resources/js/fields/Form/HiddenField.vue
Normal file
13
nova/resources/js/fields/Form/HiddenField.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="hidden" :errors="errors">
|
||||
<input :dusk="field.attribute" type="hidden" :value="value" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
|
||||
|
||||
export default {
|
||||
mixins: [DependentFormField, HandlesValidationErrors],
|
||||
}
|
||||
</script>
|
||||
62
nova/resources/js/fields/Form/InlineFormData.js
Normal file
62
nova/resources/js/fields/Form/InlineFormData.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import isNil from 'lodash/isNil'
|
||||
|
||||
export default class InlineFormData {
|
||||
constructor(attribute, formData) {
|
||||
this.attribute = attribute
|
||||
this.formData = formData
|
||||
this.localFormData = new FormData()
|
||||
}
|
||||
|
||||
append(name, ...args) {
|
||||
this.localFormData.append(name, ...args)
|
||||
this.formData.append(this.name(name), ...args)
|
||||
}
|
||||
|
||||
delete(name) {
|
||||
this.localFormData.delete(name)
|
||||
this.formData.delete(this.name(name))
|
||||
}
|
||||
|
||||
entries() {
|
||||
return this.localFormData.entries()
|
||||
}
|
||||
|
||||
get(name) {
|
||||
return this.localFormData.get(name)
|
||||
}
|
||||
|
||||
getAll(name) {
|
||||
return this.localFormData.getAll(name)
|
||||
}
|
||||
|
||||
has(name) {
|
||||
return this.localFormData.has(name)
|
||||
}
|
||||
|
||||
keys() {
|
||||
return this.localFormData.keys()
|
||||
}
|
||||
|
||||
set(name, ...args) {
|
||||
this.localFormData.set(name, ...args)
|
||||
this.formData.set(this.name(name), ...args)
|
||||
}
|
||||
|
||||
values() {
|
||||
return this.localFormData.values()
|
||||
}
|
||||
|
||||
name(attribute) {
|
||||
let [name, ...nested] = attribute.split('[')
|
||||
|
||||
if (!isNil(nested) && nested.length > 0) {
|
||||
return `${this.attribute}[${name}][${nested.join('[')}`
|
||||
}
|
||||
|
||||
return `${this.attribute}[${attribute}]`
|
||||
}
|
||||
|
||||
slug(attribute) {
|
||||
return `${this.attribute}.${attribute}`
|
||||
}
|
||||
}
|
||||
181
nova/resources/js/fields/Form/KeyValueField.vue
Normal file
181
nova/resources/js/fields/Form/KeyValueField.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:full-width-content="
|
||||
fullWidthContent || ['modal', 'action-modal'].includes(mode)
|
||||
"
|
||||
:show-help-text="showHelpText"
|
||||
>
|
||||
<template #field>
|
||||
<FormKeyValueTable
|
||||
:edit-mode="!currentlyIsReadonly"
|
||||
:can-delete-row="currentField.canDeleteRow"
|
||||
>
|
||||
<FormKeyValueHeader
|
||||
:key-label="currentField.keyLabel"
|
||||
:value-label="currentField.valueLabel"
|
||||
/>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden key-value-items">
|
||||
<FormKeyValueItem
|
||||
v-for="(item, index) in theData"
|
||||
:index="index"
|
||||
@remove-row="removeRow"
|
||||
:item.sync="item"
|
||||
:key="item.id"
|
||||
:ref="item.id"
|
||||
:read-only="currentlyIsReadonly"
|
||||
:read-only-keys="currentField.readonlyKeys"
|
||||
:can-delete-row="currentField.canDeleteRow"
|
||||
/>
|
||||
</div>
|
||||
</FormKeyValueTable>
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<Button
|
||||
v-if="
|
||||
!currentlyIsReadonly &&
|
||||
!currentField.readonlyKeys &&
|
||||
currentField.canAddRow
|
||||
"
|
||||
@click="addRowAndSelect"
|
||||
:dusk="`${field.attribute}-add-key-value`"
|
||||
leading-icon="plus-circle"
|
||||
variant="link"
|
||||
>
|
||||
{{ currentField.actionText }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import findIndex from 'lodash/findIndex'
|
||||
import fromPairs from 'lodash/fromPairs'
|
||||
import map from 'lodash/map'
|
||||
import reject from 'lodash/reject'
|
||||
import tap from 'lodash/tap'
|
||||
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
|
||||
import { Button } from 'laravel-nova-ui'
|
||||
|
||||
function guid() {
|
||||
var S4 = function () {
|
||||
return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1)
|
||||
}
|
||||
return (
|
||||
S4() +
|
||||
S4() +
|
||||
'-' +
|
||||
S4() +
|
||||
'-' +
|
||||
S4() +
|
||||
'-' +
|
||||
S4() +
|
||||
'-' +
|
||||
S4() +
|
||||
S4() +
|
||||
S4()
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
mixins: [HandlesValidationErrors, DependentFormField],
|
||||
|
||||
components: {
|
||||
Button,
|
||||
},
|
||||
|
||||
data: () => ({ theData: [] }),
|
||||
|
||||
mounted() {
|
||||
this.populateKeyValueData()
|
||||
},
|
||||
|
||||
methods: {
|
||||
/*
|
||||
* Set the initial value for the field
|
||||
*/
|
||||
populateKeyValueData() {
|
||||
this.theData = map(Object.entries(this.value || {}), ([key, value]) => ({
|
||||
id: guid(),
|
||||
key: `${key}`,
|
||||
value,
|
||||
}))
|
||||
|
||||
if (this.theData.length === 0) {
|
||||
this.addRow()
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Provide a function that fills a passed FormData object with the
|
||||
* field's internal value attribute.
|
||||
*/
|
||||
fill(formData) {
|
||||
this.fillIfVisible(
|
||||
formData,
|
||||
this.fieldAttribute,
|
||||
JSON.stringify(this.finalPayload)
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a row to the table.
|
||||
*/
|
||||
addRow() {
|
||||
return tap(guid(), id => {
|
||||
this.theData = [...this.theData, { id, key: '', value: '' }]
|
||||
return id
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a row to the table and select its first field.
|
||||
*/
|
||||
addRowAndSelect() {
|
||||
return this.selectRow(this.addRow())
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove the row from the table.
|
||||
*/
|
||||
removeRow(id) {
|
||||
return tap(
|
||||
findIndex(this.theData, row => row.id === id),
|
||||
index => this.theData.splice(index, 1)
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Select the first field in a row with the given ref ID.
|
||||
*/
|
||||
selectRow(refId) {
|
||||
return this.$nextTick(() => {
|
||||
this.$refs[refId][0].handleKeyFieldFocus()
|
||||
})
|
||||
},
|
||||
|
||||
onSyncedField() {
|
||||
this.populateKeyValueData()
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Return the final filtered json object
|
||||
*/
|
||||
finalPayload() {
|
||||
return fromPairs(
|
||||
reject(
|
||||
map(this.theData, row =>
|
||||
row && row.key ? [row.key, row.value] : undefined
|
||||
),
|
||||
row => row === undefined
|
||||
)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
30
nova/resources/js/fields/Form/KeyValueHeader.vue
Normal file
30
nova/resources/js/fields/Form/KeyValueHeader.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-gray-100 dark:bg-gray-800 rounded-t-lg flex border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div
|
||||
class="bg-clip w-48 uppercase font-bold text-xxs text-gray-500 tracking-wide px-3 py-2"
|
||||
>
|
||||
{{ keyLabel }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-clip flex-grow uppercase font-bold text-xxs text-gray-500 tracking-wide px-3 py-2 border-l border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{{ valueLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
keyLabel: {
|
||||
type: String,
|
||||
},
|
||||
valueLabel: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
136
nova/resources/js/fields/Form/KeyValueItem.vue
Normal file
136
nova/resources/js/fields/Form/KeyValueItem.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div v-if="isNotObject" class="flex items-center key-value-item">
|
||||
<div
|
||||
class="flex flex-grow border-b border-gray-200 dark:border-gray-700 key-value-fields"
|
||||
>
|
||||
<div
|
||||
class="flex-none w-48 cursor-text"
|
||||
:class="[
|
||||
readOnlyKeys || !isEditable
|
||||
? 'bg-gray-50 dark:bg-gray-800'
|
||||
: 'bg-white dark:bg-gray-900',
|
||||
]"
|
||||
>
|
||||
<textarea
|
||||
rows="1"
|
||||
:dusk="`key-value-key-${index}`"
|
||||
v-model="item.key"
|
||||
@focus="handleKeyFieldFocus"
|
||||
ref="keyField"
|
||||
type="text"
|
||||
class="font-mono text-xs resize-none block w-full px-3 py-3 dark:text-gray-400 bg-clip-border focus:outline-none focus:ring focus:ring-inset"
|
||||
:readonly="!isEditable || readOnlyKeys"
|
||||
:tabindex="!isEditable || readOnlyKeys ? -1 : 0"
|
||||
style="background-clip: border-box"
|
||||
:class="{
|
||||
'bg-white dark:bg-gray-800 focus:outline-none cursor-not-allowed':
|
||||
!isEditable || readOnlyKeys,
|
||||
'hover:bg-20 focus:bg-white dark:bg-gray-900 dark:focus:bg-gray-900':
|
||||
isEditable && !readOnlyKeys,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@click="handleValueFieldFocus"
|
||||
class="flex-grow border-l border-gray-200 dark:border-gray-700"
|
||||
:class="[
|
||||
readOnlyKeys || !isEditable
|
||||
? 'bg-gray-50 dark:bg-gray-700'
|
||||
: 'bg-white dark:bg-gray-900',
|
||||
]"
|
||||
>
|
||||
<textarea
|
||||
rows="1"
|
||||
:dusk="`key-value-value-${index}`"
|
||||
v-model="item.value"
|
||||
@focus="handleValueFieldFocus"
|
||||
ref="valueField"
|
||||
type="text"
|
||||
class="font-mono text-xs block w-full px-3 py-3 dark:text-gray-400"
|
||||
:readonly="!isEditable"
|
||||
:tabindex="!isEditable ? -1 : 0"
|
||||
:class="{
|
||||
'bg-white dark:bg-gray-800 focus:outline-none': !isEditable,
|
||||
'hover:bg-20 focus:bg-white dark:bg-gray-900 dark:focus:bg-gray-900 focus:outline-none focus:ring focus:ring-inset':
|
||||
isEditable,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isEditable && canDeleteRow"
|
||||
class="flex justify-center h-11 w-11 absolute -right-[50px]"
|
||||
>
|
||||
<Button
|
||||
@click="$emit('remove-row', item.id)"
|
||||
:dusk="`remove-key-value-${index}`"
|
||||
variant="link"
|
||||
state="danger"
|
||||
type="button"
|
||||
tabindex="0"
|
||||
:title="__('Delete')"
|
||||
icon="minus-circle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import autosize from 'autosize'
|
||||
import { Button } from 'laravel-nova-ui'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Button,
|
||||
},
|
||||
|
||||
emits: ['remove-row'],
|
||||
|
||||
props: {
|
||||
index: Number,
|
||||
item: Object,
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
readOnlyKeys: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
canDeleteRow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
autosize(this.$refs.keyField)
|
||||
autosize(this.$refs.valueField)
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleKeyFieldFocus() {
|
||||
this.$refs.keyField.select()
|
||||
},
|
||||
|
||||
handleValueFieldFocus() {
|
||||
this.$refs.valueField.select()
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
isNotObject() {
|
||||
return !(this.item.value instanceof Object)
|
||||
},
|
||||
isEditable() {
|
||||
return !this.readOnly && !this.disabled
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
23
nova/resources/js/fields/Form/KeyValueTable.vue
Normal file
23
nova/resources/js/fields/Form/KeyValueTable.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative rounded-lg rounded-b-lg bg-gray-100 dark:bg-gray-800 bg-clip border border-gray-200 dark:border-gray-700"
|
||||
:class="{ 'mr-11': editMode && deleteRowEnabled }"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
deleteRowEnabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
editMode: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
138
nova/resources/js/fields/Form/MarkdownField.vue
Normal file
138
nova/resources/js/fields/Form/MarkdownField.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:full-width-content="fullWidthContent"
|
||||
:show-help-text="showHelpText"
|
||||
>
|
||||
<template #field>
|
||||
<MarkdownEditor
|
||||
ref="theMarkdownEditor"
|
||||
v-show="currentlyIsVisible"
|
||||
:class="{ 'form-control-bordered-error': hasError }"
|
||||
:id="field.attribute"
|
||||
:previewer="previewer"
|
||||
:uploader="uploader"
|
||||
:readonly="currentlyIsReadonly"
|
||||
@file-removed="handleFileRemoved"
|
||||
@file-added="handleFileAdded"
|
||||
@initialize="initialize"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import isNil from 'lodash/isNil'
|
||||
import {
|
||||
DependentFormField,
|
||||
HandlesFieldAttachments,
|
||||
HandlesValidationErrors,
|
||||
mapProps,
|
||||
} from '@/mixins'
|
||||
|
||||
export default {
|
||||
mixins: [
|
||||
HandlesValidationErrors,
|
||||
HandlesFieldAttachments,
|
||||
DependentFormField,
|
||||
],
|
||||
|
||||
props: mapProps(['resourceName', 'resourceId', 'mode']),
|
||||
|
||||
beforeUnmount() {
|
||||
Nova.$off(this.fieldAttributeValueEventName, this.listenToValueChanges)
|
||||
|
||||
this.clearAttachments()
|
||||
this.clearFilesMarkedForRemoval()
|
||||
},
|
||||
|
||||
methods: {
|
||||
initialize() {
|
||||
this.$refs.theMarkdownEditor.setValue(
|
||||
this.value ?? this.currentField.value
|
||||
)
|
||||
|
||||
Nova.$on(this.fieldAttributeValueEventName, this.listenToValueChanges)
|
||||
},
|
||||
|
||||
fill(formData) {
|
||||
this.fillIfVisible(formData, this.fieldAttribute, this.value || '')
|
||||
|
||||
this.fillAttachmentDraftId(formData)
|
||||
},
|
||||
|
||||
handleFileRemoved(url) {
|
||||
this.flagFileForRemoval(url)
|
||||
},
|
||||
|
||||
handleFileAdded(url) {
|
||||
this.unflagFileForRemoval(url)
|
||||
},
|
||||
|
||||
handleChange(value) {
|
||||
this.value = value
|
||||
|
||||
if (this.field) {
|
||||
this.emitFieldValueChange(this.fieldAttribute, this.value)
|
||||
}
|
||||
},
|
||||
|
||||
onSyncedField() {
|
||||
if (this.currentlyIsVisible && this.$refs.theMarkdownEditor) {
|
||||
this.$refs.theMarkdownEditor.setValue(
|
||||
this.currentField.value ?? this.value
|
||||
)
|
||||
this.$refs.theMarkdownEditor.setOption(
|
||||
'readOnly',
|
||||
this.currentlyIsReadonly
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
listenToValueChanges(value) {
|
||||
if (this.currentlyIsVisible) {
|
||||
this.$refs.theMarkdownEditor.setValue(value)
|
||||
}
|
||||
|
||||
this.handleChange(value)
|
||||
},
|
||||
|
||||
async fetchPreviewContent(value) {
|
||||
Nova.$progress.start()
|
||||
|
||||
const {
|
||||
data: { preview },
|
||||
} = await Nova.request().post(
|
||||
`/nova-api/${this.resourceName}/field/${this.fieldAttribute}/preview`,
|
||||
{ value },
|
||||
{
|
||||
params: {
|
||||
editing: true,
|
||||
editMode: isNil(this.resourceId) ? 'create' : 'update',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
Nova.$progress.done()
|
||||
|
||||
return preview
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
previewer() {
|
||||
if (!this.isActionRequest) {
|
||||
return this.fetchPreviewContent
|
||||
}
|
||||
},
|
||||
|
||||
uploader() {
|
||||
if (!this.isActionRequest && this.field.withFiles) {
|
||||
return this.uploadAttachment
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
610
nova/resources/js/fields/Form/MorphToField.vue
Normal file
610
nova/resources/js/fields/Form/MorphToField.vue
Normal file
@@ -0,0 +1,610 @@
|
||||
<template>
|
||||
<div class="border-b border-gray-100 dark:border-gray-700">
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:show-errors="false"
|
||||
:field-name="fieldName"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<div v-if="hasMorphToTypes" class="flex relative">
|
||||
<select
|
||||
:disabled="
|
||||
(viaRelatedResource && !shouldIgnoresViaRelatedResource) ||
|
||||
currentlyIsReadonly
|
||||
"
|
||||
:dusk="`${field.attribute}-type`"
|
||||
:value="resourceType"
|
||||
@change="refreshResourcesForTypeChange"
|
||||
class="block w-full form-control form-input form-control-bordered form-input mb-3"
|
||||
>
|
||||
<option value="" selected :disabled="!currentField.nullable">
|
||||
{{ __('Choose Type') }}
|
||||
</option>
|
||||
|
||||
<option
|
||||
v-for="option in currentField.morphToTypes"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:selected="resourceType == option.value"
|
||||
>
|
||||
{{ option.singularLabel }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<IconArrow
|
||||
class="pointer-events-none absolute text-gray-700 top-[15px] right-[11px]"
|
||||
/>
|
||||
</div>
|
||||
<label v-else class="flex items-center select-none mt-2">
|
||||
{{ __('There are no available options for this resource.') }}
|
||||
</label>
|
||||
</template>
|
||||
</DefaultField>
|
||||
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="false"
|
||||
:field-name="fieldTypeName"
|
||||
v-if="hasMorphToTypes"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<div class="flex items-center mb-3">
|
||||
<SearchInput
|
||||
v-if="useSearchInput"
|
||||
class="w-full"
|
||||
:dusk="`${field.attribute}-search-input`"
|
||||
:disabled="currentlyIsReadonly"
|
||||
@input="performResourceSearch"
|
||||
@clear="clearResourceSelection"
|
||||
@selected="selectResourceFromSearchInput"
|
||||
:debounce="currentField.debounce"
|
||||
:value="selectedResource"
|
||||
:data="filteredResources"
|
||||
:clearable="
|
||||
currentField.nullable ||
|
||||
editingExistingResource ||
|
||||
viaRelatedResource ||
|
||||
createdViaRelationModal
|
||||
"
|
||||
trackBy="value"
|
||||
:mode="mode"
|
||||
>
|
||||
<div v-if="selectedResource" class="flex items-center">
|
||||
<div v-if="selectedResource.avatar" class="mr-3">
|
||||
<img
|
||||
:src="selectedResource.avatar"
|
||||
class="w-8 h-8 rounded-full block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{ selectedResource.display }}
|
||||
</div>
|
||||
|
||||
<template #option="{ selected, option }">
|
||||
<div class="flex items-center">
|
||||
<div v-if="option.avatar" class="flex-none mr-3">
|
||||
<img
|
||||
:src="option.avatar"
|
||||
class="w-8 h-8 rounded-full block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-auto">
|
||||
<div
|
||||
class="text-sm font-semibold leading-5"
|
||||
:class="{ 'text-white': selected }"
|
||||
>
|
||||
{{ option.display }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="currentField.withSubtitles"
|
||||
class="mt-1 text-xs font-semibold leading-5 text-gray-500"
|
||||
:class="{ 'text-white': selected }"
|
||||
>
|
||||
<span v-if="option.subtitle">{{ option.subtitle }}</span>
|
||||
<span v-else>{{ __('No additional information...') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SearchInput>
|
||||
|
||||
<SelectControl
|
||||
v-else
|
||||
class="w-full"
|
||||
:class="{ 'form-control-bordered-error': hasError }"
|
||||
:dusk="`${field.attribute}-select`"
|
||||
@change="selectResourceFromSelectControl"
|
||||
:disabled="!resourceType || currentlyIsReadonly"
|
||||
:options="availableResources"
|
||||
v-model:selected="selectedResourceId"
|
||||
label="display"
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
:disabled="!currentField.nullable"
|
||||
:selected="selectedResourceId === ''"
|
||||
>
|
||||
{{ __('Choose') }} {{ fieldTypeName }}
|
||||
</option>
|
||||
</SelectControl>
|
||||
|
||||
<CreateRelationButton
|
||||
v-if="canShowNewRelationModal"
|
||||
@click="openRelationModal"
|
||||
class="ml-2"
|
||||
:dusk="`${field.attribute}-inline-create`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CreateRelationModal
|
||||
v-if="canShowNewRelationModal"
|
||||
:show="relationModalOpen"
|
||||
:size="field.modalSize"
|
||||
@set-resource="handleSetResource"
|
||||
@create-cancelled="closeRelationModal"
|
||||
:resource-name="resourceType"
|
||||
:via-relationship="viaRelationship"
|
||||
:via-resource="viaResource"
|
||||
:via-resource-id="viaResourceId"
|
||||
/>
|
||||
|
||||
<TrashedCheckbox
|
||||
v-if="shouldShowTrashed"
|
||||
class="mt-3"
|
||||
:resource-name="field.attribute"
|
||||
:checked="withTrashed"
|
||||
@input="toggleWithTrashed"
|
||||
/>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import find from 'lodash/find'
|
||||
import isNil from 'lodash/isNil'
|
||||
import storage from '@/storage/MorphToFieldStorage'
|
||||
import {
|
||||
DependentFormField,
|
||||
HandlesValidationErrors,
|
||||
InteractsWithQueryString,
|
||||
PerformsSearches,
|
||||
TogglesTrashed,
|
||||
} from '@/mixins'
|
||||
import filled from '@/util/filled'
|
||||
|
||||
export default {
|
||||
mixins: [
|
||||
DependentFormField,
|
||||
HandlesValidationErrors,
|
||||
InteractsWithQueryString,
|
||||
PerformsSearches,
|
||||
TogglesTrashed,
|
||||
],
|
||||
|
||||
data: () => ({
|
||||
resourceType: '',
|
||||
initializingWithExistingResource: false,
|
||||
createdViaRelationModal: false,
|
||||
softDeletes: false,
|
||||
selectedResourceId: null,
|
||||
selectedResource: null,
|
||||
search: '',
|
||||
relationModalOpen: false,
|
||||
withTrashed: false,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*/
|
||||
mounted() {
|
||||
this.initializeComponent()
|
||||
},
|
||||
|
||||
methods: {
|
||||
initializeComponent() {
|
||||
this.selectedResourceId = this.field.value
|
||||
|
||||
if (this.editingExistingResource) {
|
||||
this.initializingWithExistingResource = true
|
||||
this.resourceType = this.field.morphToType
|
||||
this.selectedResourceId = this.field.morphToId
|
||||
} else if (this.viaRelatedResource) {
|
||||
this.initializingWithExistingResource = true
|
||||
this.resourceType = this.viaResource
|
||||
this.selectedResourceId = this.viaResourceId
|
||||
}
|
||||
|
||||
if (this.shouldSelectInitialResource) {
|
||||
if (!this.resourceType && this.field.defaultResource) {
|
||||
this.resourceType = this.field.defaultResource
|
||||
}
|
||||
this.getAvailableResources().then(() => this.selectInitialResource())
|
||||
}
|
||||
|
||||
if (this.resourceType) {
|
||||
this.determineIfSoftDeletes()
|
||||
}
|
||||
|
||||
this.field.fill = this.fill
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the currently selected resource
|
||||
*/
|
||||
selectResourceFromSearchInput(resource) {
|
||||
if (this.field) {
|
||||
this.emitFieldValueChange(
|
||||
`${this.fieldAttribute}_type`,
|
||||
this.resourceType
|
||||
)
|
||||
}
|
||||
|
||||
this.selectResource(resource)
|
||||
},
|
||||
|
||||
/**
|
||||
* Select a resource using the <select> control
|
||||
*/
|
||||
selectResourceFromSelectControl(value) {
|
||||
this.selectedResourceId = value
|
||||
this.selectInitialResource()
|
||||
|
||||
if (this.field) {
|
||||
this.emitFieldValueChange(
|
||||
`${this.fieldAttribute}_type`,
|
||||
this.resourceType
|
||||
)
|
||||
this.emitFieldValueChange(this.fieldAttribute, this.selectedResourceId)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fill the forms formData with details from this field
|
||||
*/
|
||||
fill(formData) {
|
||||
if (this.selectedResource && this.resourceType) {
|
||||
this.fillIfVisible(
|
||||
formData,
|
||||
this.fieldAttribute,
|
||||
this.selectedResource.value
|
||||
)
|
||||
this.fillIfVisible(
|
||||
formData,
|
||||
`${this.fieldAttribute}_type`,
|
||||
this.resourceType
|
||||
)
|
||||
} else {
|
||||
this.fillIfVisible(formData, this.fieldAttribute, '')
|
||||
this.fillIfVisible(formData, `${this.fieldAttribute}_type`, '')
|
||||
}
|
||||
|
||||
this.fillIfVisible(
|
||||
formData,
|
||||
`${this.fieldAttribute}_trashed`,
|
||||
this.withTrashed
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the resources that may be related to this resource.
|
||||
*/
|
||||
getAvailableResources(search = '') {
|
||||
Nova.$progress.start()
|
||||
|
||||
return storage
|
||||
.fetchAvailableResources(this.resourceName, this.fieldAttribute, {
|
||||
params: this.queryParams,
|
||||
})
|
||||
.then(({ data: { resources, softDeletes, withTrashed } }) => {
|
||||
Nova.$progress.done()
|
||||
|
||||
if (this.initializingWithExistingResource || !this.isSearchable) {
|
||||
this.withTrashed = withTrashed
|
||||
}
|
||||
|
||||
if (this.isSearchable) {
|
||||
this.initializingWithExistingResource = false
|
||||
}
|
||||
this.availableResources = resources
|
||||
this.softDeletes = softDeletes
|
||||
})
|
||||
.catch(e => {
|
||||
Nova.$progress.done()
|
||||
})
|
||||
},
|
||||
|
||||
onSyncedField() {
|
||||
if (this.resourceType !== this.currentField.morphToType) {
|
||||
this.refreshResourcesForTypeChange(this.currentField.morphToType)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Select the initial selected resource
|
||||
*/
|
||||
selectInitialResource() {
|
||||
this.selectedResource = find(
|
||||
this.availableResources,
|
||||
r => r.value == this.selectedResourceId
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine if the selected resource type is soft deleting.
|
||||
*/
|
||||
determineIfSoftDeletes() {
|
||||
return storage
|
||||
.determineIfSoftDeletes(this.resourceType)
|
||||
.then(({ data: { softDeletes } }) => (this.softDeletes = softDeletes))
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle the changing of the resource type.
|
||||
*/
|
||||
async refreshResourcesForTypeChange(event) {
|
||||
this.resourceType = event?.target?.value ?? event
|
||||
this.availableResources = []
|
||||
this.selectedResource = ''
|
||||
this.selectedResourceId = ''
|
||||
this.withTrashed = false
|
||||
|
||||
this.softDeletes = false
|
||||
this.determineIfSoftDeletes()
|
||||
|
||||
if (!this.isSearchable && this.resourceType) {
|
||||
this.getAvailableResources().then(() => {
|
||||
this.emitFieldValueChange(
|
||||
`${this.fieldAttribute}_type`,
|
||||
this.resourceType
|
||||
)
|
||||
this.emitFieldValueChange(this.fieldAttribute, null)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle the trashed state of the search
|
||||
*/
|
||||
toggleWithTrashed() {
|
||||
// Reload the data if the component doesn't have selected resource
|
||||
if (!filled(this.selectedResource)) {
|
||||
this.withTrashed = !this.withTrashed
|
||||
|
||||
// Reload the data if the component doesn't support searching
|
||||
if (!this.isSearchable) {
|
||||
this.getAvailableResources()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
openRelationModal() {
|
||||
Nova.$emit('create-relation-modal-opened')
|
||||
this.relationModalOpen = true
|
||||
},
|
||||
|
||||
closeRelationModal() {
|
||||
this.relationModalOpen = false
|
||||
Nova.$emit('create-relation-modal-closed')
|
||||
},
|
||||
|
||||
handleSetResource({ id }) {
|
||||
this.closeRelationModal()
|
||||
this.selectedResourceId = id
|
||||
this.createdViaRelationModal = true
|
||||
this.initializingWithExistingResource = true
|
||||
this.getAvailableResources().then(() => {
|
||||
this.selectInitialResource()
|
||||
|
||||
this.emitFieldValueChange(
|
||||
`${this.fieldAttribute}_type`,
|
||||
this.resourceType
|
||||
)
|
||||
this.emitFieldValueChange(this.fieldAttribute, this.selectedResourceId)
|
||||
})
|
||||
},
|
||||
|
||||
performResourceSearch(search) {
|
||||
if (this.useSearchInput) {
|
||||
this.performSearch(search)
|
||||
} else {
|
||||
this.search = search
|
||||
}
|
||||
},
|
||||
|
||||
clearResourceSelection() {
|
||||
this.clearSelection()
|
||||
|
||||
if (this.viaRelatedResource && !this.createdViaRelationModal) {
|
||||
this.updateQueryString({
|
||||
viaResource: null,
|
||||
viaResourceId: null,
|
||||
viaRelationship: null,
|
||||
relationshipType: null,
|
||||
}).then(() => {
|
||||
Nova.$router.reload({
|
||||
onSuccess: () => {
|
||||
this.initializingWithExistingResource = false
|
||||
this.initializeComponent()
|
||||
},
|
||||
})
|
||||
})
|
||||
} else {
|
||||
if (this.createdViaRelationModal) {
|
||||
this.createdViaRelationModal = false
|
||||
this.initializingWithExistingResource = false
|
||||
}
|
||||
|
||||
this.getAvailableResources()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Determine if an existing resource is being updated.
|
||||
*/
|
||||
editingExistingResource() {
|
||||
return Boolean(this.field.morphToId && this.field.morphToType)
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine if we are creating a new resource via a parent relation
|
||||
*/
|
||||
viaRelatedResource() {
|
||||
return Boolean(
|
||||
find(
|
||||
this.currentField.morphToTypes,
|
||||
type => type.value == this.viaResource
|
||||
) &&
|
||||
this.viaResource &&
|
||||
this.viaResourceId &&
|
||||
this.currentField.reverse
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine if we should select an initial resource when mounting this field
|
||||
*/
|
||||
shouldSelectInitialResource() {
|
||||
return Boolean(
|
||||
this.editingExistingResource ||
|
||||
this.viaRelatedResource ||
|
||||
Boolean(this.field.value && this.field.defaultResource)
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine if the related resources is searchable
|
||||
*/
|
||||
isSearchable() {
|
||||
return Boolean(this.currentField.searchable)
|
||||
},
|
||||
|
||||
shouldLoadFirstResource() {
|
||||
return (
|
||||
((this.useSearchInput &&
|
||||
!this.shouldIgnoreViaRelatedResource &&
|
||||
this.shouldSelectInitialResource) ||
|
||||
this.createdViaRelationModal) &&
|
||||
this.initializingWithExistingResource
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the query params for getting available resources
|
||||
*/
|
||||
queryParams() {
|
||||
return {
|
||||
type: this.resourceType,
|
||||
current: this.selectedResourceId,
|
||||
first: this.shouldLoadFirstResource,
|
||||
search: this.search,
|
||||
withTrashed: this.withTrashed,
|
||||
viaResource: this.viaResource,
|
||||
viaResourceId: this.viaResourceId,
|
||||
viaRelationship: this.viaRelationship,
|
||||
component: this.field.dependentComponentKey,
|
||||
dependsOn: this.encodedDependentFieldValues,
|
||||
editing: true,
|
||||
editMode:
|
||||
isNil(this.resourceId) || this.resourceId === ''
|
||||
? 'create'
|
||||
: 'update',
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the morphable type label for the field
|
||||
*/
|
||||
fieldName() {
|
||||
return this.field.name
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the selected morphable type's label
|
||||
*/
|
||||
fieldTypeName() {
|
||||
if (this.resourceType) {
|
||||
return (
|
||||
find(this.currentField.morphToTypes, type => {
|
||||
return type.value == this.resourceType
|
||||
})?.singularLabel || ''
|
||||
)
|
||||
}
|
||||
|
||||
return ''
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine whether there are any morph to types.
|
||||
*/
|
||||
hasMorphToTypes() {
|
||||
return this.currentField.morphToTypes.length > 0
|
||||
},
|
||||
|
||||
authorizedToCreate() {
|
||||
return find(Nova.config('resources'), resource => {
|
||||
return resource.uriKey == this.resourceType
|
||||
}).authorizedToCreate
|
||||
},
|
||||
|
||||
canShowNewRelationModal() {
|
||||
return (
|
||||
this.currentField.showCreateRelationButton &&
|
||||
this.resourceType &&
|
||||
!this.shownViaNewRelationModal &&
|
||||
!this.viaRelatedResource &&
|
||||
!this.currentlyIsReadonly &&
|
||||
this.authorizedToCreate
|
||||
)
|
||||
},
|
||||
|
||||
shouldShowTrashed() {
|
||||
return (
|
||||
this.softDeletes &&
|
||||
!this.viaRelatedResource &&
|
||||
!this.currentlyIsReadonly &&
|
||||
this.currentField.displaysWithTrashed
|
||||
)
|
||||
},
|
||||
|
||||
currentFieldValues() {
|
||||
return {
|
||||
[this.fieldAttribute]: this.value,
|
||||
[`${this.fieldAttribute}_type`]: this.resourceType,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the field options filtered by the search string.
|
||||
*/
|
||||
filteredResources() {
|
||||
if (!this.isSearchable) {
|
||||
return this.availableResources.filter(option => {
|
||||
return (
|
||||
option.display.toLowerCase().indexOf(this.search.toLowerCase()) >
|
||||
-1 || new String(option.value).indexOf(this.search) > -1
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return this.availableResources
|
||||
},
|
||||
|
||||
shouldIgnoresViaRelatedResource() {
|
||||
return this.viaRelatedResource && filled(this.search)
|
||||
},
|
||||
|
||||
useSearchInput() {
|
||||
return this.isSearchable || this.viaRelatedResource
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
156
nova/resources/js/fields/Form/MultiSelectField.vue
Normal file
156
nova/resources/js/fields/Form/MultiSelectField.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<!-- Select Input Field -->
|
||||
<MultiSelectControl
|
||||
:id="currentField.uniqueKey"
|
||||
:dusk="field.attribute"
|
||||
v-model:selected="value"
|
||||
@change="handleChange"
|
||||
class="w-full"
|
||||
:class="errorClasses"
|
||||
:options="currentField.options"
|
||||
:disabled="currentlyIsReadonly"
|
||||
>
|
||||
<option
|
||||
v-if="shouldShowPlaceholder"
|
||||
value=""
|
||||
:selected="!hasValue"
|
||||
:disabled="!currentField.nullable"
|
||||
>
|
||||
{{ placeholder }}
|
||||
</option>
|
||||
</MultiSelectControl>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import filter from 'lodash/filter'
|
||||
import map from 'lodash/map'
|
||||
import merge from 'lodash/merge'
|
||||
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
|
||||
import filled from '@/util/filled'
|
||||
|
||||
export default {
|
||||
mixins: [HandlesValidationErrors, DependentFormField],
|
||||
|
||||
data: () => ({
|
||||
search: '',
|
||||
}),
|
||||
|
||||
methods: {
|
||||
/*
|
||||
* Set the initial value for the field
|
||||
*/
|
||||
setInitialValue() {
|
||||
let values = !(
|
||||
this.currentField.value === undefined ||
|
||||
this.currentField.value === null ||
|
||||
this.currentField.value === ''
|
||||
)
|
||||
? merge(this.currentField.value || [], this.value)
|
||||
: this.value
|
||||
|
||||
let selectedOptions = filter(
|
||||
this.currentField.options ?? [],
|
||||
o => values.includes(o.value) || values.includes(o.value.toString())
|
||||
)
|
||||
|
||||
this.value = map(selectedOptions, o => o.value)
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the field default value.
|
||||
*/
|
||||
fieldDefaultValue() {
|
||||
return []
|
||||
},
|
||||
|
||||
/**
|
||||
* Provide a function that fills a passed FormData object with the
|
||||
* field's internal value attribute. Here we are forcing there to be a
|
||||
* value sent to the server instead of the default behavior of
|
||||
* `this.value || ''` to avoid loose-comparison issues if the keys
|
||||
* are truthy or falsey
|
||||
*/
|
||||
fill(formData) {
|
||||
this.fillIfVisible(
|
||||
formData,
|
||||
this.fieldAttribute,
|
||||
JSON.stringify(this.value)
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the search string to be used to filter the select field.
|
||||
*/
|
||||
performSearch(event) {
|
||||
this.search = event
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle the selection change event.
|
||||
*/
|
||||
handleChange(values) {
|
||||
let selectedOptions = filter(
|
||||
this.currentField.options ?? [],
|
||||
o => values.includes(o.value) || values.includes(o.value.toString())
|
||||
)
|
||||
|
||||
this.value = map(selectedOptions, o => o.value)
|
||||
|
||||
if (this.field) {
|
||||
this.emitFieldValueChange(this.fieldAttribute, this.value)
|
||||
}
|
||||
},
|
||||
|
||||
onSyncedField() {
|
||||
this.setInitialValue()
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Return the field options filtered by the search string.
|
||||
*/
|
||||
filteredOptions() {
|
||||
let options = this.currentField.options || []
|
||||
|
||||
return options.filter(option => {
|
||||
return (
|
||||
option.label
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.indexOf(this.search.toLowerCase()) > -1
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the placeholder text for the field.
|
||||
*/
|
||||
placeholder() {
|
||||
return this.currentField.placeholder || this.__('Choose an option')
|
||||
},
|
||||
|
||||
/**
|
||||
* Return value has been setted.
|
||||
*/
|
||||
hasValue() {
|
||||
return Boolean(
|
||||
!(this.value === undefined || this.value === null || this.value === '')
|
||||
)
|
||||
},
|
||||
|
||||
shouldShowPlaceholder() {
|
||||
return filled(this.currentField.placeholder) || this.currentField.nullable
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
88
nova/resources/js/fields/Form/Panel.vue
Normal file
88
nova/resources/js/fields/Form/Panel.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div v-if="panel.fields.length > 0" v-show="visibleFieldsCount > 0">
|
||||
<Heading
|
||||
:level="1"
|
||||
:class="panel.helpText ? 'mb-2' : 'mb-3'"
|
||||
:dusk="`${dusk}-heading`"
|
||||
>
|
||||
{{ panel.name }}
|
||||
</Heading>
|
||||
|
||||
<p
|
||||
v-if="panel.helpText"
|
||||
class="text-gray-500 text-sm font-semibold italic mb-3"
|
||||
v-html="panel.helpText"
|
||||
/>
|
||||
|
||||
<Card class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<component
|
||||
v-for="(field, index) in panel.fields"
|
||||
:index="index"
|
||||
:key="index"
|
||||
:is="`form-${field.component}`"
|
||||
:errors="validationErrors"
|
||||
:resource-id="resourceId"
|
||||
:resource-name="resourceName"
|
||||
:related-resource-name="relatedResourceName"
|
||||
:related-resource-id="relatedResourceId"
|
||||
:field="field"
|
||||
:via-resource="viaResource"
|
||||
:via-resource-id="viaResourceId"
|
||||
:via-relationship="viaRelationship"
|
||||
:shown-via-new-relation-modal="shownViaNewRelationModal"
|
||||
:form-unique-id="formUniqueId"
|
||||
:mode="mode"
|
||||
@field-shown="handleFieldShown"
|
||||
@field-hidden="handleFieldHidden"
|
||||
@field-changed="$emit('field-changed')"
|
||||
@file-deleted="handleFileDeleted"
|
||||
@file-upload-started="$emit('file-upload-started')"
|
||||
@file-upload-finished="$emit('file-upload-finished')"
|
||||
:show-help-text="showHelpText"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { HandlesPanelVisibility, mapProps } from '@/mixins'
|
||||
|
||||
export default {
|
||||
name: 'FormPanel',
|
||||
|
||||
mixins: [HandlesPanelVisibility],
|
||||
|
||||
emits: [
|
||||
'field-changed',
|
||||
'update-last-retrieved-at-timestamp',
|
||||
'file-deleted',
|
||||
'file-upload-started',
|
||||
'file-upload-finished',
|
||||
],
|
||||
|
||||
props: {
|
||||
...mapProps(['mode']),
|
||||
shownViaNewRelationModal: { type: Boolean, default: false },
|
||||
showHelpText: { type: Boolean, default: false },
|
||||
panel: { type: Object, required: true },
|
||||
name: { default: 'Panel' },
|
||||
dusk: { type: String },
|
||||
fields: { type: Array, default: [] },
|
||||
formUniqueId: { type: String },
|
||||
validationErrors: { type: Object, required: true },
|
||||
resourceName: { type: String, required: true },
|
||||
resourceId: { type: [Number, String] },
|
||||
relatedResourceName: { type: String },
|
||||
relatedResourceId: { type: [Number, String] },
|
||||
viaResource: { type: String },
|
||||
viaResourceId: { type: [Number, String] },
|
||||
viaRelationship: { type: String },
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleFileDeleted() {
|
||||
this.$emit('update-last-retrieved-at-timestamp')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
30
nova/resources/js/fields/Form/PasswordField.vue
Normal file
30
nova/resources/js/fields/Form/PasswordField.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<input
|
||||
:id="currentField.uniqueKey"
|
||||
:dusk="field.attribute"
|
||||
type="password"
|
||||
v-model="value"
|
||||
class="w-full form-control form-input form-control-bordered"
|
||||
:class="errorClasses"
|
||||
:placeholder="placeholder"
|
||||
autocomplete="new-password"
|
||||
:disabled="currentlyIsReadonly"
|
||||
/>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
|
||||
|
||||
export default {
|
||||
mixins: [HandlesValidationErrors, DependentFormField],
|
||||
}
|
||||
</script>
|
||||
396
nova/resources/js/fields/Form/PlaceField.vue
Normal file
396
nova/resources/js/fields/Form/PlaceField.vue
Normal file
@@ -0,0 +1,396 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="field"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<input
|
||||
:ref="field.attribute"
|
||||
:id="field.uniqueKey"
|
||||
:dusk="field.attribute"
|
||||
type="text"
|
||||
v-model="value"
|
||||
class="w-full form-control form-input form-control-bordered"
|
||||
:class="errorClasses"
|
||||
:placeholder="field.name"
|
||||
:disabled="isReadonly"
|
||||
/>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import find from 'lodash/find'
|
||||
import { FormField, HandlesValidationErrors } from '@/mixins'
|
||||
|
||||
export default {
|
||||
mixins: [HandlesValidationErrors, FormField],
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*/
|
||||
mounted() {
|
||||
this.setInitialValue()
|
||||
|
||||
this.field.fill = this.fill
|
||||
|
||||
this.initializePlaces()
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Initialize Algolia places library.
|
||||
*/
|
||||
initializePlaces() {
|
||||
const places = require('places.js')
|
||||
|
||||
const placeType = this.field.placeType
|
||||
|
||||
const config = {
|
||||
appId: Nova.config('algoliaAppId'),
|
||||
apiKey: Nova.config('algoliaApiKey'),
|
||||
container: this.$refs[this.fieldAttribute],
|
||||
type: this.field.placeType ? this.field.placeType : 'address',
|
||||
templates: {
|
||||
value(suggestion) {
|
||||
return suggestion.name
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if (this.field.countries) {
|
||||
config.countries = this.field.countries
|
||||
}
|
||||
|
||||
if (this.field.language) {
|
||||
config.language = this.field.language
|
||||
}
|
||||
|
||||
const placesAutocomplete = places(config)
|
||||
|
||||
placesAutocomplete.on('change', e => {
|
||||
this.$nextTick(() => {
|
||||
this.value = e.suggestion.name
|
||||
|
||||
this.emitFieldValue(this.field.secondAddressLine, '')
|
||||
this.emitFieldValue(this.field.city, e.suggestion.city)
|
||||
|
||||
this.emitFieldValue(
|
||||
this.field.state,
|
||||
this.parseState(
|
||||
e.suggestion.administrative,
|
||||
e.suggestion.countryCode
|
||||
)
|
||||
)
|
||||
|
||||
this.emitFieldValue(this.field.postalCode, e.suggestion.postcode)
|
||||
this.emitFieldValue(this.field.suburb, e.suggestion.suburb)
|
||||
|
||||
this.emitFieldValue(
|
||||
this.field.country,
|
||||
e.suggestion.countryCode.toUpperCase()
|
||||
)
|
||||
|
||||
this.emitFieldValue(this.field.latitude, e.suggestion.latlng.lat)
|
||||
this.emitFieldValue(this.field.longitude, e.suggestion.latlng.lng)
|
||||
})
|
||||
})
|
||||
|
||||
placesAutocomplete.on('clear', () => {
|
||||
this.$nextTick(() => {
|
||||
this.value = ''
|
||||
|
||||
this.emitFieldValue(this.field.secondAddressLine, '')
|
||||
this.emitFieldValue(this.field.city, '')
|
||||
this.emitFieldValue(this.field.state, '')
|
||||
this.emitFieldValue(this.field.postalCode, '')
|
||||
this.emitFieldValue(this.field.suburb, '')
|
||||
this.emitFieldValue(this.field.country, '')
|
||||
this.emitFieldValue(this.field.latitude, '')
|
||||
this.emitFieldValue(this.field.longitude, '')
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse the selected state into an abbreviation if possible.
|
||||
*/
|
||||
parseState(state, countryCode) {
|
||||
if (countryCode != 'us') {
|
||||
return state
|
||||
}
|
||||
|
||||
return find(this.states, s => {
|
||||
return s.name == state
|
||||
}).abbr
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Get the list of United States.
|
||||
*/
|
||||
states() {
|
||||
return {
|
||||
AL: {
|
||||
count: '0',
|
||||
name: 'Alabama',
|
||||
abbr: 'AL',
|
||||
},
|
||||
AK: {
|
||||
count: '1',
|
||||
name: 'Alaska',
|
||||
abbr: 'AK',
|
||||
},
|
||||
AZ: {
|
||||
count: '2',
|
||||
name: 'Arizona',
|
||||
abbr: 'AZ',
|
||||
},
|
||||
AR: {
|
||||
count: '3',
|
||||
name: 'Arkansas',
|
||||
abbr: 'AR',
|
||||
},
|
||||
CA: {
|
||||
count: '4',
|
||||
name: 'California',
|
||||
abbr: 'CA',
|
||||
},
|
||||
CO: {
|
||||
count: '5',
|
||||
name: 'Colorado',
|
||||
abbr: 'CO',
|
||||
},
|
||||
CT: {
|
||||
count: '6',
|
||||
name: 'Connecticut',
|
||||
abbr: 'CT',
|
||||
},
|
||||
DE: {
|
||||
count: '7',
|
||||
name: 'Delaware',
|
||||
abbr: 'DE',
|
||||
},
|
||||
DC: {
|
||||
count: '8',
|
||||
name: 'District Of Columbia',
|
||||
abbr: 'DC',
|
||||
},
|
||||
FL: {
|
||||
count: '9',
|
||||
name: 'Florida',
|
||||
abbr: 'FL',
|
||||
},
|
||||
GA: {
|
||||
count: '10',
|
||||
name: 'Georgia',
|
||||
abbr: 'GA',
|
||||
},
|
||||
HI: {
|
||||
count: '11',
|
||||
name: 'Hawaii',
|
||||
abbr: 'HI',
|
||||
},
|
||||
ID: {
|
||||
count: '12',
|
||||
name: 'Idaho',
|
||||
abbr: 'ID',
|
||||
},
|
||||
IL: {
|
||||
count: '13',
|
||||
name: 'Illinois',
|
||||
abbr: 'IL',
|
||||
},
|
||||
IN: {
|
||||
count: '14',
|
||||
name: 'Indiana',
|
||||
abbr: 'IN',
|
||||
},
|
||||
IA: {
|
||||
count: '15',
|
||||
name: 'Iowa',
|
||||
abbr: 'IA',
|
||||
},
|
||||
KS: {
|
||||
count: '16',
|
||||
name: 'Kansas',
|
||||
abbr: 'KS',
|
||||
},
|
||||
KY: {
|
||||
count: '17',
|
||||
name: 'Kentucky',
|
||||
abbr: 'KY',
|
||||
},
|
||||
LA: {
|
||||
count: '18',
|
||||
name: 'Louisiana',
|
||||
abbr: 'LA',
|
||||
},
|
||||
ME: {
|
||||
count: '19',
|
||||
name: 'Maine',
|
||||
abbr: 'ME',
|
||||
},
|
||||
MD: {
|
||||
count: '20',
|
||||
name: 'Maryland',
|
||||
abbr: 'MD',
|
||||
},
|
||||
MA: {
|
||||
count: '21',
|
||||
name: 'Massachusetts',
|
||||
abbr: 'MA',
|
||||
},
|
||||
MI: {
|
||||
count: '22',
|
||||
name: 'Michigan',
|
||||
abbr: 'MI',
|
||||
},
|
||||
MN: {
|
||||
count: '23',
|
||||
name: 'Minnesota',
|
||||
abbr: 'MN',
|
||||
},
|
||||
MS: {
|
||||
count: '24',
|
||||
name: 'Mississippi',
|
||||
abbr: 'MS',
|
||||
},
|
||||
MO: {
|
||||
count: '25',
|
||||
name: 'Missouri',
|
||||
abbr: 'MO',
|
||||
},
|
||||
MT: {
|
||||
count: '26',
|
||||
name: 'Montana',
|
||||
abbr: 'MT',
|
||||
},
|
||||
NE: {
|
||||
count: '27',
|
||||
name: 'Nebraska',
|
||||
abbr: 'NE',
|
||||
},
|
||||
NV: {
|
||||
count: '28',
|
||||
name: 'Nevada',
|
||||
abbr: 'NV',
|
||||
},
|
||||
NH: {
|
||||
count: '29',
|
||||
name: 'New Hampshire',
|
||||
abbr: 'NH',
|
||||
},
|
||||
NJ: {
|
||||
count: '30',
|
||||
name: 'New Jersey',
|
||||
abbr: 'NJ',
|
||||
},
|
||||
NM: {
|
||||
count: '31',
|
||||
name: 'New Mexico',
|
||||
abbr: 'NM',
|
||||
},
|
||||
NY: {
|
||||
count: '32',
|
||||
name: 'New York',
|
||||
abbr: 'NY',
|
||||
},
|
||||
NC: {
|
||||
count: '33',
|
||||
name: 'North Carolina',
|
||||
abbr: 'NC',
|
||||
},
|
||||
ND: {
|
||||
count: '34',
|
||||
name: 'North Dakota',
|
||||
abbr: 'ND',
|
||||
},
|
||||
OH: {
|
||||
count: '35',
|
||||
name: 'Ohio',
|
||||
abbr: 'OH',
|
||||
},
|
||||
OK: {
|
||||
count: '36',
|
||||
name: 'Oklahoma',
|
||||
abbr: 'OK',
|
||||
},
|
||||
OR: {
|
||||
count: '37',
|
||||
name: 'Oregon',
|
||||
abbr: 'OR',
|
||||
},
|
||||
PA: {
|
||||
count: '38',
|
||||
name: 'Pennsylvania',
|
||||
abbr: 'PA',
|
||||
},
|
||||
RI: {
|
||||
count: '39',
|
||||
name: 'Rhode Island',
|
||||
abbr: 'RI',
|
||||
},
|
||||
SC: {
|
||||
count: '40',
|
||||
name: 'South Carolina',
|
||||
abbr: 'SC',
|
||||
},
|
||||
SD: {
|
||||
count: '41',
|
||||
name: 'South Dakota',
|
||||
abbr: 'SD',
|
||||
},
|
||||
TN: {
|
||||
count: '42',
|
||||
name: 'Tennessee',
|
||||
abbr: 'TN',
|
||||
},
|
||||
TX: {
|
||||
count: '43',
|
||||
name: 'Texas',
|
||||
abbr: 'TX',
|
||||
},
|
||||
UT: {
|
||||
count: '44',
|
||||
name: 'Utah',
|
||||
abbr: 'UT',
|
||||
},
|
||||
VT: {
|
||||
count: '45',
|
||||
name: 'Vermont',
|
||||
abbr: 'VT',
|
||||
},
|
||||
VA: {
|
||||
count: '46',
|
||||
name: 'Virginia',
|
||||
abbr: 'VA',
|
||||
},
|
||||
WA: {
|
||||
count: '47',
|
||||
name: 'Washington',
|
||||
abbr: 'WA',
|
||||
},
|
||||
WV: {
|
||||
count: '48',
|
||||
name: 'West Virginia',
|
||||
abbr: 'WV',
|
||||
},
|
||||
WI: {
|
||||
count: '49',
|
||||
name: 'Wisconsin',
|
||||
abbr: 'WI',
|
||||
},
|
||||
WY: {
|
||||
count: '50',
|
||||
name: 'Wyoming',
|
||||
abbr: 'WY',
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
95
nova/resources/js/fields/Form/RelationshipPanel.vue
Normal file
95
nova/resources/js/fields/Form/RelationshipPanel.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div v-if="field.authorizedToCreate">
|
||||
<Heading :level="4" :class="panel.helpText ? 'mb-2' : 'mb-3'">{{
|
||||
panel.name
|
||||
}}</Heading>
|
||||
|
||||
<p
|
||||
v-if="panel.helpText"
|
||||
class="text-gray-500 text-sm font-semibold italic mb-3"
|
||||
v-html="panel.helpText"
|
||||
></p>
|
||||
|
||||
<component
|
||||
:is="`form-${field.component}`"
|
||||
:errors="validationErrors"
|
||||
:resource-id="relationId"
|
||||
:resource-name="field.resourceName"
|
||||
:field="field"
|
||||
:via-resource="field.from.viaResource"
|
||||
:via-resource-id="field.from.viaResourceId"
|
||||
:via-relationship="field.from.viaRelationship"
|
||||
:form-unique-id="relationFormUniqueId"
|
||||
:mode="mode"
|
||||
@field-changed="$emit('field-changed')"
|
||||
@file-deleted="handleFileDeleted"
|
||||
@file-upload-started="$emit('file-upload-started')"
|
||||
@file-upload-finished="$emit('file-upload-finished')"
|
||||
:show-help-text="showHelpText"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { uid } from 'uid/single'
|
||||
import { BehavesAsPanel } from '@/mixins'
|
||||
import { mapProps } from '@/mixins'
|
||||
|
||||
export default {
|
||||
name: 'FormRelationshipPanel',
|
||||
|
||||
emits: [
|
||||
'field-changed',
|
||||
'update-last-retrieved-at-timestamp',
|
||||
'file-upload-started',
|
||||
'file-upload-finished',
|
||||
'file-deleted',
|
||||
],
|
||||
|
||||
mixins: [BehavesAsPanel],
|
||||
|
||||
props: {
|
||||
shownViaNewRelationModal: { type: Boolean, default: false },
|
||||
showHelpText: { type: Boolean, default: false },
|
||||
panel: { type: Object, required: true },
|
||||
name: { default: 'Relationship Panel' },
|
||||
...mapProps(['mode']),
|
||||
fields: { type: Array, default: [] },
|
||||
formUniqueId: { type: String },
|
||||
validationErrors: { type: Object, required: true },
|
||||
resourceName: { type: String, required: true },
|
||||
resourceId: { type: [Number, String] },
|
||||
viaResource: { type: String },
|
||||
viaResourceId: { type: [Number, String] },
|
||||
viaRelationship: { type: String },
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
relationFormUniqueId: uid(),
|
||||
}),
|
||||
|
||||
mounted() {
|
||||
if (!this.field.authorizedToCreate) {
|
||||
this.field.fill = () => {}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleFileDeleted() {
|
||||
this.$emit('update-last-retrieved-at-timestamp')
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
field() {
|
||||
return this.panel.fields[0]
|
||||
},
|
||||
|
||||
relationId() {
|
||||
if (['hasOne', 'morphOne'].includes(this.field.relationshipType)) {
|
||||
return this.field.hasOneId
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
194
nova/resources/js/fields/Form/RepeaterField.vue
Normal file
194
nova/resources/js/fields/Form/RepeaterField.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<div v-if="value.length > 0" class="space-y-4" :dusk="fieldAttribute">
|
||||
<RepeaterRow
|
||||
v-for="(item, index) in value"
|
||||
:dusk="`${index}-repeater-row`"
|
||||
:data-repeater-id="valueMap.get(item)"
|
||||
:item="item"
|
||||
:index="index"
|
||||
:key="valueMap.get(item)"
|
||||
@click="removeItem"
|
||||
:errors="errors"
|
||||
:sortable="currentField.sortable && value.length > 1"
|
||||
@move-up="moveUp"
|
||||
@move-down="moveDown"
|
||||
:field="currentField"
|
||||
:via-parent="fieldAttribute"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="text-center"
|
||||
:class="{
|
||||
'bg-gray-50 dark:bg-gray-900 rounded-lg border-4 dark:border-gray-600 border-dashed py-3':
|
||||
value.length === 0,
|
||||
}"
|
||||
>
|
||||
<Dropdown v-if="currentField.repeatables.length > 1">
|
||||
<Button
|
||||
variant="link"
|
||||
leading-icon="plus-circle"
|
||||
trailing-icon="chevron-down"
|
||||
>
|
||||
{{ __('Add item') }}
|
||||
</Button>
|
||||
|
||||
<template #menu>
|
||||
<DropdownMenu class="py-1">
|
||||
<DropdownMenuItem
|
||||
@click="() => addItem(repeatable.type)"
|
||||
as="button"
|
||||
v-for="repeatable in currentField.repeatables"
|
||||
class="space-x-2"
|
||||
>
|
||||
<span><Icon solid :type="repeatable.icon" /></span>
|
||||
<span>{{ repeatable.singularLabel }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
|
||||
<InvertedButton
|
||||
v-else
|
||||
@click="addItem(currentField.repeatables[0].type)"
|
||||
type="button"
|
||||
>
|
||||
<span>{{
|
||||
__('Add :resource', {
|
||||
resource: currentField.repeatables[0].singularLabel,
|
||||
})
|
||||
}}</span>
|
||||
</InvertedButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FormField, HandlesValidationErrors } from '@/mixins'
|
||||
import cloneDeep from 'lodash/cloneDeep'
|
||||
import { uid } from 'uid/single'
|
||||
import { computed } from 'vue'
|
||||
import { Button } from 'laravel-nova-ui'
|
||||
|
||||
export default {
|
||||
mixins: [FormField, HandlesValidationErrors],
|
||||
|
||||
components: { Button },
|
||||
|
||||
provide() {
|
||||
return {
|
||||
removeFile: this.removeFile,
|
||||
shownViaNewRelationModal: computed(() => this.shownViaNewRelationModal),
|
||||
viaResource: computed(() => this.viaResource),
|
||||
viaResourceId: computed(() => this.viaResourceId),
|
||||
viaRelationship: computed(() => this.viaRelationship),
|
||||
resourceName: computed(() => this.resourceName),
|
||||
resourceId: computed(() => this.resourceId),
|
||||
}
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
valueMap: new WeakMap(),
|
||||
}),
|
||||
|
||||
beforeMount() {
|
||||
this.value.map(repeatable => {
|
||||
this.valueMap.set(repeatable, uid())
|
||||
|
||||
return repeatable
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Return the field default value.
|
||||
*/
|
||||
fieldDefaultValue() {
|
||||
return []
|
||||
},
|
||||
|
||||
removeFile(attribute) {
|
||||
const {
|
||||
resourceName,
|
||||
resourceId,
|
||||
relatedResourceName,
|
||||
relatedResourceId,
|
||||
viaRelationship,
|
||||
} = this
|
||||
|
||||
const uri =
|
||||
viaRelationship && relatedResourceName && relatedResourceId
|
||||
? `/nova-api/${resourceName}/${resourceId}/${relatedResourceName}/${relatedResourceId}/field/${attribute}?viaRelationship=${viaRelationship}`
|
||||
: `/nova-api/${resourceName}/${resourceId}/field/${attribute}`
|
||||
|
||||
Nova.request().delete(uri)
|
||||
},
|
||||
|
||||
fill(formData) {
|
||||
this.finalPayload.forEach((repeatable, i) => {
|
||||
const attribute = `${this.fieldAttribute}[${i}]`
|
||||
formData.append(`${attribute}[type]`, repeatable.type)
|
||||
Object.keys(repeatable.fields).forEach(key => {
|
||||
formData.append(
|
||||
`${attribute}[fields][${key}]`,
|
||||
repeatable.fields[key]
|
||||
)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
addItem(repeatableType) {
|
||||
const repeatable = this.currentField.repeatables.find(
|
||||
t => t.type === repeatableType
|
||||
)
|
||||
const copy = cloneDeep(repeatable)
|
||||
|
||||
this.valueMap.set(copy, uid())
|
||||
|
||||
this.value.push(copy)
|
||||
},
|
||||
|
||||
removeItem(index) {
|
||||
const item = this.value.splice(index, 1)
|
||||
|
||||
this.valueMap.delete(item)
|
||||
},
|
||||
|
||||
moveUp(index) {
|
||||
const item = this.value.splice(index, 1)
|
||||
this.value.splice(Math.max(0, index - 1), 0, item[0])
|
||||
},
|
||||
|
||||
moveDown(index) {
|
||||
const item = this.value.splice(index, 1)
|
||||
this.value.splice(Math.min(this.value.length, index + 1), 0, item[0])
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
finalPayload() {
|
||||
return this.value.map(repeatable => {
|
||||
const formData = new FormData()
|
||||
const fields = {}
|
||||
|
||||
repeatable.fields.forEach(f => f.fill && f.fill(formData))
|
||||
|
||||
for (const pair of formData.entries()) {
|
||||
fields[pair[0]] = pair[1]
|
||||
}
|
||||
|
||||
return { type: repeatable.type, fields }
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
239
nova/resources/js/fields/Form/SelectField.vue
Normal file
239
nova/resources/js/fields/Form/SelectField.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<!-- Search Input -->
|
||||
<SearchInput
|
||||
v-if="!currentlyIsReadonly && isSearchable"
|
||||
:dusk="`${field.attribute}-search-input`"
|
||||
@input="performSearch"
|
||||
@clear="clearSelection"
|
||||
@selected="selectOption"
|
||||
:has-error="hasError"
|
||||
:value="selectedOption"
|
||||
:data="filteredOptions"
|
||||
:clearable="currentField.nullable"
|
||||
trackBy="value"
|
||||
class="w-full"
|
||||
:mode="mode"
|
||||
:disabled="currentlyIsReadonly"
|
||||
>
|
||||
<!-- The Selected Option Slot -->
|
||||
<div v-if="selectedOption" class="flex items-center">
|
||||
{{ selectedOption.label }}
|
||||
</div>
|
||||
|
||||
<template #option="{ selected, option }">
|
||||
<!-- Options List Slot -->
|
||||
<div
|
||||
class="flex items-center text-sm font-semibold leading-5"
|
||||
:class="{ 'text-white': selected }"
|
||||
>
|
||||
{{ option.label }}
|
||||
</div>
|
||||
</template>
|
||||
</SearchInput>
|
||||
|
||||
<!-- Select Input Field -->
|
||||
<SelectControl
|
||||
v-else
|
||||
:id="field.attribute"
|
||||
:dusk="field.attribute"
|
||||
v-model:selected="value"
|
||||
@change="handleChange"
|
||||
class="w-full"
|
||||
:has-error="hasError"
|
||||
:options="currentField.options"
|
||||
:disabled="currentlyIsReadonly"
|
||||
>
|
||||
<option value="" selected :disabled="!currentField.nullable">
|
||||
{{ placeholder }}
|
||||
</option>
|
||||
</SelectControl>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import find from 'lodash/find'
|
||||
import first from 'lodash/first'
|
||||
import isNil from 'lodash/isNil'
|
||||
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
|
||||
import filled from '@/util/filled'
|
||||
|
||||
export default {
|
||||
mixins: [HandlesValidationErrors, DependentFormField],
|
||||
|
||||
data: () => ({
|
||||
search: '',
|
||||
selectedOption: null,
|
||||
}),
|
||||
|
||||
created() {
|
||||
if (filled(this.field.value)) {
|
||||
let selectedOption = find(
|
||||
this.field.options,
|
||||
v => v.value == this.field.value
|
||||
)
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.selectOption(selectedOption)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Return the field default value.
|
||||
*/
|
||||
fieldDefaultValue() {
|
||||
return null
|
||||
},
|
||||
|
||||
/**
|
||||
* Provide a function that fills a passed FormData object with the
|
||||
* field's internal value attribute. Here we are forcing there to be a
|
||||
* value sent to the server instead of the default behavior of
|
||||
* `this.value || ''` to avoid loose-comparison issues if the keys
|
||||
* are truthy or falsey
|
||||
*/
|
||||
fill(formData) {
|
||||
this.fillIfVisible(formData, this.fieldAttribute, this.value ?? '')
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the search string to be used to filter the select field.
|
||||
*/
|
||||
performSearch(event) {
|
||||
this.search = event
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the current selection for the field.
|
||||
*/
|
||||
clearSelection() {
|
||||
this.selectedOption = null
|
||||
this.value = this.fieldDefaultValue()
|
||||
|
||||
if (this.field) {
|
||||
this.emitFieldValueChange(this.fieldAttribute, this.value)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Select the given option.
|
||||
*/
|
||||
selectOption(option) {
|
||||
if (isNil(option)) {
|
||||
this.clearSelection()
|
||||
return
|
||||
}
|
||||
|
||||
this.selectedOption = option
|
||||
this.value = option.value
|
||||
|
||||
if (this.field) {
|
||||
this.emitFieldValueChange(this.fieldAttribute, this.value)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle the selection change event.
|
||||
*/
|
||||
handleChange(value) {
|
||||
let selectedOption = find(
|
||||
this.currentField.options,
|
||||
v => v.value == value
|
||||
)
|
||||
|
||||
this.selectOption(selectedOption)
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle on synced field.
|
||||
*/
|
||||
onSyncedField() {
|
||||
let currentSelectedOption = null
|
||||
let hasValue = false
|
||||
|
||||
if (this.selectedOption) {
|
||||
hasValue = true
|
||||
currentSelectedOption = find(
|
||||
this.currentField.options,
|
||||
v => v.value === this.selectedOption.value
|
||||
)
|
||||
}
|
||||
|
||||
let selectedOption = find(
|
||||
this.currentField.options,
|
||||
v => v.value == this.currentField.value
|
||||
)
|
||||
|
||||
if (isNil(currentSelectedOption)) {
|
||||
this.clearSelection()
|
||||
|
||||
if (this.currentField.value) {
|
||||
this.selectOption(selectedOption)
|
||||
} else if (hasValue && !this.currentField.nullable) {
|
||||
this.selectOption(first(this.currentField.options))
|
||||
}
|
||||
|
||||
return
|
||||
} else if (
|
||||
currentSelectedOption &&
|
||||
selectedOption &&
|
||||
['create', 'attach'].includes(this.editMode)
|
||||
) {
|
||||
this.selectOption(selectedOption)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.selectOption(currentSelectedOption)
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Determine if the related resources is searchable
|
||||
*/
|
||||
isSearchable() {
|
||||
return this.currentField.searchable
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the field options filtered by the search string.
|
||||
*/
|
||||
filteredOptions() {
|
||||
return this.currentField.options.filter(option => {
|
||||
return (
|
||||
option.label
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.indexOf(this.search.toLowerCase()) > -1
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the placeholder text for the field.
|
||||
*/
|
||||
placeholder() {
|
||||
return this.currentField.placeholder || this.__('Choose an option')
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine if the field has a non-empty value.
|
||||
*/
|
||||
hasValue() {
|
||||
return Boolean(
|
||||
!(this.value === undefined || this.value === null || this.value === '')
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
117
nova/resources/js/fields/Form/SlugField.vue
Normal file
117
nova/resources/js/fields/Form/SlugField.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="field"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
v-bind="extraAttributes"
|
||||
ref="theInput"
|
||||
class="w-full form-control form-input form-control-bordered"
|
||||
:id="field.uniqueKey"
|
||||
:dusk="field.attribute"
|
||||
v-model="value"
|
||||
:disabled="isReadonly"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="rounded inline-flex text-sm ml-3 link-default"
|
||||
v-if="field.showCustomizeButton"
|
||||
type="button"
|
||||
@click="toggleCustomizeClick"
|
||||
>
|
||||
{{ __('Customize') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FormField, HandlesValidationErrors } from '@/mixins'
|
||||
import debounce from 'lodash/debounce'
|
||||
|
||||
export default {
|
||||
mixins: [HandlesValidationErrors, FormField],
|
||||
|
||||
data: () => ({
|
||||
isListeningToChanges: false,
|
||||
debouncedHandleChange: null,
|
||||
}),
|
||||
|
||||
mounted() {
|
||||
if (this.shouldRegisterInitialListener) {
|
||||
this.registerChangeListener()
|
||||
}
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
this.removeChangeListener()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async fetchPreviewContent(value) {
|
||||
const {
|
||||
data: { preview },
|
||||
} = await Nova.request().post(
|
||||
`/nova-api/${this.resourceName}/field/${this.fieldAttribute}/preview`,
|
||||
{ value }
|
||||
)
|
||||
|
||||
return preview
|
||||
},
|
||||
|
||||
registerChangeListener() {
|
||||
Nova.$on(this.eventName, debounce(this.handleChange, 250))
|
||||
|
||||
this.isListeningToChanges = true
|
||||
},
|
||||
|
||||
removeChangeListener() {
|
||||
if (this.isListeningToChanges === true) {
|
||||
Nova.$off(this.eventName)
|
||||
}
|
||||
},
|
||||
|
||||
async handleChange(value) {
|
||||
this.value = await this.fetchPreviewContent(value)
|
||||
},
|
||||
|
||||
toggleCustomizeClick() {
|
||||
if (this.field.readonly) {
|
||||
this.removeChangeListener()
|
||||
this.isListeningToChanges = false
|
||||
this.field.readonly = false
|
||||
this.field.extraAttributes.readonly = false
|
||||
this.field.showCustomizeButton = false
|
||||
this.$refs.theInput.focus()
|
||||
return
|
||||
}
|
||||
|
||||
this.registerChangeListener()
|
||||
this.field.readonly = true
|
||||
this.field.extraAttributes.readonly = true
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
shouldRegisterInitialListener() {
|
||||
return !this.field.updating
|
||||
},
|
||||
|
||||
eventName() {
|
||||
return this.getFieldAttributeChangeEventName(this.field.from)
|
||||
},
|
||||
|
||||
extraAttributes() {
|
||||
return {
|
||||
...this.field.extraAttributes,
|
||||
class: this.errorClasses,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
48
nova/resources/js/fields/Form/StatusField.vue
Normal file
48
nova/resources/js/fields/Form/StatusField.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<input
|
||||
:id="currentField.uniqueKey"
|
||||
:type="inputType"
|
||||
:min="inputMin"
|
||||
:max="inputMax"
|
||||
:step="inputStep"
|
||||
v-model="value"
|
||||
class="w-full form-control form-input form-control-bordered"
|
||||
:class="errorClasses"
|
||||
:placeholder="field.name"
|
||||
/>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
|
||||
|
||||
export default {
|
||||
mixins: [HandlesValidationErrors, DependentFormField],
|
||||
|
||||
computed: {
|
||||
inputType() {
|
||||
return this.currentField.type || 'text'
|
||||
},
|
||||
|
||||
inputStep() {
|
||||
return this.currentField.step
|
||||
},
|
||||
|
||||
inputMin() {
|
||||
return this.currentField.min
|
||||
},
|
||||
|
||||
inputMax() {
|
||||
return this.currentField.max
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
198
nova/resources/js/fields/Form/TagField.vue
Normal file
198
nova/resources/js/fields/Form/TagField.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center">
|
||||
<SearchSearchInput
|
||||
ref="searchable"
|
||||
:dusk="`${field.resourceName}-search-input`"
|
||||
@input="performSearch"
|
||||
:error="hasError"
|
||||
:debounce="field.debounce"
|
||||
:options="tags"
|
||||
@selected="selectResource"
|
||||
trackBy="value"
|
||||
:disabled="currentlyIsReadonly"
|
||||
:loading="loading"
|
||||
class="w-full"
|
||||
>
|
||||
<template #option="{ dusk, selected, option }">
|
||||
<SearchInputResult
|
||||
:option="option"
|
||||
:selected="selected"
|
||||
:with-subtitles="field.withSubtitles"
|
||||
:dusk="dusk"
|
||||
/>
|
||||
</template>
|
||||
</SearchSearchInput>
|
||||
|
||||
<CreateRelationButton
|
||||
v-if="field.showCreateRelationButton"
|
||||
v-tooltip="
|
||||
__('Create :resource', { resource: field.singularLabel })
|
||||
"
|
||||
@click="openRelationModal"
|
||||
:dusk="`${field.attribute}-inline-create`"
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="value.length > 0" :dusk="`${field.attribute}-selected-tags`">
|
||||
<TagList
|
||||
v-if="field.style === 'list'"
|
||||
:tags="value"
|
||||
@tag-removed="i => removeResource(i)"
|
||||
:resource-name="field.resourceName"
|
||||
:editable="!currentlyIsReadonly"
|
||||
:with-preview="field.withPreview"
|
||||
/>
|
||||
|
||||
<TagGroup
|
||||
v-if="field.style === 'group'"
|
||||
:tags="value"
|
||||
@tag-removed="i => removeResource(i)"
|
||||
:resource-name="field.resourceName"
|
||||
:editable="!currentlyIsReadonly"
|
||||
:with-preview="field.withPreview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateRelationModal
|
||||
:resource-name="field.resourceName"
|
||||
:show="field.showCreateRelationButton && relationModalOpen"
|
||||
:size="field.modalSize"
|
||||
@set-resource="handleSetResource"
|
||||
@create-cancelled="relationModalOpen = false"
|
||||
/>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
DependentFormField,
|
||||
PerformsSearches,
|
||||
HandlesValidationErrors,
|
||||
mapProps,
|
||||
} from '@/mixins'
|
||||
import { minimum } from '@/util'
|
||||
import first from 'lodash/first'
|
||||
import storage from '@/storage/ResourceSearchStorage'
|
||||
import TagList from '../../components/Tags/TagList'
|
||||
import SearchInputResult from '../../components/Inputs/SearchInputResult'
|
||||
import PreviewResourceModal from '../../components/Modals/PreviewResourceModal'
|
||||
|
||||
export default {
|
||||
components: { PreviewResourceModal, SearchInputResult, TagList },
|
||||
mixins: [DependentFormField, PerformsSearches, HandlesValidationErrors],
|
||||
|
||||
props: {
|
||||
...mapProps(['resourceId']),
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
relationModalOpen: false,
|
||||
search: '',
|
||||
value: [],
|
||||
tags: [],
|
||||
loading: false,
|
||||
}),
|
||||
|
||||
mounted() {
|
||||
if (this.currentField.preload) {
|
||||
this.getAvailableResources()
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Perform a search to get the relatable resources.
|
||||
*/
|
||||
performSearch(search) {
|
||||
this.search = search
|
||||
|
||||
const trimmedSearch = search.trim()
|
||||
|
||||
// If the field is set to preload and the user clears the search we
|
||||
// should reset the field to default and load all of the search results.
|
||||
this.searchDebouncer(() => {
|
||||
this.getAvailableResources(trimmedSearch)
|
||||
}, 500)
|
||||
},
|
||||
|
||||
fill(formData) {
|
||||
this.fillIfVisible(
|
||||
formData,
|
||||
this.currentField.attribute,
|
||||
this.value.length > 0 ? JSON.stringify(this.value) : ''
|
||||
)
|
||||
},
|
||||
|
||||
async getAvailableResources(search) {
|
||||
this.loading = true
|
||||
|
||||
const queryParams = {
|
||||
search: search,
|
||||
current: null,
|
||||
first: false,
|
||||
// withTrashed: true,
|
||||
}
|
||||
|
||||
const { data } = await minimum(
|
||||
storage.fetchAvailableResources(this.currentField.resourceName, {
|
||||
params: queryParams,
|
||||
}),
|
||||
250
|
||||
)
|
||||
|
||||
this.loading = false
|
||||
this.tags = data.resources
|
||||
},
|
||||
|
||||
selectResource(resource) {
|
||||
const found = this.value.filter(t => t.value === resource.value)
|
||||
|
||||
if (found.length === 0) {
|
||||
this.value.push(resource)
|
||||
}
|
||||
},
|
||||
|
||||
handleSetResource({ id }) {
|
||||
const queryParams = {
|
||||
search: '',
|
||||
current: id,
|
||||
first: true,
|
||||
}
|
||||
|
||||
storage
|
||||
.fetchAvailableResources(this.currentField.resourceName, {
|
||||
params: queryParams,
|
||||
})
|
||||
.then(({ data: { resources } }) => {
|
||||
this.selectResource(first(resources))
|
||||
})
|
||||
.finally(() => {
|
||||
this.closeRelationModal()
|
||||
})
|
||||
},
|
||||
|
||||
removeResource(index) {
|
||||
this.value.splice(index, 1)
|
||||
},
|
||||
|
||||
openRelationModal() {
|
||||
this.relationModalOpen = true
|
||||
},
|
||||
|
||||
closeRelationModal() {
|
||||
this.relationModalOpen = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
77
nova/resources/js/fields/Form/TextField.vue
Normal file
77
nova/resources/js/fields/Form/TextField.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<div class="space-y-1">
|
||||
<input
|
||||
v-bind="extraAttributes"
|
||||
class="w-full form-control form-input form-control-bordered"
|
||||
@input="handleChange"
|
||||
:value="value"
|
||||
:id="currentField.uniqueKey"
|
||||
:dusk="field.attribute"
|
||||
:disabled="currentlyIsReadonly"
|
||||
:maxlength="field.enforceMaxlength ? field.maxlength : -1"
|
||||
/>
|
||||
|
||||
<datalist v-if="suggestions.length > 0" :id="suggestionsId">
|
||||
<option
|
||||
:key="suggestion"
|
||||
v-for="suggestion in suggestions"
|
||||
:value="suggestion"
|
||||
/>
|
||||
</datalist>
|
||||
|
||||
<CharacterCounter
|
||||
v-if="field.maxlength"
|
||||
:count="value.length"
|
||||
:limit="field.maxlength"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
DependentFormField,
|
||||
FieldSuggestions,
|
||||
HandlesValidationErrors,
|
||||
} from '@/mixins'
|
||||
|
||||
export default {
|
||||
mixins: [DependentFormField, FieldSuggestions, HandlesValidationErrors],
|
||||
|
||||
computed: {
|
||||
defaultAttributes() {
|
||||
return {
|
||||
type: this.currentField.type || 'text',
|
||||
placeholder: this.currentField.placeholder || this.field.name,
|
||||
class: this.errorClasses,
|
||||
min: this.currentField.min,
|
||||
max: this.currentField.max,
|
||||
step: this.currentField.step,
|
||||
pattern: this.currentField.pattern,
|
||||
|
||||
...this.suggestionsAttributes,
|
||||
}
|
||||
},
|
||||
|
||||
extraAttributes() {
|
||||
const attrs = this.currentField.extraAttributes
|
||||
|
||||
return {
|
||||
// Leave the default attributes even though we can now specify
|
||||
// whatever attributes we like because the old number field still
|
||||
// uses the old field attributes
|
||||
...this.defaultAttributes,
|
||||
...attrs,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
56
nova/resources/js/fields/Form/TextareaField.vue
Normal file
56
nova/resources/js/fields/Form/TextareaField.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:full-width-content="fullWidthContent"
|
||||
:show-help-text="showHelpText"
|
||||
>
|
||||
<template #field>
|
||||
<div class="space-y-1">
|
||||
<textarea
|
||||
v-bind="extraAttributes"
|
||||
class="block w-full form-control form-input form-control-bordered py-3 h-auto"
|
||||
:id="currentField.uniqueKey"
|
||||
:dusk="field.attribute"
|
||||
:value="value"
|
||||
@input="handleChange"
|
||||
:maxlength="field.enforceMaxlength ? field.maxlength : -1"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
|
||||
<CharacterCounter
|
||||
v-if="field.maxlength"
|
||||
:count="value.length"
|
||||
:limit="field.maxlength"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
|
||||
|
||||
export default {
|
||||
mixins: [HandlesValidationErrors, DependentFormField],
|
||||
|
||||
computed: {
|
||||
defaultAttributes() {
|
||||
return {
|
||||
rows: this.currentField.rows,
|
||||
class: this.errorClasses,
|
||||
placeholder: this.field.name,
|
||||
}
|
||||
},
|
||||
|
||||
extraAttributes() {
|
||||
const attrs = this.currentField.extraAttributes
|
||||
|
||||
return {
|
||||
...this.defaultAttributes,
|
||||
...attrs,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
116
nova/resources/js/fields/Form/TrixField.vue
Normal file
116
nova/resources/js/fields/Form/TrixField.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:full-width-content="fullWidthContent"
|
||||
:key="index"
|
||||
:show-help-text="showHelpText"
|
||||
>
|
||||
<template #field>
|
||||
<div class="rounded-lg" :class="{ disabled: currentlyIsReadonly }">
|
||||
<Trix
|
||||
name="trixman"
|
||||
:value="value"
|
||||
@change="handleChange"
|
||||
@file-added="handleFileAdded"
|
||||
@file-removed="handleFileRemoved"
|
||||
:class="{ 'form-control-bordered-error': hasError }"
|
||||
:with-files="currentField.withFiles"
|
||||
v-bind="currentField.extraAttributes"
|
||||
:disabled="currentlyIsReadonly"
|
||||
class="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
DependentFormField,
|
||||
HandlesFieldAttachments,
|
||||
HandlesValidationErrors,
|
||||
} from '@/mixins'
|
||||
|
||||
export default {
|
||||
emits: ['field-changed'],
|
||||
|
||||
mixins: [
|
||||
HandlesValidationErrors,
|
||||
HandlesFieldAttachments,
|
||||
DependentFormField,
|
||||
],
|
||||
|
||||
data: () => ({ index: 0 }),
|
||||
|
||||
mounted() {
|
||||
Nova.$on(this.fieldAttributeValueEventName, this.listenToValueChanges)
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
Nova.$off(this.fieldAttributeValueEventName, this.listenToValueChanges)
|
||||
|
||||
this.clearAttachments()
|
||||
this.clearFilesMarkedForRemoval()
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Update the field's internal value when it's value changes
|
||||
*/
|
||||
handleChange(value) {
|
||||
this.value = value
|
||||
|
||||
this.$emit('field-changed')
|
||||
},
|
||||
|
||||
fill(formData) {
|
||||
this.fillIfVisible(formData, this.fieldAttribute, this.value || '')
|
||||
|
||||
this.fillAttachmentDraftId(formData)
|
||||
},
|
||||
|
||||
/**
|
||||
* Initiate an attachement upload
|
||||
*/
|
||||
handleFileAdded({ attachment }) {
|
||||
if (attachment.file) {
|
||||
// Trix provides file if it's an upload
|
||||
const onCompleted = (path, url) => {
|
||||
return attachment.setAttributes({
|
||||
url: url,
|
||||
href: url,
|
||||
})
|
||||
}
|
||||
|
||||
const onUploadProgress = progressEvent => {
|
||||
attachment.setUploadProgress(
|
||||
Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||
)
|
||||
}
|
||||
|
||||
this.uploadAttachment(attachment.file, {
|
||||
onCompleted,
|
||||
onUploadProgress,
|
||||
})
|
||||
} else {
|
||||
// fx 'undo' which restores a previous attachment without a file upload
|
||||
this.unflagFileForRemoval(attachment.attachment.attributes.values.url)
|
||||
}
|
||||
},
|
||||
|
||||
handleFileRemoved({ attachment: { attachment } }) {
|
||||
this.flagFileForRemoval(attachment.attributes.values.url)
|
||||
},
|
||||
|
||||
onSyncedField() {
|
||||
this.handleChange(this.currentField.value ?? this.value)
|
||||
this.index++
|
||||
},
|
||||
|
||||
listenToValueChanges(value) {
|
||||
this.index++
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
67
nova/resources/js/fields/Form/UrlField.vue
Normal file
67
nova/resources/js/fields/Form/UrlField.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<DefaultField
|
||||
:field="currentField"
|
||||
:errors="errors"
|
||||
:show-help-text="showHelpText"
|
||||
:full-width-content="fullWidthContent"
|
||||
>
|
||||
<template #field>
|
||||
<input
|
||||
v-bind="extraAttributes"
|
||||
class="w-full form-control form-input form-control-bordered"
|
||||
type="url"
|
||||
@input="handleChange"
|
||||
:value="value"
|
||||
:id="currentField.uniqueKey"
|
||||
:dusk="field.attribute"
|
||||
:disabled="currentlyIsReadonly"
|
||||
:list="`${field.attribute}-list`"
|
||||
/>
|
||||
|
||||
<datalist
|
||||
v-if="currentField.suggestions && currentField.suggestions.length > 0"
|
||||
:id="`${field.attribute}-list`"
|
||||
>
|
||||
<option
|
||||
:key="suggestion"
|
||||
v-for="suggestion in currentField.suggestions"
|
||||
:value="suggestion"
|
||||
/>
|
||||
</datalist>
|
||||
</template>
|
||||
</DefaultField>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
|
||||
|
||||
export default {
|
||||
mixins: [HandlesValidationErrors, DependentFormField],
|
||||
|
||||
computed: {
|
||||
defaultAttributes() {
|
||||
return {
|
||||
type: this.currentField.type || 'text',
|
||||
min: this.currentField.min,
|
||||
max: this.currentField.max,
|
||||
step: this.currentField.step,
|
||||
pattern: this.currentField.pattern,
|
||||
placeholder: this.currentField.placeholder || this.field.name,
|
||||
class: this.errorClasses,
|
||||
}
|
||||
},
|
||||
|
||||
extraAttributes() {
|
||||
const attrs = this.field.extraAttributes
|
||||
|
||||
return {
|
||||
// Leave the default attributes even though we can now specify
|
||||
// whatever attributes we like because the old number field still
|
||||
// uses the old field attributes
|
||||
...this.defaultAttributes,
|
||||
...attrs,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
16
nova/resources/js/fields/Form/VaporAudioField.vue
Normal file
16
nova/resources/js/fields/Form/VaporAudioField.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import FileField from '@/fields/Form/FileField'
|
||||
|
||||
export default {
|
||||
extends: FileField,
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Determining if the field is a Vapor field.
|
||||
*/
|
||||
isVaporField() {
|
||||
return true
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
16
nova/resources/js/fields/Form/VaporFileField.vue
Normal file
16
nova/resources/js/fields/Form/VaporFileField.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import FileField from '@/fields/Form/FileField'
|
||||
|
||||
export default {
|
||||
extends: FileField,
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Determining if the field is a Vapor field.
|
||||
*/
|
||||
isVaporField() {
|
||||
return true
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user