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