611 lines
17 KiB
Vue
611 lines
17 KiB
Vue
<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>
|