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

View File

@@ -0,0 +1,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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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}`
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>