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

View File

@@ -0,0 +1,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">&mdash;</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>

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

View 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>&mdash;</p>
</template>
</PanelItem>
</template>
<script>
export default {
props: ['index', 'resource', 'resourceName', 'resourceId', 'field'],
}
</script>

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

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

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

View 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>&mdash;</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>

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

View File

@@ -0,0 +1,9 @@
<template>
<PanelItem :index="index" :field="field" />
</template>
<script>
export default {
props: ['index', 'resource', 'resourceName', 'resourceId', 'field'],
}
</script>

View 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>&mdash;</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>

View 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>&mdash;</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>

View 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>&mdash;</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>

View 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">&mdash;</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>

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

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

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

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

View 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>&mdash;</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>

View File

@@ -0,0 +1,9 @@
<template>
<div class="hidden" />
</template>
<script>
export default {
props: ['index', 'resource', 'resourceName', 'resourceId', 'field'],
}
</script>

View File

@@ -0,0 +1,9 @@
<template>
<PanelItem :index="index" :field="field" />
</template>
<script>
export default {
props: ['index', 'resource', 'resourceName', 'resourceId', 'field'],
}
</script>

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

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

View 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>&mdash;</p>
</template>
</PanelItem>
</template>
<script>
export default {
props: ['index', 'resourceName', 'resourceId', 'field'],
}
</script>

View 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>&mdash;</p>
</template>
</PanelItem>
</template>
<script>
export default {
props: ['index', 'resource', 'resourceName', 'resourceId', 'field'],
}
</script>

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

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

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

View File

@@ -0,0 +1,15 @@
<template>
<PanelItem :index="index" :field="field">
<template #value>
<p>
&middot;&middot;&middot;&middot;&middot;&middot;&middot;&middot;&middot;
</p>
</template>
</PanelItem>
</template>
<script>
export default {
props: ['index', 'resource', 'resourceName', 'resourceId', 'field'],
}
</script>

View File

@@ -0,0 +1,7 @@
<script>
import TextField from '@/fields/Detail/TextField'
export default {
extends: TextField,
}
</script>

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

View File

@@ -0,0 +1,9 @@
<template>
<PanelItem :index="index" :field="field" />
</template>
<script>
export default {
props: ['index', 'resource', 'resourceName', 'resourceId', 'field'],
}
</script>

View File

@@ -0,0 +1,7 @@
<script>
import TextField from '@/fields/Detail/TextField'
export default {
extends: TextField,
}
</script>

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

View 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>&mdash;</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>

View 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>&mdash;</span>
</template>
</PanelItem>
</template>
<script>
import { FieldValue } from '@/mixins'
export default {
mixins: [FieldValue],
props: ['index', 'resource', 'resourceName', 'resourceId', 'field'],
}
</script>

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

View File

@@ -0,0 +1,9 @@
<template>
<PanelItem :index="index" :field="field" />
</template>
<script>
export default {
props: ['index', 'resource', 'resourceName', 'resourceId', 'field'],
}
</script>

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

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

View 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>&mdash;</p>
</template>
</PanelItem>
</template>
<script>
import { FieldValue } from '@/mixins'
export default {
mixins: [FieldValue],
props: ['index', 'resource', 'resourceName', 'resourceId', 'field'],
}
</script>

View File

@@ -0,0 +1,7 @@
<script>
import AudioField from '@/fields/Detail/AudioField'
export default {
extends: AudioField,
}
</script>

View File

@@ -0,0 +1,7 @@
<script>
import FileField from '@/fields/Detail/FileField'
export default {
extends: FileField,
}
</script>

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

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

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

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

View 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>&mdash;</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>

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

View 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 === ''">&mdash;</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>

View 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 === ''">&mdash;</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>

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

View 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 === ''">&mdash;</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>

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

View File

@@ -0,0 +1,16 @@
<script>
import FileField from '@/fields/Form/FileField'
export default {
extends: FileField,
computed: {
/**
* Determining if the field is a Vapor field.
*/
isVaporField() {
return false
},
},
}
</script>

View File

@@ -0,0 +1,546 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<div class="flex items-center">
<SearchInput
v-if="useSearchInput"
:dusk="`${field.resourceName}-search-input`"
:disabled="currentlyIsReadonly"
@input="performResourceSearch"
@clear="clearResourceSelection"
@selected="selectResource"
:has-error="hasError"
:debounce="currentField.debounce"
:value="selectedResource"
:data="filteredResources"
:clearable="
currentField.nullable ||
editingExistingResource ||
viaRelatedResource ||
createdViaRelationModal
"
trackBy="value"
class="w-full"
:mode="mode"
>
<div v-if="selectedResource" class="flex items-center">
<div v-if="selectedResource.avatar" class="mr-3">
<img
:src="selectedResource.avatar"
class="w-8 h-8 rounded-full block"
/>
</div>
{{ selectedResource.display }}
</div>
<template #option="{ selected, option }">
<SearchInputResult
:option="option"
:selected="selected"
:with-subtitles="currentField.withSubtitles"
/>
</template>
</SearchInput>
<SelectControl
v-else
class="w-full"
:has-error="hasError"
:dusk="`${field.resourceName}-select`"
:disabled="currentlyIsReadonly"
:options="availableResources"
v-model:selected="selectedResourceId"
@change="selectResourceFromSelectControl"
label="display"
>
<option value="" selected :disabled="!currentField.nullable">
{{ placeholder }}
</option>
</SelectControl>
<CreateRelationButton
v-if="canShowNewRelationModal"
v-tooltip="__('Create :resource', { resource: field.singularLabel })"
@click="openRelationModal"
:dusk="`${field.attribute}-inline-create`"
/>
</div>
<CreateRelationModal
:show="canShowNewRelationModal && relationModalOpen"
:size="field.modalSize"
@set-resource="handleSetResource"
@create-cancelled="closeRelationModal"
:resource-name="field.resourceName"
:resource-id="resourceId"
:via-relationship="viaRelationship"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
/>
<TrashedCheckbox
v-if="shouldShowTrashed"
class="mt-3"
:resource-name="field.resourceName"
:checked="withTrashed"
@input="toggleWithTrashed"
/>
</template>
</DefaultField>
</template>
<script>
import find from 'lodash/find'
import isNil from 'lodash/isNil'
import storage from '@/storage/BelongsToFieldStorage'
import {
DependentFormField,
HandlesValidationErrors,
InteractsWithQueryString,
PerformsSearches,
TogglesTrashed,
} from '@/mixins'
import filled from '@/util/filled'
import findIndex from 'lodash/findIndex'
export default {
mixins: [
DependentFormField,
HandlesValidationErrors,
InteractsWithQueryString,
PerformsSearches,
TogglesTrashed,
],
props: {
resourceId: {},
},
data: () => ({
availableResources: [],
initializingWithExistingResource: false,
createdViaRelationModal: false,
selectedResource: null,
selectedResourceId: null,
softDeletes: false,
withTrashed: false,
search: '',
relationModalOpen: false,
}),
/**
* Mount the component.
*/
mounted() {
this.initializeComponent()
},
methods: {
initializeComponent() {
this.withTrashed = false
this.selectedResourceId = this.currentField.value
if (this.editingExistingResource) {
// If a user is editing an existing resource with this relation
// we'll have a belongsToId on the field, and we should prefill
// that resource in this field
this.initializingWithExistingResource = true
this.selectedResourceId = this.currentField.belongsToId
} else if (this.viaRelatedResource) {
// If the user is creating this resource via a related resource's index
// page we'll have a viaResource and viaResourceId in the params and
// should prefill the resource in this field with that information
this.initializingWithExistingResource = true
this.selectedResourceId = this.viaResourceId
}
if (this.shouldSelectInitialResource) {
if (this.useSearchInput) {
// If we should select the initial resource and the field is
// searchable, we won't load all the resources but we will select
// the initial option.
this.getAvailableResources().then(() => this.selectInitialResource())
} else {
// If we should select the initial resource but the field is not
// searchable we should load all of the available resources into the
// field first and select the initial option.
this.initializingWithExistingResource = false
this.getAvailableResources().then(() => this.selectInitialResource())
}
} else if (!this.isSearchable && this.currentlyIsVisible) {
// If we don't need to select an initial resource because the user
// came to create a resource directly and there's no parent resource,
// and the field is searchable we'll just load all of the resources.
this.getAvailableResources()
}
this.determineIfSoftDeletes()
this.field.fill = this.fill
},
/**
* Select a resource using the <select> control
*/
selectResourceFromSelectControl(value) {
this.selectedResourceId = value
this.selectInitialResource()
if (this.field) {
this.emitFieldValueChange(this.fieldAttribute, this.selectedResourceId)
}
},
/**
* Fill the forms formData with details from this field
*/
fill(formData) {
this.fillIfVisible(
formData,
this.fieldAttribute,
this.selectedResource ? this.selectedResource.value : ''
)
this.fillIfVisible(
formData,
`${this.fieldAttribute}_trashed`,
this.withTrashed
)
},
/**
* Get the resources that may be related to this resource.
*/
getAvailableResources() {
Nova.$progress.start()
return storage
.fetchAvailableResources(this.resourceName, this.fieldAttribute, {
params: this.queryParams,
})
.then(({ data: { resources, softDeletes, withTrashed } }) => {
Nova.$progress.done()
if (this.initializingWithExistingResource || !this.isSearchable) {
this.withTrashed = withTrashed
}
if (this.viaRelatedResource) {
let selectedResource = find(resources, r =>
this.isSelectedResourceId(r.value)
)
if (
isNil(selectedResource) &&
!this.shouldIgnoreViaRelatedResource
) {
return Nova.visit('/404')
}
}
// Turn off initializing the existing resource after the first time
if (this.useSearchInput) {
this.initializingWithExistingResource = false
}
this.availableResources = resources
this.softDeletes = softDeletes
})
.catch(e => {
Nova.$progress.done()
})
},
/**
* Determine if the relatd resource is soft deleting.
*/
determineIfSoftDeletes() {
return storage
.determineIfSoftDeletes(this.field.resourceName)
.then(response => {
this.softDeletes = response.data.softDeletes
})
},
/**
* Determine if the given value is numeric.
*/
isNumeric(value) {
return !isNaN(parseFloat(value)) && isFinite(value)
},
/**
* Select the initial selected resource
*/
selectInitialResource() {
this.selectedResource = find(this.availableResources, r =>
this.isSelectedResourceId(r.value)
)
},
/**
* Toggle the trashed state of the search
*/
toggleWithTrashed() {
let currentlySelectedResource
let currentlySelectedResourceId
if (filled(this.selectedResource)) {
currentlySelectedResource = this.selectedResource
currentlySelectedResourceId = this.selectedResource.value
}
this.withTrashed = !this.withTrashed
this.selectedResource = null
this.selectedResourceId = null
if (!this.useSearchInput) {
this.getAvailableResources().then(() => {
let index = findIndex(this.availableResources, r => {
return r.value === currentlySelectedResourceId
})
if (index > -1) {
this.selectedResource = this.availableResources[index]
this.selectedResourceId = currentlySelectedResourceId
} else {
// We didn't find the resource anymore, so let's remove the selection...
this.selectedResource = null
this.selectedResourceId = null
}
})
}
},
openRelationModal() {
Nova.$emit('create-relation-modal-opened')
this.relationModalOpen = true
},
closeRelationModal() {
this.relationModalOpen = false
Nova.$emit('create-relation-modal-closed')
},
handleSetResource({ id }) {
this.closeRelationModal()
this.selectedResourceId = id
this.initializingWithExistingResource = true
this.createdViaRelationModal = true
this.getAvailableResources().then(() => {
this.selectInitialResource()
this.emitFieldValueChange(this.fieldAttribute, this.selectedResourceId)
})
},
performResourceSearch(search) {
if (this.useSearchInput) {
this.performSearch(search)
} else {
this.search = search
}
},
clearResourceSelection() {
const id = this.selectedResourceId
this.clearSelection()
if (this.viaRelatedResource && !this.createdViaRelationModal) {
this.updateQueryString({
viaResource: null,
viaResourceId: null,
viaRelationship: null,
relationshipType: null,
}).then(() => {
Nova.$router.reload({
onSuccess: () => {
this.initializingWithExistingResource = false
this.initializeComponent()
},
})
})
} else {
if (this.createdViaRelationModal) {
this.selectedResourceId = id
this.createdViaRelationModal = false
this.initializingWithExistingResource = true
} else if (this.editingExistingResource) {
this.initializingWithExistingResource = false
}
if (
(!this.isSearchable || this.shouldLoadFirstResource) &&
this.currentlyIsVisible
) {
this.getAvailableResources()
}
}
},
onSyncedField() {
if (this.viaRelatedResource) {
return
}
this.initializeComponent()
if (isNil(this.syncedField.value) && isNil(this.selectedResourceId)) {
this.selectInitialResource()
}
},
emitOnSyncedFieldValueChange() {
if (this.viaRelatedResource) {
return
}
this.emitFieldValueChange(this.fieldAttribute, this.selectedResourceId)
},
syncedFieldValueHasNotChanged() {
return this.isSelectedResourceId(this.currentField.value)
},
isSelectedResourceId(value) {
return (
!isNil(value) &&
value?.toString() === this.selectedResourceId?.toString()
)
},
},
computed: {
/**
* Determine if we are editing and existing resource
*/
editingExistingResource() {
return filled(this.field.belongsToId)
},
/**
* Determine if we are creating a new resource via a parent relation
*/
viaRelatedResource() {
return Boolean(
this.viaResource === this.field.resourceName &&
this.field.reverse &&
this.viaResourceId
)
},
/**
* Determine if we should select an initial resource when mounting this field
*/
shouldSelectInitialResource() {
return Boolean(
this.editingExistingResource ||
this.viaRelatedResource ||
this.currentField.value
)
},
/**
* Determine if the related resources is searchable
*/
isSearchable() {
return Boolean(this.currentField.searchable)
},
/**
* Get the query params for getting available resources
*/
queryParams() {
return {
current: this.selectedResourceId,
first: this.shouldLoadFirstResource,
search: this.search,
withTrashed: this.withTrashed,
resourceId: this.resourceId,
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
viaRelationship: this.viaRelationship,
component: this.field.dependentComponentKey,
dependsOn: this.encodedDependentFieldValues,
editing: true,
editMode:
isNil(this.resourceId) || this.resourceId === ''
? 'create'
: 'update',
}
},
shouldLoadFirstResource() {
return (
(this.initializingWithExistingResource &&
!this.shouldIgnoreViaRelatedResource) ||
Boolean(this.currentlyIsReadonly && this.selectedResourceId)
)
},
shouldShowTrashed() {
return (
this.softDeletes &&
!this.viaRelatedResource &&
!this.currentlyIsReadonly &&
this.currentField.displaysWithTrashed
)
},
authorizedToCreate() {
return find(Nova.config('resources'), resource => {
return resource.uriKey === this.field.resourceName
}).authorizedToCreate
},
canShowNewRelationModal() {
return (
this.currentField.showCreateRelationButton &&
!this.shownViaNewRelationModal &&
!this.viaRelatedResource &&
!this.currentlyIsReadonly &&
this.authorizedToCreate
)
},
/**
* Return the placeholder text for the field.
*/
placeholder() {
return this.currentField.placeholder || this.__('—')
},
/**
* Return the field options filtered by the search string.
*/
filteredResources() {
if (!this.isSearchable) {
return this.availableResources.filter(option => {
return (
option.display.toLowerCase().indexOf(this.search.toLowerCase()) >
-1 || new String(option.value).indexOf(this.search) > -1
)
})
}
return this.availableResources
},
shouldIgnoreViaRelatedResource() {
return this.viaRelatedResource && filled(this.search)
},
useSearchInput() {
return this.isSearchable || this.viaRelatedResource
},
},
}
</script>

View File

@@ -0,0 +1,75 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<Checkbox
:disabled="currentlyIsReadonly"
:dusk="currentField.uniqueKey"
:id="currentField.uniqueKey"
:model-value="checked"
:name="field.name"
@change="toggle"
class="mt-2"
/>
</template>
</DefaultField>
</template>
<script>
import { Checkbox } from 'laravel-nova-ui'
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
export default {
components: {
Checkbox,
},
mixins: [HandlesValidationErrors, DependentFormField],
methods: {
/*
* Set the initial value for the field
*/
setInitialValue() {
this.value = this.currentField.value ?? this.value
},
/**
* Return the field default value.
*/
fieldDefaultValue() {
return false
},
/**
* Provide a function that fills a passed FormData object with the
* field's internal value attribute
*/
fill(formData) {
this.fillIfVisible(formData, this.fieldAttribute, this.trueValue)
},
toggle() {
this.value = !this.value
if (this.field) {
this.emitFieldValueChange(this.fieldAttribute, this.value)
}
},
},
computed: {
checked() {
return Boolean(this.value)
},
trueValue() {
return +this.checked
},
},
}
</script>

View File

@@ -0,0 +1,97 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<div class="space-y-2">
<CheckboxWithLabel
v-for="option in value"
:key="option.name"
:name="option.name"
:checked="option.checked"
@input="toggle($event, option)"
:disabled="currentlyIsReadonly"
>
<span>{{ option.label }}</span>
</CheckboxWithLabel>
</div>
</template>
</DefaultField>
</template>
<script>
import find from 'lodash/find'
import isNil from 'lodash/isNil'
import fromPairs from 'lodash/fromPairs'
import map from 'lodash/map'
import merge from 'lodash/merge'
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
export default {
mixins: [HandlesValidationErrors, DependentFormField],
data: () => ({
value: {},
}),
methods: {
/*
* Set the initial value for the field
*/
setInitialValue() {
let values = merge(this.finalPayload, this.currentField.value || {})
this.value = map(this.currentField.options, o => {
return {
name: o.name,
label: o.label,
checked: values[o.name] || false,
}
})
},
/**
* Provide a function that fills a passed FormData object with the
* field's internal value attribute.
*/
fill(formData) {
this.fillIfVisible(
formData,
this.fieldAttribute,
JSON.stringify(this.finalPayload)
)
},
/**
* Toggle the option's value.
*/
toggle(event, option) {
const firstOption = find(this.value, o => o.name == option.name)
firstOption.checked = event.target.checked
if (this.field) {
this.emitFieldValueChange(
this.fieldAttribute,
JSON.stringify(this.finalPayload)
)
}
},
onSyncedField() {
this.setInitialValue()
},
},
computed: {
/**
* Return the final filtered json object
*/
finalPayload() {
return fromPairs(map(this.value, o => [o.name, o.checked]))
},
},
}
</script>

View File

@@ -0,0 +1,86 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:full-width-content="fullWidthContent"
:show-help-text="showHelpText"
>
<template #field>
<textarea
ref="theTextarea"
:id="currentField.uniqueKey"
class="w-full form-control form-input form-control-bordered py-3 h-auto"
/>
</template>
</DefaultField>
</template>
<script>
import CodeMirror from 'codemirror'
// Modes
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
export default {
mixins: [HandlesValidationErrors, DependentFormField],
codemirror: null,
/**
* Mount the component.
*/
mounted() {
this.setInitialValue()
if (this.isVisible) {
this.handleShowingComponent()
}
},
watch: {
currentlyIsVisible(current, previous) {
if (current === true && previous === false) {
this.$nextTick(() => this.handleShowingComponent())
} else if (current === false && previous === true) {
this.handleHidingComponent()
}
},
},
methods: {
handleShowingComponent() {
const config = {
tabSize: 4,
indentWithTabs: true,
lineWrapping: true,
lineNumbers: true,
theme: 'dracula',
...{ readOnly: this.currentlyIsReadonly },
...this.currentField.options,
}
this.codemirror = CodeMirror.fromTextArea(this.$refs.theTextarea, config)
this.codemirror.getDoc().setValue(this.value ?? this.currentField.value)
this.codemirror.setSize('100%', this.currentField.height)
this.codemirror.getDoc().on('change', (cm, changeObj) => {
this.value = cm.getValue()
if (this.field) {
this.emitFieldValueChange(this.fieldAttribute, this.value)
}
})
},
handleHidingComponent() {
this.codemirror = null
},
onSyncedField() {
if (this.codemirror) {
this.codemirror.getDoc().setValue(this.currentField.value)
}
},
},
}
</script>

View File

@@ -0,0 +1,50 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<input
v-bind="defaultAttributes"
class="bg-white form-control form-input form-control-bordered p-2"
type="color"
@input="handleChange"
:value="value"
:id="currentField.uniqueKey"
:dusk="field.attribute"
:disabled="currentlyIsReadonly"
/>
<datalist v-if="suggestions.length > 0" :id="suggestionsId">
<option
:key="suggestion"
v-for="suggestion in suggestions"
:value="suggestion"
/>
</datalist>
</template>
</DefaultField>
</template>
<script>
import {
DependentFormField,
FieldSuggestions,
HandlesValidationErrors,
} from '@/mixins'
export default {
mixins: [DependentFormField, FieldSuggestions, HandlesValidationErrors],
computed: {
defaultAttributes() {
return {
class: this.errorClasses,
...this.suggestionsAttributes,
}
},
},
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<div class="flex flex-wrap items-stretch w-full relative">
<div class="flex -mr-px">
<span
class="flex items-center leading-normal rounded rounded-r-none border border-r-0 border-gray-300 dark:border-gray-700 px-3 whitespace-nowrap bg-gray-100 dark:bg-gray-800 text-gray-500 text-sm font-bold"
>
{{ currentField.currency }}
</span>
</div>
<input
class="flex-shrink flex-grow flex-auto leading-normal w-px flex-1 rounded-l-none form-control form-input form-control-bordered"
:id="currentField.uniqueKey"
:dusk="field.attribute"
v-bind="extraAttributes"
:disabled="currentlyIsReadonly"
@input="handleChange"
:value="value"
/>
</div>
</template>
</DefaultField>
</template>
<script>
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
export default {
mixins: [HandlesValidationErrors, DependentFormField],
props: ['resourceName', 'resourceId', 'field'],
computed: {
defaultAttributes() {
return {
type: 'number',
min: this.currentField.min,
max: this.currentField.max,
step: this.currentField.step,
pattern: this.currentField.pattern,
placeholder: this.currentField.placeholder || this.field.name,
class: this.errorClasses,
}
},
extraAttributes() {
const attrs = this.currentField.extraAttributes
return {
// Leave the default attributes even though we can now specify
// whatever attributes we like because the old number field still
// uses the old field attributes
...this.defaultAttributes,
...attrs,
}
},
},
}
</script>

View File

@@ -0,0 +1,72 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<div class="flex items-center">
<input
type="date"
class="form-control form-input form-control-bordered"
ref="dateTimePicker"
:id="currentField.uniqueKey"
:dusk="field.attribute"
:name="field.name"
:value="value"
:class="errorClasses"
:disabled="currentlyIsReadonly"
@change="handleChange"
:min="currentField.min"
:max="currentField.max"
:step="currentField.step"
/>
</div>
</template>
</DefaultField>
</template>
<script>
import isNil from 'lodash/isNil'
import { DateTime } from 'luxon'
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
import filled from '@/util/filled'
export default {
mixins: [HandlesValidationErrors, DependentFormField],
methods: {
/*
* Set the initial value for the field
*/
setInitialValue() {
if (!isNil(this.currentField.value)) {
this.value = DateTime.fromISO(
this.currentField.value || this.value
).toISODate()
}
},
/**
* On save, populate our form data
*/
fill(formData) {
if (this.currentlyIsVisible) {
this.fillIfVisible(formData, this.fieldAttribute, this.value)
}
},
/**
* Update the field's internal value
*/
handleChange(event) {
this.value = event?.target?.value ?? event
if (this.field) {
this.emitFieldValueChange(this.fieldAttribute, this.value)
}
},
},
}
</script>

View File

@@ -0,0 +1,114 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<div class="flex items-center">
<input
type="datetime-local"
class="form-control form-input form-control-bordered"
ref="dateTimePicker"
:id="currentField.uniqueKey"
:dusk="field.attribute"
:name="field.name"
:value="formattedDate"
:class="errorClasses"
:disabled="currentlyIsReadonly"
@change="handleChange"
:min="currentField.min"
:max="currentField.max"
:step="currentField.step"
/>
<span class="ml-3">
{{ timezone }}
</span>
</div>
</template>
</DefaultField>
</template>
<script>
import isNil from 'lodash/isNil'
import { DateTime } from 'luxon'
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
import filled from '@/util/filled'
export default {
mixins: [HandlesValidationErrors, DependentFormField],
data: () => ({
formattedDate: '',
}),
methods: {
/*
* Set the initial value for the field
*/
setInitialValue() {
if (!isNil(this.currentField.value)) {
let isoDate = DateTime.fromISO(this.currentField.value || this.value, {
zone: Nova.config('timezone'),
})
this.value = isoDate.toString()
isoDate = isoDate.setZone(this.timezone)
this.formattedDate = [
isoDate.toISODate(),
isoDate.toFormat(this.timeFormat),
].join('T')
}
},
/**
* On save, populate our form data
*/
fill(formData) {
this.fillIfVisible(formData, this.fieldAttribute, this.value || '')
if (this.currentlyIsVisible && filled(this.value)) {
let isoDate = DateTime.fromISO(this.value, { zone: this.timezone })
this.formattedDate = [
isoDate.toISODate(),
isoDate.toFormat(this.timeFormat),
].join('T')
}
},
/**
* Update the field's internal value
*/
handleChange(event) {
let value = event?.target?.value ?? event
if (filled(value)) {
let isoDate = DateTime.fromISO(value, { zone: this.timezone })
this.value = isoDate.setZone(Nova.config('timezone')).toString()
} else {
this.value = this.fieldDefaultValue()
}
if (this.field) {
this.emitFieldValueChange(this.fieldAttribute, this.value)
}
},
},
computed: {
timeFormat() {
return this.currentField.step % 60 === 0 ? 'HH:mm' : 'HH:mm:ss'
},
timezone() {
return Nova.config('userTimezone') || Nova.config('timezone')
},
},
}
</script>

View File

@@ -0,0 +1,43 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<input
v-bind="extraAttributes"
class="w-full form-control form-input form-control-bordered"
@input="handleChange"
:value="value"
:id="currentField.uniqueKey"
:dusk="field.attribute"
:disabled="currentlyIsReadonly"
/>
</template>
</DefaultField>
</template>
<script>
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
export default {
mixins: [HandlesValidationErrors, DependentFormField],
computed: {
extraAttributes() {
return {
// Leave the default attributes even though we can now specify
// whatever attributes we like because the old number field still
// uses the old field attributes
type: this.currentField.type || 'email',
pattern: this.currentField.pattern,
placeholder: this.currentField.placeholder || this.field.name,
class: this.errorClasses,
...this.currentField.extraAttributes,
}
},
},
}
</script>

View File

@@ -0,0 +1,318 @@
<template>
<DefaultField
:field="currentField"
:label-for="labelFor"
:errors="errors"
:show-help-text="!isReadonly && showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<!-- Existing Image -->
<div class="space-y-4">
<div
v-if="hasValue && previewFile && files.length === 0"
class="grid grid-cols-4 gap-x-6 gap-y-2"
>
<FilePreviewBlock
v-if="previewFile"
:file="previewFile"
:removable="shouldShowRemoveButton"
@removed="confirmRemoval"
:rounded="field.rounded"
:dusk="`${field.attribute}-delete-link`"
/>
</div>
<!-- Upload Removal Modal -->
<ConfirmUploadRemovalModal
:show="removeModalOpen"
@confirm="removeUploadedFile"
@close="closeRemoveModal"
/>
<!-- DropZone -->
<DropZone
v-if="shouldShowField"
:files="files"
@file-changed="handleFileChange"
@file-removed="file = null"
:rounded="field.rounded"
:accepted-types="field.acceptedTypes"
:disabled="file?.processing"
:dusk="`${field.attribute}-delete-link`"
:input-dusk="field.attribute"
/>
</div>
</template>
</DefaultField>
</template>
<script>
import { DependentFormField, Errors, HandlesValidationErrors } from '@/mixins'
import InlineFormData from './InlineFormData'
import Vapor from 'laravel-vapor'
function createFile(file) {
return {
name: file.name,
extension: file.name.split('.').pop(),
type: file.type,
originalFile: file,
vapor: false,
processing: false,
progress: 0,
}
}
export default {
emits: ['file-upload-started', 'file-upload-finished', 'file-deleted'],
mixins: [HandlesValidationErrors, DependentFormField],
inject: ['removeFile'],
expose: ['beforeRemove'],
data: () => ({
previewFile: null,
file: null,
removeModalOpen: false,
missing: false,
deleted: false,
uploadErrors: new Errors(),
vaporFile: {
key: '',
uuid: '',
filename: '',
extension: '',
},
uploadProgress: 0,
startedDrag: false,
uploadModalShown: false,
}),
async mounted() {
this.preparePreviewImage()
this.field.fill = formData => {
let attribute = this.fieldAttribute
if (this.file && !this.isVaporField) {
formData.append(attribute, this.file.originalFile, this.file.name)
}
if (this.file && this.isVaporField) {
formData.append(attribute, this.file.name)
this.fillVaporFilePayload(formData, attribute)
}
}
},
methods: {
preparePreviewImage() {
if (this.hasValue && this.imageUrl) {
this.fetchPreviewImage()
}
if (this.hasValue && !this.imageUrl) {
this.previewFile = createFile({
name: this.currentField.value,
type: this.currentField.value.split('.').pop(),
})
}
},
async fetchPreviewImage() {
let response = await fetch(this.imageUrl)
let data = await response.blob()
this.previewFile = createFile(
new File([data], this.currentField.value, { type: data.type })
)
},
handleFileChange(newFiles) {
this.file = createFile(newFiles[0])
if (this.isVaporField) {
this.file.vapor = true
this.uploadVaporFiles()
}
},
uploadVaporFiles() {
this.file.processing = true
this.$emit('file-upload-started')
Vapor.store(this.file.originalFile, {
progress: progress => {
this.file.progress = Math.round(progress * 100)
},
})
.then(response => {
this.vaporFile.key = response.key
this.vaporFile.uuid = response.uuid
this.vaporFile.filename = this.file.name
this.vaporFile.extension = this.file.extension
this.file.processing = false
this.file.progress = 100
this.$emit('file-upload-finished')
})
.catch(error => {
if (error.response.status === 403) {
Nova.error(
this.__('Sorry! You are not authorized to perform this action.')
)
}
})
},
confirmRemoval() {
this.removeModalOpen = true
},
closeRemoveModal() {
this.removeModalOpen = false
},
beforeRemove() {
this.removeUploadedFile()
},
async removeUploadedFile() {
// this.uploadErrors = new Errors()
try {
await this.removeFile(this.fieldAttribute)
this.$emit('file-deleted')
this.deleted = true
this.file = null
Nova.success(this.__('The file was deleted!'))
} catch (error) {
if (error.response?.status === 422) {
this.uploadErrors = new Errors(error.response.data.errors)
}
} finally {
this.closeRemoveModal()
}
},
fillVaporFilePayload(formData, attribute) {
const vaporAttribute =
formData instanceof InlineFormData
? formData.slug(attribute)
: attribute
const vaporFormData =
formData instanceof InlineFormData ? formData.formData : formData
vaporFormData.append(
`vaporFile[${vaporAttribute}][key]`,
this.vaporFile.key
)
vaporFormData.append(
`vaporFile[${vaporAttribute}][uuid]`,
this.vaporFile.uuid
)
vaporFormData.append(
`vaporFile[${vaporAttribute}][filename]`,
this.vaporFile.filename
)
vaporFormData.append(
`vaporFile[${vaporAttribute}][extension]`,
this.vaporFile.extension
)
},
},
computed: {
files() {
return this.file ? [this.file] : []
},
/**
* Determine if the field has an upload error.
*/
hasError() {
return this.uploadErrors.has(this.fieldAttribute)
},
/**
* Return the first error for the field.
*/
firstError() {
if (this.hasError) {
return this.uploadErrors.first(this.fieldAttribute)
}
},
/**
* The ID attribute to use for the file field.
*/
idAttr() {
return this.labelFor
},
/**
* The label attribute to use for the file field.
*/
labelFor() {
let name = this.resourceName
if (this.relatedResourceName) {
name += '-' + this.relatedResourceName
}
return `file-${name}-${this.fieldAttribute}`
},
/**
* Determine whether the field has a value.
*/
hasValue() {
return (
Boolean(this.field.value || this.imageUrl) &&
!Boolean(this.deleted) &&
!Boolean(this.missing)
)
},
/**
* Determine whether the field should show the loader component.
*/
shouldShowLoader() {
return !Boolean(this.deleted) && Boolean(this.imageUrl)
},
/**
* Determine whether the file field input should be shown.
*/
shouldShowField() {
return Boolean(!this.currentlyIsReadonly)
},
/**
* Determine whether the field should show the remove button.
*/
shouldShowRemoveButton() {
return Boolean(this.currentField.deletable && !this.currentlyIsReadonly)
},
/**
* Return the preview or thumbnail URL for the field.
*/
imageUrl() {
return this.currentField.previewUrl || this.currentField.thumbnailUrl
},
/**
* Determining if the field is a Vapor field.
*/
isVaporField() {
return this.currentField.component === 'vapor-file-field'
},
},
}
</script>

View File

@@ -0,0 +1,236 @@
<template>
<Card>
<LoadingView :loading="loading">
<template v-if="isEditing">
<component
v-for="(field, index) in availableFields"
:index="index"
:key="index"
:is="`form-${field.component}`"
:errors="errors"
:resource-id="resourceId"
:resource-name="resourceName"
:field="field"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:shown-via-new-relation-modal="false"
:form-unique-id="formUniqueId"
@field-changed="$emit('field-changed')"
@file-deleted="handleFileDeleted"
@file-upload-started="$emit('file-upload-started')"
@file-upload-finished="$emit('file-upload-finished')"
:show-help-text="showHelpText"
/>
</template>
<div v-else class="flex flex-col justify-center items-center px-6 py-8">
<button
class="focus:outline-none focus:ring rounded border-2 border-primary-300 dark:border-gray-500 hover:border-primary-500 active:border-primary-400 dark:hover:border-gray-400 dark:active:border-gray-300 bg-white dark:bg-transparent text-primary-500 dark:text-gray-400 px-3 h-9 inline-flex items-center font-bold shrink-0"
:dusk="`create-${field.attribute}-relation-button`"
@click.prevent="showEditForm"
type="button"
>
<span class="hidden md:inline-block">
{{ __('Create :resource', { resource: field.singularLabel }) }}
</span>
<span class="inline-block md:hidden">
{{ __('Create') }}
</span>
</button>
</div>
</LoadingView>
</Card>
</template>
<script>
import each from 'lodash/each'
import map from 'lodash/map'
import tap from 'lodash/tap'
import reject from 'lodash/reject'
import {
TogglesTrashed,
PerformsSearches,
FormField,
HandlesValidationErrors,
mapProps,
} from '@/mixins'
import InlineFormData from './InlineFormData'
export default {
emits: [
'field-changed',
'update-last-retrieved-at-timestamp',
'file-upload-started',
'file-upload-finished',
],
mixins: [HandlesValidationErrors, FormField],
provide() {
return {
removeFile: this.removeFile,
}
},
props: {
...mapProps([
'resourceName',
'resourceId',
'viaResource',
'viaResourceId',
'viaRelationship',
]),
field: {
type: Object,
},
formUniqueId: {
type: String,
},
errors: {
type: Object,
required: true,
},
},
data() {
return {
loading: false,
isEditing: this.field.hasOneId !== null || this.field.required === true,
fields: [],
}
},
/**
* Mount the component.
*/
mounted() {
this.initializeComponent()
},
methods: {
initializeComponent() {
this.getFields()
this.field.fill = this.fill
},
removeFile(attribute) {
const { resourceName, resourceId } = this
Nova.request().delete(
`/nova-api/${resourceName}/${resourceId}/field/${attribute}`
)
},
fill(formData) {
if (this.isEditing && this.isVisible) {
tap(new InlineFormData(this.fieldAttribute, formData), form => {
each(this.availableFields, field => {
field.fill(form)
})
})
}
},
/**
* Get the available fields for the resource.
*/
async getFields() {
this.loading = true
this.panels = []
this.fields = []
const {
data: { title, panels, fields },
} = await Nova.request()
.get(this.getFieldsEndpoint, {
params: {
editing: true,
editMode: this.editMode,
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
viaRelationship: this.viaRelationship,
relationshipType: this.field.relationshipType,
},
})
.catch(error => {
if ([403, 404].includes(error.response.status)) {
Nova.error(this.__('There was a problem fetching the resource.'))
}
})
this.fields = map(fields, field => {
if (
field.resourceName === this.field.from.viaResource &&
field.relationshipType === 'belongsTo' &&
(this.editMode === 'create' ||
field.belongsToId.toString() ===
this.field.from.viaResourceId.toString())
) {
field.visible = false
field.fill = () => {}
} else if (
field.relationshipType === 'morphTo' &&
(this.editMode === 'create' ||
(field.resourceName === this.field.from.viaResource &&
field.morphToId.toString() ===
this.field.from.viaResourceId.toString()))
) {
field.visible = false
field.fill = () => {}
}
field.validationKey = `${this.fieldAttribute}.${field.validationKey}`
return field
})
this.loading = false
Nova.$emit('resource-loaded', {
resourceName: this.resourceName,
resourceId: this.resourceId ? this.resourceId.toString() : null,
mode: this.editMode,
})
},
showEditForm() {
this.isEditing = true
},
handleFileDeleted() {
this.$emit('update-last-retrieved-at-timestamp')
},
},
computed: {
availableFields() {
return reject(this.fields, field => {
return (
(['relationship-panel'].includes(field.component) &&
['hasOne', 'morphOne'].includes(
field.fields[0].relationshipType
)) ||
field.readonly
)
})
},
getFieldsEndpoint() {
if (this.editMode === 'update') {
return `/nova-api/${this.resourceName}/${this.resourceId}/update-fields`
}
return `/nova-api/${this.resourceName}/creation-fields`
},
editMode() {
return this.field.hasOneId === null ? 'create' : 'update'
},
},
}
</script>

View File

@@ -0,0 +1,50 @@
<template>
<FieldWrapper v-if="currentField.visible">
<!-- :class="{ 'rounded-t-lg': index === 0 }"-->
<div
v-if="shouldDisplayAsHtml"
v-html="currentField.value"
:class="classes"
/>
<div v-else :class="classes">
<Heading :level="3">{{ currentField.value }}</Heading>
</div>
</FieldWrapper>
</template>
<script>
import { DependentFormField } from '@/mixins'
export default {
mixins: [DependentFormField],
props: {
index: { type: Number },
resourceName: { type: String, require: true },
field: { type: Object, require: true },
},
methods: {
/**
* Provide a function to fills FormData when field is visible.
*/
fillIfVisible(formData, attribute, value) {
//
},
},
computed: {
classes: () => [
'remove-last-margin-bottom',
'leading-normal',
'w-full',
'py-4',
'px-8',
],
shouldDisplayAsHtml() {
return this.currentField.asHtml || false
},
},
}
</script>

View File

@@ -0,0 +1,13 @@
<template>
<div class="hidden" :errors="errors">
<input :dusk="field.attribute" type="hidden" :value="value" />
</div>
</template>
<script>
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
export default {
mixins: [DependentFormField, HandlesValidationErrors],
}
</script>

View File

@@ -0,0 +1,62 @@
import isNil from 'lodash/isNil'
export default class InlineFormData {
constructor(attribute, formData) {
this.attribute = attribute
this.formData = formData
this.localFormData = new FormData()
}
append(name, ...args) {
this.localFormData.append(name, ...args)
this.formData.append(this.name(name), ...args)
}
delete(name) {
this.localFormData.delete(name)
this.formData.delete(this.name(name))
}
entries() {
return this.localFormData.entries()
}
get(name) {
return this.localFormData.get(name)
}
getAll(name) {
return this.localFormData.getAll(name)
}
has(name) {
return this.localFormData.has(name)
}
keys() {
return this.localFormData.keys()
}
set(name, ...args) {
this.localFormData.set(name, ...args)
this.formData.set(this.name(name), ...args)
}
values() {
return this.localFormData.values()
}
name(attribute) {
let [name, ...nested] = attribute.split('[')
if (!isNil(nested) && nested.length > 0) {
return `${this.attribute}[${name}][${nested.join('[')}`
}
return `${this.attribute}[${attribute}]`
}
slug(attribute) {
return `${this.attribute}.${attribute}`
}
}

View File

@@ -0,0 +1,181 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:full-width-content="
fullWidthContent || ['modal', 'action-modal'].includes(mode)
"
:show-help-text="showHelpText"
>
<template #field>
<FormKeyValueTable
:edit-mode="!currentlyIsReadonly"
:can-delete-row="currentField.canDeleteRow"
>
<FormKeyValueHeader
:key-label="currentField.keyLabel"
:value-label="currentField.valueLabel"
/>
<div class="bg-white dark:bg-gray-800 overflow-hidden key-value-items">
<FormKeyValueItem
v-for="(item, index) in theData"
:index="index"
@remove-row="removeRow"
:item.sync="item"
:key="item.id"
:ref="item.id"
:read-only="currentlyIsReadonly"
:read-only-keys="currentField.readonlyKeys"
:can-delete-row="currentField.canDeleteRow"
/>
</div>
</FormKeyValueTable>
<div class="flex items-center justify-center">
<Button
v-if="
!currentlyIsReadonly &&
!currentField.readonlyKeys &&
currentField.canAddRow
"
@click="addRowAndSelect"
:dusk="`${field.attribute}-add-key-value`"
leading-icon="plus-circle"
variant="link"
>
{{ currentField.actionText }}
</Button>
</div>
</template>
</DefaultField>
</template>
<script>
import findIndex from 'lodash/findIndex'
import fromPairs from 'lodash/fromPairs'
import map from 'lodash/map'
import reject from 'lodash/reject'
import tap from 'lodash/tap'
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
import { Button } from 'laravel-nova-ui'
function guid() {
var S4 = function () {
return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1)
}
return (
S4() +
S4() +
'-' +
S4() +
'-' +
S4() +
'-' +
S4() +
'-' +
S4() +
S4() +
S4()
)
}
export default {
mixins: [HandlesValidationErrors, DependentFormField],
components: {
Button,
},
data: () => ({ theData: [] }),
mounted() {
this.populateKeyValueData()
},
methods: {
/*
* Set the initial value for the field
*/
populateKeyValueData() {
this.theData = map(Object.entries(this.value || {}), ([key, value]) => ({
id: guid(),
key: `${key}`,
value,
}))
if (this.theData.length === 0) {
this.addRow()
}
},
/**
* Provide a function that fills a passed FormData object with the
* field's internal value attribute.
*/
fill(formData) {
this.fillIfVisible(
formData,
this.fieldAttribute,
JSON.stringify(this.finalPayload)
)
},
/**
* Add a row to the table.
*/
addRow() {
return tap(guid(), id => {
this.theData = [...this.theData, { id, key: '', value: '' }]
return id
})
},
/**
* Add a row to the table and select its first field.
*/
addRowAndSelect() {
return this.selectRow(this.addRow())
},
/**
* Remove the row from the table.
*/
removeRow(id) {
return tap(
findIndex(this.theData, row => row.id === id),
index => this.theData.splice(index, 1)
)
},
/**
* Select the first field in a row with the given ref ID.
*/
selectRow(refId) {
return this.$nextTick(() => {
this.$refs[refId][0].handleKeyFieldFocus()
})
},
onSyncedField() {
this.populateKeyValueData()
},
},
computed: {
/**
* Return the final filtered json object
*/
finalPayload() {
return fromPairs(
reject(
map(this.theData, row =>
row && row.key ? [row.key, row.value] : undefined
),
row => row === undefined
)
)
},
},
}
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div
class="bg-gray-100 dark:bg-gray-800 rounded-t-lg flex border-b border-gray-200 dark:border-gray-700"
>
<div
class="bg-clip w-48 uppercase font-bold text-xxs text-gray-500 tracking-wide px-3 py-2"
>
{{ keyLabel }}
</div>
<div
class="bg-clip flex-grow uppercase font-bold text-xxs text-gray-500 tracking-wide px-3 py-2 border-l border-gray-200 dark:border-gray-700"
>
{{ valueLabel }}
</div>
</div>
</template>
<script>
export default {
props: {
keyLabel: {
type: String,
},
valueLabel: {
type: String,
},
},
}
</script>

View File

@@ -0,0 +1,136 @@
<template>
<div v-if="isNotObject" class="flex items-center key-value-item">
<div
class="flex flex-grow border-b border-gray-200 dark:border-gray-700 key-value-fields"
>
<div
class="flex-none w-48 cursor-text"
:class="[
readOnlyKeys || !isEditable
? 'bg-gray-50 dark:bg-gray-800'
: 'bg-white dark:bg-gray-900',
]"
>
<textarea
rows="1"
:dusk="`key-value-key-${index}`"
v-model="item.key"
@focus="handleKeyFieldFocus"
ref="keyField"
type="text"
class="font-mono text-xs resize-none block w-full px-3 py-3 dark:text-gray-400 bg-clip-border focus:outline-none focus:ring focus:ring-inset"
:readonly="!isEditable || readOnlyKeys"
:tabindex="!isEditable || readOnlyKeys ? -1 : 0"
style="background-clip: border-box"
:class="{
'bg-white dark:bg-gray-800 focus:outline-none cursor-not-allowed':
!isEditable || readOnlyKeys,
'hover:bg-20 focus:bg-white dark:bg-gray-900 dark:focus:bg-gray-900':
isEditable && !readOnlyKeys,
}"
/>
</div>
<div
@click="handleValueFieldFocus"
class="flex-grow border-l border-gray-200 dark:border-gray-700"
:class="[
readOnlyKeys || !isEditable
? 'bg-gray-50 dark:bg-gray-700'
: 'bg-white dark:bg-gray-900',
]"
>
<textarea
rows="1"
:dusk="`key-value-value-${index}`"
v-model="item.value"
@focus="handleValueFieldFocus"
ref="valueField"
type="text"
class="font-mono text-xs block w-full px-3 py-3 dark:text-gray-400"
:readonly="!isEditable"
:tabindex="!isEditable ? -1 : 0"
:class="{
'bg-white dark:bg-gray-800 focus:outline-none': !isEditable,
'hover:bg-20 focus:bg-white dark:bg-gray-900 dark:focus:bg-gray-900 focus:outline-none focus:ring focus:ring-inset':
isEditable,
}"
/>
</div>
</div>
<div
v-if="isEditable && canDeleteRow"
class="flex justify-center h-11 w-11 absolute -right-[50px]"
>
<Button
@click="$emit('remove-row', item.id)"
:dusk="`remove-key-value-${index}`"
variant="link"
state="danger"
type="button"
tabindex="0"
:title="__('Delete')"
icon="minus-circle"
/>
</div>
</div>
</template>
<script>
import autosize from 'autosize'
import { Button } from 'laravel-nova-ui'
export default {
components: {
Button,
},
emits: ['remove-row'],
props: {
index: Number,
item: Object,
disabled: {
type: Boolean,
default: false,
},
readOnly: {
type: Boolean,
default: false,
},
readOnlyKeys: {
type: Boolean,
default: false,
},
canDeleteRow: {
type: Boolean,
default: true,
},
},
mounted() {
autosize(this.$refs.keyField)
autosize(this.$refs.valueField)
},
methods: {
handleKeyFieldFocus() {
this.$refs.keyField.select()
},
handleValueFieldFocus() {
this.$refs.valueField.select()
},
},
computed: {
isNotObject() {
return !(this.item.value instanceof Object)
},
isEditable() {
return !this.readOnly && !this.disabled
},
},
}
</script>

View File

@@ -0,0 +1,23 @@
<template>
<div
class="relative rounded-lg rounded-b-lg bg-gray-100 dark:bg-gray-800 bg-clip border border-gray-200 dark:border-gray-700"
:class="{ 'mr-11': editMode && deleteRowEnabled }"
>
<slot />
</div>
</template>
<script>
export default {
props: {
deleteRowEnabled: {
type: Boolean,
default: true,
},
editMode: {
type: Boolean,
default: true,
},
},
}
</script>

View File

@@ -0,0 +1,138 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:full-width-content="fullWidthContent"
:show-help-text="showHelpText"
>
<template #field>
<MarkdownEditor
ref="theMarkdownEditor"
v-show="currentlyIsVisible"
:class="{ 'form-control-bordered-error': hasError }"
:id="field.attribute"
:previewer="previewer"
:uploader="uploader"
:readonly="currentlyIsReadonly"
@file-removed="handleFileRemoved"
@file-added="handleFileAdded"
@initialize="initialize"
@change="handleChange"
/>
</template>
</DefaultField>
</template>
<script>
import isNil from 'lodash/isNil'
import {
DependentFormField,
HandlesFieldAttachments,
HandlesValidationErrors,
mapProps,
} from '@/mixins'
export default {
mixins: [
HandlesValidationErrors,
HandlesFieldAttachments,
DependentFormField,
],
props: mapProps(['resourceName', 'resourceId', 'mode']),
beforeUnmount() {
Nova.$off(this.fieldAttributeValueEventName, this.listenToValueChanges)
this.clearAttachments()
this.clearFilesMarkedForRemoval()
},
methods: {
initialize() {
this.$refs.theMarkdownEditor.setValue(
this.value ?? this.currentField.value
)
Nova.$on(this.fieldAttributeValueEventName, this.listenToValueChanges)
},
fill(formData) {
this.fillIfVisible(formData, this.fieldAttribute, this.value || '')
this.fillAttachmentDraftId(formData)
},
handleFileRemoved(url) {
this.flagFileForRemoval(url)
},
handleFileAdded(url) {
this.unflagFileForRemoval(url)
},
handleChange(value) {
this.value = value
if (this.field) {
this.emitFieldValueChange(this.fieldAttribute, this.value)
}
},
onSyncedField() {
if (this.currentlyIsVisible && this.$refs.theMarkdownEditor) {
this.$refs.theMarkdownEditor.setValue(
this.currentField.value ?? this.value
)
this.$refs.theMarkdownEditor.setOption(
'readOnly',
this.currentlyIsReadonly
)
}
},
listenToValueChanges(value) {
if (this.currentlyIsVisible) {
this.$refs.theMarkdownEditor.setValue(value)
}
this.handleChange(value)
},
async fetchPreviewContent(value) {
Nova.$progress.start()
const {
data: { preview },
} = await Nova.request().post(
`/nova-api/${this.resourceName}/field/${this.fieldAttribute}/preview`,
{ value },
{
params: {
editing: true,
editMode: isNil(this.resourceId) ? 'create' : 'update',
},
}
)
Nova.$progress.done()
return preview
},
},
computed: {
previewer() {
if (!this.isActionRequest) {
return this.fetchPreviewContent
}
},
uploader() {
if (!this.isActionRequest && this.field.withFiles) {
return this.uploadAttachment
}
},
},
}
</script>

View File

@@ -0,0 +1,610 @@
<template>
<div class="border-b border-gray-100 dark:border-gray-700">
<DefaultField
:field="currentField"
:show-errors="false"
:field-name="fieldName"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<div v-if="hasMorphToTypes" class="flex relative">
<select
:disabled="
(viaRelatedResource && !shouldIgnoresViaRelatedResource) ||
currentlyIsReadonly
"
:dusk="`${field.attribute}-type`"
:value="resourceType"
@change="refreshResourcesForTypeChange"
class="block w-full form-control form-input form-control-bordered form-input mb-3"
>
<option value="" selected :disabled="!currentField.nullable">
{{ __('Choose Type') }}
</option>
<option
v-for="option in currentField.morphToTypes"
:key="option.value"
:value="option.value"
:selected="resourceType == option.value"
>
{{ option.singularLabel }}
</option>
</select>
<IconArrow
class="pointer-events-none absolute text-gray-700 top-[15px] right-[11px]"
/>
</div>
<label v-else class="flex items-center select-none mt-2">
{{ __('There are no available options for this resource.') }}
</label>
</template>
</DefaultField>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="false"
:field-name="fieldTypeName"
v-if="hasMorphToTypes"
:full-width-content="fullWidthContent"
>
<template #field>
<div class="flex items-center mb-3">
<SearchInput
v-if="useSearchInput"
class="w-full"
:dusk="`${field.attribute}-search-input`"
:disabled="currentlyIsReadonly"
@input="performResourceSearch"
@clear="clearResourceSelection"
@selected="selectResourceFromSearchInput"
:debounce="currentField.debounce"
:value="selectedResource"
:data="filteredResources"
:clearable="
currentField.nullable ||
editingExistingResource ||
viaRelatedResource ||
createdViaRelationModal
"
trackBy="value"
:mode="mode"
>
<div v-if="selectedResource" class="flex items-center">
<div v-if="selectedResource.avatar" class="mr-3">
<img
:src="selectedResource.avatar"
class="w-8 h-8 rounded-full block"
/>
</div>
{{ selectedResource.display }}
</div>
<template #option="{ selected, option }">
<div class="flex items-center">
<div v-if="option.avatar" class="flex-none mr-3">
<img
:src="option.avatar"
class="w-8 h-8 rounded-full block"
/>
</div>
<div class="flex-auto">
<div
class="text-sm font-semibold leading-5"
:class="{ 'text-white': selected }"
>
{{ option.display }}
</div>
<div
v-if="currentField.withSubtitles"
class="mt-1 text-xs font-semibold leading-5 text-gray-500"
:class="{ 'text-white': selected }"
>
<span v-if="option.subtitle">{{ option.subtitle }}</span>
<span v-else>{{ __('No additional information...') }}</span>
</div>
</div>
</div>
</template>
</SearchInput>
<SelectControl
v-else
class="w-full"
:class="{ 'form-control-bordered-error': hasError }"
:dusk="`${field.attribute}-select`"
@change="selectResourceFromSelectControl"
:disabled="!resourceType || currentlyIsReadonly"
:options="availableResources"
v-model:selected="selectedResourceId"
label="display"
>
<option
value=""
:disabled="!currentField.nullable"
:selected="selectedResourceId === ''"
>
{{ __('Choose') }} {{ fieldTypeName }}
</option>
</SelectControl>
<CreateRelationButton
v-if="canShowNewRelationModal"
@click="openRelationModal"
class="ml-2"
:dusk="`${field.attribute}-inline-create`"
/>
</div>
<CreateRelationModal
v-if="canShowNewRelationModal"
:show="relationModalOpen"
:size="field.modalSize"
@set-resource="handleSetResource"
@create-cancelled="closeRelationModal"
:resource-name="resourceType"
:via-relationship="viaRelationship"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
/>
<TrashedCheckbox
v-if="shouldShowTrashed"
class="mt-3"
:resource-name="field.attribute"
:checked="withTrashed"
@input="toggleWithTrashed"
/>
</template>
</DefaultField>
</div>
</template>
<script>
import find from 'lodash/find'
import isNil from 'lodash/isNil'
import storage from '@/storage/MorphToFieldStorage'
import {
DependentFormField,
HandlesValidationErrors,
InteractsWithQueryString,
PerformsSearches,
TogglesTrashed,
} from '@/mixins'
import filled from '@/util/filled'
export default {
mixins: [
DependentFormField,
HandlesValidationErrors,
InteractsWithQueryString,
PerformsSearches,
TogglesTrashed,
],
data: () => ({
resourceType: '',
initializingWithExistingResource: false,
createdViaRelationModal: false,
softDeletes: false,
selectedResourceId: null,
selectedResource: null,
search: '',
relationModalOpen: false,
withTrashed: false,
}),
/**
* Mount the component.
*/
mounted() {
this.initializeComponent()
},
methods: {
initializeComponent() {
this.selectedResourceId = this.field.value
if (this.editingExistingResource) {
this.initializingWithExistingResource = true
this.resourceType = this.field.morphToType
this.selectedResourceId = this.field.morphToId
} else if (this.viaRelatedResource) {
this.initializingWithExistingResource = true
this.resourceType = this.viaResource
this.selectedResourceId = this.viaResourceId
}
if (this.shouldSelectInitialResource) {
if (!this.resourceType && this.field.defaultResource) {
this.resourceType = this.field.defaultResource
}
this.getAvailableResources().then(() => this.selectInitialResource())
}
if (this.resourceType) {
this.determineIfSoftDeletes()
}
this.field.fill = this.fill
},
/**
* Set the currently selected resource
*/
selectResourceFromSearchInput(resource) {
if (this.field) {
this.emitFieldValueChange(
`${this.fieldAttribute}_type`,
this.resourceType
)
}
this.selectResource(resource)
},
/**
* Select a resource using the <select> control
*/
selectResourceFromSelectControl(value) {
this.selectedResourceId = value
this.selectInitialResource()
if (this.field) {
this.emitFieldValueChange(
`${this.fieldAttribute}_type`,
this.resourceType
)
this.emitFieldValueChange(this.fieldAttribute, this.selectedResourceId)
}
},
/**
* Fill the forms formData with details from this field
*/
fill(formData) {
if (this.selectedResource && this.resourceType) {
this.fillIfVisible(
formData,
this.fieldAttribute,
this.selectedResource.value
)
this.fillIfVisible(
formData,
`${this.fieldAttribute}_type`,
this.resourceType
)
} else {
this.fillIfVisible(formData, this.fieldAttribute, '')
this.fillIfVisible(formData, `${this.fieldAttribute}_type`, '')
}
this.fillIfVisible(
formData,
`${this.fieldAttribute}_trashed`,
this.withTrashed
)
},
/**
* Get the resources that may be related to this resource.
*/
getAvailableResources(search = '') {
Nova.$progress.start()
return storage
.fetchAvailableResources(this.resourceName, this.fieldAttribute, {
params: this.queryParams,
})
.then(({ data: { resources, softDeletes, withTrashed } }) => {
Nova.$progress.done()
if (this.initializingWithExistingResource || !this.isSearchable) {
this.withTrashed = withTrashed
}
if (this.isSearchable) {
this.initializingWithExistingResource = false
}
this.availableResources = resources
this.softDeletes = softDeletes
})
.catch(e => {
Nova.$progress.done()
})
},
onSyncedField() {
if (this.resourceType !== this.currentField.morphToType) {
this.refreshResourcesForTypeChange(this.currentField.morphToType)
}
},
/**
* Select the initial selected resource
*/
selectInitialResource() {
this.selectedResource = find(
this.availableResources,
r => r.value == this.selectedResourceId
)
},
/**
* Determine if the selected resource type is soft deleting.
*/
determineIfSoftDeletes() {
return storage
.determineIfSoftDeletes(this.resourceType)
.then(({ data: { softDeletes } }) => (this.softDeletes = softDeletes))
},
/**
* Handle the changing of the resource type.
*/
async refreshResourcesForTypeChange(event) {
this.resourceType = event?.target?.value ?? event
this.availableResources = []
this.selectedResource = ''
this.selectedResourceId = ''
this.withTrashed = false
this.softDeletes = false
this.determineIfSoftDeletes()
if (!this.isSearchable && this.resourceType) {
this.getAvailableResources().then(() => {
this.emitFieldValueChange(
`${this.fieldAttribute}_type`,
this.resourceType
)
this.emitFieldValueChange(this.fieldAttribute, null)
})
}
},
/**
* Toggle the trashed state of the search
*/
toggleWithTrashed() {
// Reload the data if the component doesn't have selected resource
if (!filled(this.selectedResource)) {
this.withTrashed = !this.withTrashed
// Reload the data if the component doesn't support searching
if (!this.isSearchable) {
this.getAvailableResources()
}
}
},
openRelationModal() {
Nova.$emit('create-relation-modal-opened')
this.relationModalOpen = true
},
closeRelationModal() {
this.relationModalOpen = false
Nova.$emit('create-relation-modal-closed')
},
handleSetResource({ id }) {
this.closeRelationModal()
this.selectedResourceId = id
this.createdViaRelationModal = true
this.initializingWithExistingResource = true
this.getAvailableResources().then(() => {
this.selectInitialResource()
this.emitFieldValueChange(
`${this.fieldAttribute}_type`,
this.resourceType
)
this.emitFieldValueChange(this.fieldAttribute, this.selectedResourceId)
})
},
performResourceSearch(search) {
if (this.useSearchInput) {
this.performSearch(search)
} else {
this.search = search
}
},
clearResourceSelection() {
this.clearSelection()
if (this.viaRelatedResource && !this.createdViaRelationModal) {
this.updateQueryString({
viaResource: null,
viaResourceId: null,
viaRelationship: null,
relationshipType: null,
}).then(() => {
Nova.$router.reload({
onSuccess: () => {
this.initializingWithExistingResource = false
this.initializeComponent()
},
})
})
} else {
if (this.createdViaRelationModal) {
this.createdViaRelationModal = false
this.initializingWithExistingResource = false
}
this.getAvailableResources()
}
},
},
computed: {
/**
* Determine if an existing resource is being updated.
*/
editingExistingResource() {
return Boolean(this.field.morphToId && this.field.morphToType)
},
/**
* Determine if we are creating a new resource via a parent relation
*/
viaRelatedResource() {
return Boolean(
find(
this.currentField.morphToTypes,
type => type.value == this.viaResource
) &&
this.viaResource &&
this.viaResourceId &&
this.currentField.reverse
)
},
/**
* Determine if we should select an initial resource when mounting this field
*/
shouldSelectInitialResource() {
return Boolean(
this.editingExistingResource ||
this.viaRelatedResource ||
Boolean(this.field.value && this.field.defaultResource)
)
},
/**
* Determine if the related resources is searchable
*/
isSearchable() {
return Boolean(this.currentField.searchable)
},
shouldLoadFirstResource() {
return (
((this.useSearchInput &&
!this.shouldIgnoreViaRelatedResource &&
this.shouldSelectInitialResource) ||
this.createdViaRelationModal) &&
this.initializingWithExistingResource
)
},
/**
* Get the query params for getting available resources
*/
queryParams() {
return {
type: this.resourceType,
current: this.selectedResourceId,
first: this.shouldLoadFirstResource,
search: this.search,
withTrashed: this.withTrashed,
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
viaRelationship: this.viaRelationship,
component: this.field.dependentComponentKey,
dependsOn: this.encodedDependentFieldValues,
editing: true,
editMode:
isNil(this.resourceId) || this.resourceId === ''
? 'create'
: 'update',
}
},
/**
* Return the morphable type label for the field
*/
fieldName() {
return this.field.name
},
/**
* Return the selected morphable type's label
*/
fieldTypeName() {
if (this.resourceType) {
return (
find(this.currentField.morphToTypes, type => {
return type.value == this.resourceType
})?.singularLabel || ''
)
}
return ''
},
/**
* Determine whether there are any morph to types.
*/
hasMorphToTypes() {
return this.currentField.morphToTypes.length > 0
},
authorizedToCreate() {
return find(Nova.config('resources'), resource => {
return resource.uriKey == this.resourceType
}).authorizedToCreate
},
canShowNewRelationModal() {
return (
this.currentField.showCreateRelationButton &&
this.resourceType &&
!this.shownViaNewRelationModal &&
!this.viaRelatedResource &&
!this.currentlyIsReadonly &&
this.authorizedToCreate
)
},
shouldShowTrashed() {
return (
this.softDeletes &&
!this.viaRelatedResource &&
!this.currentlyIsReadonly &&
this.currentField.displaysWithTrashed
)
},
currentFieldValues() {
return {
[this.fieldAttribute]: this.value,
[`${this.fieldAttribute}_type`]: this.resourceType,
}
},
/**
* Return the field options filtered by the search string.
*/
filteredResources() {
if (!this.isSearchable) {
return this.availableResources.filter(option => {
return (
option.display.toLowerCase().indexOf(this.search.toLowerCase()) >
-1 || new String(option.value).indexOf(this.search) > -1
)
})
}
return this.availableResources
},
shouldIgnoresViaRelatedResource() {
return this.viaRelatedResource && filled(this.search)
},
useSearchInput() {
return this.isSearchable || this.viaRelatedResource
},
},
}
</script>

View File

@@ -0,0 +1,156 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<!-- Select Input Field -->
<MultiSelectControl
:id="currentField.uniqueKey"
:dusk="field.attribute"
v-model:selected="value"
@change="handleChange"
class="w-full"
:class="errorClasses"
:options="currentField.options"
:disabled="currentlyIsReadonly"
>
<option
v-if="shouldShowPlaceholder"
value=""
:selected="!hasValue"
:disabled="!currentField.nullable"
>
{{ placeholder }}
</option>
</MultiSelectControl>
</template>
</DefaultField>
</template>
<script>
import filter from 'lodash/filter'
import map from 'lodash/map'
import merge from 'lodash/merge'
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
import filled from '@/util/filled'
export default {
mixins: [HandlesValidationErrors, DependentFormField],
data: () => ({
search: '',
}),
methods: {
/*
* Set the initial value for the field
*/
setInitialValue() {
let values = !(
this.currentField.value === undefined ||
this.currentField.value === null ||
this.currentField.value === ''
)
? merge(this.currentField.value || [], this.value)
: this.value
let selectedOptions = filter(
this.currentField.options ?? [],
o => values.includes(o.value) || values.includes(o.value.toString())
)
this.value = map(selectedOptions, o => o.value)
},
/**
* Return the field default value.
*/
fieldDefaultValue() {
return []
},
/**
* Provide a function that fills a passed FormData object with the
* field's internal value attribute. Here we are forcing there to be a
* value sent to the server instead of the default behavior of
* `this.value || ''` to avoid loose-comparison issues if the keys
* are truthy or falsey
*/
fill(formData) {
this.fillIfVisible(
formData,
this.fieldAttribute,
JSON.stringify(this.value)
)
},
/**
* Set the search string to be used to filter the select field.
*/
performSearch(event) {
this.search = event
},
/**
* Handle the selection change event.
*/
handleChange(values) {
let selectedOptions = filter(
this.currentField.options ?? [],
o => values.includes(o.value) || values.includes(o.value.toString())
)
this.value = map(selectedOptions, o => o.value)
if (this.field) {
this.emitFieldValueChange(this.fieldAttribute, this.value)
}
},
onSyncedField() {
this.setInitialValue()
},
},
computed: {
/**
* Return the field options filtered by the search string.
*/
filteredOptions() {
let options = this.currentField.options || []
return options.filter(option => {
return (
option.label
.toString()
.toLowerCase()
.indexOf(this.search.toLowerCase()) > -1
)
})
},
/**
* Return the placeholder text for the field.
*/
placeholder() {
return this.currentField.placeholder || this.__('Choose an option')
},
/**
* Return value has been setted.
*/
hasValue() {
return Boolean(
!(this.value === undefined || this.value === null || this.value === '')
)
},
shouldShowPlaceholder() {
return filled(this.currentField.placeholder) || this.currentField.nullable
},
},
}
</script>

View File

@@ -0,0 +1,88 @@
<template>
<div v-if="panel.fields.length > 0" v-show="visibleFieldsCount > 0">
<Heading
:level="1"
:class="panel.helpText ? 'mb-2' : 'mb-3'"
:dusk="`${dusk}-heading`"
>
{{ panel.name }}
</Heading>
<p
v-if="panel.helpText"
class="text-gray-500 text-sm font-semibold italic mb-3"
v-html="panel.helpText"
/>
<Card class="divide-y divide-gray-100 dark:divide-gray-700">
<component
v-for="(field, index) in panel.fields"
:index="index"
:key="index"
:is="`form-${field.component}`"
:errors="validationErrors"
:resource-id="resourceId"
:resource-name="resourceName"
:related-resource-name="relatedResourceName"
:related-resource-id="relatedResourceId"
:field="field"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:shown-via-new-relation-modal="shownViaNewRelationModal"
:form-unique-id="formUniqueId"
:mode="mode"
@field-shown="handleFieldShown"
@field-hidden="handleFieldHidden"
@field-changed="$emit('field-changed')"
@file-deleted="handleFileDeleted"
@file-upload-started="$emit('file-upload-started')"
@file-upload-finished="$emit('file-upload-finished')"
:show-help-text="showHelpText"
/>
</Card>
</div>
</template>
<script>
import { HandlesPanelVisibility, mapProps } from '@/mixins'
export default {
name: 'FormPanel',
mixins: [HandlesPanelVisibility],
emits: [
'field-changed',
'update-last-retrieved-at-timestamp',
'file-deleted',
'file-upload-started',
'file-upload-finished',
],
props: {
...mapProps(['mode']),
shownViaNewRelationModal: { type: Boolean, default: false },
showHelpText: { type: Boolean, default: false },
panel: { type: Object, required: true },
name: { default: 'Panel' },
dusk: { type: String },
fields: { type: Array, default: [] },
formUniqueId: { type: String },
validationErrors: { type: Object, required: true },
resourceName: { type: String, required: true },
resourceId: { type: [Number, String] },
relatedResourceName: { type: String },
relatedResourceId: { type: [Number, String] },
viaResource: { type: String },
viaResourceId: { type: [Number, String] },
viaRelationship: { type: String },
},
methods: {
handleFileDeleted() {
this.$emit('update-last-retrieved-at-timestamp')
},
},
}
</script>

View File

@@ -0,0 +1,30 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<input
:id="currentField.uniqueKey"
:dusk="field.attribute"
type="password"
v-model="value"
class="w-full form-control form-input form-control-bordered"
:class="errorClasses"
:placeholder="placeholder"
autocomplete="new-password"
:disabled="currentlyIsReadonly"
/>
</template>
</DefaultField>
</template>
<script>
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
export default {
mixins: [HandlesValidationErrors, DependentFormField],
}
</script>

View File

@@ -0,0 +1,396 @@
<template>
<DefaultField
:field="field"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<input
:ref="field.attribute"
:id="field.uniqueKey"
:dusk="field.attribute"
type="text"
v-model="value"
class="w-full form-control form-input form-control-bordered"
:class="errorClasses"
:placeholder="field.name"
:disabled="isReadonly"
/>
</template>
</DefaultField>
</template>
<script>
import find from 'lodash/find'
import { FormField, HandlesValidationErrors } from '@/mixins'
export default {
mixins: [HandlesValidationErrors, FormField],
/**
* Mount the component.
*/
mounted() {
this.setInitialValue()
this.field.fill = this.fill
this.initializePlaces()
},
methods: {
/**
* Initialize Algolia places library.
*/
initializePlaces() {
const places = require('places.js')
const placeType = this.field.placeType
const config = {
appId: Nova.config('algoliaAppId'),
apiKey: Nova.config('algoliaApiKey'),
container: this.$refs[this.fieldAttribute],
type: this.field.placeType ? this.field.placeType : 'address',
templates: {
value(suggestion) {
return suggestion.name
},
},
}
if (this.field.countries) {
config.countries = this.field.countries
}
if (this.field.language) {
config.language = this.field.language
}
const placesAutocomplete = places(config)
placesAutocomplete.on('change', e => {
this.$nextTick(() => {
this.value = e.suggestion.name
this.emitFieldValue(this.field.secondAddressLine, '')
this.emitFieldValue(this.field.city, e.suggestion.city)
this.emitFieldValue(
this.field.state,
this.parseState(
e.suggestion.administrative,
e.suggestion.countryCode
)
)
this.emitFieldValue(this.field.postalCode, e.suggestion.postcode)
this.emitFieldValue(this.field.suburb, e.suggestion.suburb)
this.emitFieldValue(
this.field.country,
e.suggestion.countryCode.toUpperCase()
)
this.emitFieldValue(this.field.latitude, e.suggestion.latlng.lat)
this.emitFieldValue(this.field.longitude, e.suggestion.latlng.lng)
})
})
placesAutocomplete.on('clear', () => {
this.$nextTick(() => {
this.value = ''
this.emitFieldValue(this.field.secondAddressLine, '')
this.emitFieldValue(this.field.city, '')
this.emitFieldValue(this.field.state, '')
this.emitFieldValue(this.field.postalCode, '')
this.emitFieldValue(this.field.suburb, '')
this.emitFieldValue(this.field.country, '')
this.emitFieldValue(this.field.latitude, '')
this.emitFieldValue(this.field.longitude, '')
})
})
},
/**
* Parse the selected state into an abbreviation if possible.
*/
parseState(state, countryCode) {
if (countryCode != 'us') {
return state
}
return find(this.states, s => {
return s.name == state
}).abbr
},
},
computed: {
/**
* Get the list of United States.
*/
states() {
return {
AL: {
count: '0',
name: 'Alabama',
abbr: 'AL',
},
AK: {
count: '1',
name: 'Alaska',
abbr: 'AK',
},
AZ: {
count: '2',
name: 'Arizona',
abbr: 'AZ',
},
AR: {
count: '3',
name: 'Arkansas',
abbr: 'AR',
},
CA: {
count: '4',
name: 'California',
abbr: 'CA',
},
CO: {
count: '5',
name: 'Colorado',
abbr: 'CO',
},
CT: {
count: '6',
name: 'Connecticut',
abbr: 'CT',
},
DE: {
count: '7',
name: 'Delaware',
abbr: 'DE',
},
DC: {
count: '8',
name: 'District Of Columbia',
abbr: 'DC',
},
FL: {
count: '9',
name: 'Florida',
abbr: 'FL',
},
GA: {
count: '10',
name: 'Georgia',
abbr: 'GA',
},
HI: {
count: '11',
name: 'Hawaii',
abbr: 'HI',
},
ID: {
count: '12',
name: 'Idaho',
abbr: 'ID',
},
IL: {
count: '13',
name: 'Illinois',
abbr: 'IL',
},
IN: {
count: '14',
name: 'Indiana',
abbr: 'IN',
},
IA: {
count: '15',
name: 'Iowa',
abbr: 'IA',
},
KS: {
count: '16',
name: 'Kansas',
abbr: 'KS',
},
KY: {
count: '17',
name: 'Kentucky',
abbr: 'KY',
},
LA: {
count: '18',
name: 'Louisiana',
abbr: 'LA',
},
ME: {
count: '19',
name: 'Maine',
abbr: 'ME',
},
MD: {
count: '20',
name: 'Maryland',
abbr: 'MD',
},
MA: {
count: '21',
name: 'Massachusetts',
abbr: 'MA',
},
MI: {
count: '22',
name: 'Michigan',
abbr: 'MI',
},
MN: {
count: '23',
name: 'Minnesota',
abbr: 'MN',
},
MS: {
count: '24',
name: 'Mississippi',
abbr: 'MS',
},
MO: {
count: '25',
name: 'Missouri',
abbr: 'MO',
},
MT: {
count: '26',
name: 'Montana',
abbr: 'MT',
},
NE: {
count: '27',
name: 'Nebraska',
abbr: 'NE',
},
NV: {
count: '28',
name: 'Nevada',
abbr: 'NV',
},
NH: {
count: '29',
name: 'New Hampshire',
abbr: 'NH',
},
NJ: {
count: '30',
name: 'New Jersey',
abbr: 'NJ',
},
NM: {
count: '31',
name: 'New Mexico',
abbr: 'NM',
},
NY: {
count: '32',
name: 'New York',
abbr: 'NY',
},
NC: {
count: '33',
name: 'North Carolina',
abbr: 'NC',
},
ND: {
count: '34',
name: 'North Dakota',
abbr: 'ND',
},
OH: {
count: '35',
name: 'Ohio',
abbr: 'OH',
},
OK: {
count: '36',
name: 'Oklahoma',
abbr: 'OK',
},
OR: {
count: '37',
name: 'Oregon',
abbr: 'OR',
},
PA: {
count: '38',
name: 'Pennsylvania',
abbr: 'PA',
},
RI: {
count: '39',
name: 'Rhode Island',
abbr: 'RI',
},
SC: {
count: '40',
name: 'South Carolina',
abbr: 'SC',
},
SD: {
count: '41',
name: 'South Dakota',
abbr: 'SD',
},
TN: {
count: '42',
name: 'Tennessee',
abbr: 'TN',
},
TX: {
count: '43',
name: 'Texas',
abbr: 'TX',
},
UT: {
count: '44',
name: 'Utah',
abbr: 'UT',
},
VT: {
count: '45',
name: 'Vermont',
abbr: 'VT',
},
VA: {
count: '46',
name: 'Virginia',
abbr: 'VA',
},
WA: {
count: '47',
name: 'Washington',
abbr: 'WA',
},
WV: {
count: '48',
name: 'West Virginia',
abbr: 'WV',
},
WI: {
count: '49',
name: 'Wisconsin',
abbr: 'WI',
},
WY: {
count: '50',
name: 'Wyoming',
abbr: 'WY',
},
}
},
},
}
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div v-if="field.authorizedToCreate">
<Heading :level="4" :class="panel.helpText ? 'mb-2' : 'mb-3'">{{
panel.name
}}</Heading>
<p
v-if="panel.helpText"
class="text-gray-500 text-sm font-semibold italic mb-3"
v-html="panel.helpText"
></p>
<component
:is="`form-${field.component}`"
:errors="validationErrors"
:resource-id="relationId"
:resource-name="field.resourceName"
:field="field"
:via-resource="field.from.viaResource"
:via-resource-id="field.from.viaResourceId"
:via-relationship="field.from.viaRelationship"
:form-unique-id="relationFormUniqueId"
:mode="mode"
@field-changed="$emit('field-changed')"
@file-deleted="handleFileDeleted"
@file-upload-started="$emit('file-upload-started')"
@file-upload-finished="$emit('file-upload-finished')"
:show-help-text="showHelpText"
/>
</div>
</template>
<script>
import { uid } from 'uid/single'
import { BehavesAsPanel } from '@/mixins'
import { mapProps } from '@/mixins'
export default {
name: 'FormRelationshipPanel',
emits: [
'field-changed',
'update-last-retrieved-at-timestamp',
'file-upload-started',
'file-upload-finished',
'file-deleted',
],
mixins: [BehavesAsPanel],
props: {
shownViaNewRelationModal: { type: Boolean, default: false },
showHelpText: { type: Boolean, default: false },
panel: { type: Object, required: true },
name: { default: 'Relationship Panel' },
...mapProps(['mode']),
fields: { type: Array, default: [] },
formUniqueId: { type: String },
validationErrors: { type: Object, required: true },
resourceName: { type: String, required: true },
resourceId: { type: [Number, String] },
viaResource: { type: String },
viaResourceId: { type: [Number, String] },
viaRelationship: { type: String },
},
data: () => ({
relationFormUniqueId: uid(),
}),
mounted() {
if (!this.field.authorizedToCreate) {
this.field.fill = () => {}
}
},
methods: {
handleFileDeleted() {
this.$emit('update-last-retrieved-at-timestamp')
},
},
computed: {
field() {
return this.panel.fields[0]
},
relationId() {
if (['hasOne', 'morphOne'].includes(this.field.relationshipType)) {
return this.field.hasOneId
}
},
},
}
</script>

View File

@@ -0,0 +1,194 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<div v-if="value.length > 0" class="space-y-4" :dusk="fieldAttribute">
<RepeaterRow
v-for="(item, index) in value"
:dusk="`${index}-repeater-row`"
:data-repeater-id="valueMap.get(item)"
:item="item"
:index="index"
:key="valueMap.get(item)"
@click="removeItem"
:errors="errors"
:sortable="currentField.sortable && value.length > 1"
@move-up="moveUp"
@move-down="moveDown"
:field="currentField"
:via-parent="fieldAttribute"
/>
</div>
<div>
<div
class="text-center"
:class="{
'bg-gray-50 dark:bg-gray-900 rounded-lg border-4 dark:border-gray-600 border-dashed py-3':
value.length === 0,
}"
>
<Dropdown v-if="currentField.repeatables.length > 1">
<Button
variant="link"
leading-icon="plus-circle"
trailing-icon="chevron-down"
>
{{ __('Add item') }}
</Button>
<template #menu>
<DropdownMenu class="py-1">
<DropdownMenuItem
@click="() => addItem(repeatable.type)"
as="button"
v-for="repeatable in currentField.repeatables"
class="space-x-2"
>
<span><Icon solid :type="repeatable.icon" /></span>
<span>{{ repeatable.singularLabel }}</span>
</DropdownMenuItem>
</DropdownMenu>
</template>
</Dropdown>
<InvertedButton
v-else
@click="addItem(currentField.repeatables[0].type)"
type="button"
>
<span>{{
__('Add :resource', {
resource: currentField.repeatables[0].singularLabel,
})
}}</span>
</InvertedButton>
</div>
</div>
</template>
</DefaultField>
</template>
<script>
import { FormField, HandlesValidationErrors } from '@/mixins'
import cloneDeep from 'lodash/cloneDeep'
import { uid } from 'uid/single'
import { computed } from 'vue'
import { Button } from 'laravel-nova-ui'
export default {
mixins: [FormField, HandlesValidationErrors],
components: { Button },
provide() {
return {
removeFile: this.removeFile,
shownViaNewRelationModal: computed(() => this.shownViaNewRelationModal),
viaResource: computed(() => this.viaResource),
viaResourceId: computed(() => this.viaResourceId),
viaRelationship: computed(() => this.viaRelationship),
resourceName: computed(() => this.resourceName),
resourceId: computed(() => this.resourceId),
}
},
data: () => ({
valueMap: new WeakMap(),
}),
beforeMount() {
this.value.map(repeatable => {
this.valueMap.set(repeatable, uid())
return repeatable
})
},
methods: {
/**
* Return the field default value.
*/
fieldDefaultValue() {
return []
},
removeFile(attribute) {
const {
resourceName,
resourceId,
relatedResourceName,
relatedResourceId,
viaRelationship,
} = this
const uri =
viaRelationship && relatedResourceName && relatedResourceId
? `/nova-api/${resourceName}/${resourceId}/${relatedResourceName}/${relatedResourceId}/field/${attribute}?viaRelationship=${viaRelationship}`
: `/nova-api/${resourceName}/${resourceId}/field/${attribute}`
Nova.request().delete(uri)
},
fill(formData) {
this.finalPayload.forEach((repeatable, i) => {
const attribute = `${this.fieldAttribute}[${i}]`
formData.append(`${attribute}[type]`, repeatable.type)
Object.keys(repeatable.fields).forEach(key => {
formData.append(
`${attribute}[fields][${key}]`,
repeatable.fields[key]
)
})
})
},
addItem(repeatableType) {
const repeatable = this.currentField.repeatables.find(
t => t.type === repeatableType
)
const copy = cloneDeep(repeatable)
this.valueMap.set(copy, uid())
this.value.push(copy)
},
removeItem(index) {
const item = this.value.splice(index, 1)
this.valueMap.delete(item)
},
moveUp(index) {
const item = this.value.splice(index, 1)
this.value.splice(Math.max(0, index - 1), 0, item[0])
},
moveDown(index) {
const item = this.value.splice(index, 1)
this.value.splice(Math.min(this.value.length, index + 1), 0, item[0])
},
},
computed: {
finalPayload() {
return this.value.map(repeatable => {
const formData = new FormData()
const fields = {}
repeatable.fields.forEach(f => f.fill && f.fill(formData))
for (const pair of formData.entries()) {
fields[pair[0]] = pair[1]
}
return { type: repeatable.type, fields }
})
},
},
}
</script>

View File

@@ -0,0 +1,239 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<!-- Search Input -->
<SearchInput
v-if="!currentlyIsReadonly && isSearchable"
:dusk="`${field.attribute}-search-input`"
@input="performSearch"
@clear="clearSelection"
@selected="selectOption"
:has-error="hasError"
:value="selectedOption"
:data="filteredOptions"
:clearable="currentField.nullable"
trackBy="value"
class="w-full"
:mode="mode"
:disabled="currentlyIsReadonly"
>
<!-- The Selected Option Slot -->
<div v-if="selectedOption" class="flex items-center">
{{ selectedOption.label }}
</div>
<template #option="{ selected, option }">
<!-- Options List Slot -->
<div
class="flex items-center text-sm font-semibold leading-5"
:class="{ 'text-white': selected }"
>
{{ option.label }}
</div>
</template>
</SearchInput>
<!-- Select Input Field -->
<SelectControl
v-else
:id="field.attribute"
:dusk="field.attribute"
v-model:selected="value"
@change="handleChange"
class="w-full"
:has-error="hasError"
:options="currentField.options"
:disabled="currentlyIsReadonly"
>
<option value="" selected :disabled="!currentField.nullable">
{{ placeholder }}
</option>
</SelectControl>
</template>
</DefaultField>
</template>
<script>
import find from 'lodash/find'
import first from 'lodash/first'
import isNil from 'lodash/isNil'
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
import filled from '@/util/filled'
export default {
mixins: [HandlesValidationErrors, DependentFormField],
data: () => ({
search: '',
selectedOption: null,
}),
created() {
if (filled(this.field.value)) {
let selectedOption = find(
this.field.options,
v => v.value == this.field.value
)
this.$nextTick(() => {
this.selectOption(selectedOption)
})
}
},
methods: {
/**
* Return the field default value.
*/
fieldDefaultValue() {
return null
},
/**
* Provide a function that fills a passed FormData object with the
* field's internal value attribute. Here we are forcing there to be a
* value sent to the server instead of the default behavior of
* `this.value || ''` to avoid loose-comparison issues if the keys
* are truthy or falsey
*/
fill(formData) {
this.fillIfVisible(formData, this.fieldAttribute, this.value ?? '')
},
/**
* Set the search string to be used to filter the select field.
*/
performSearch(event) {
this.search = event
},
/**
* Clear the current selection for the field.
*/
clearSelection() {
this.selectedOption = null
this.value = this.fieldDefaultValue()
if (this.field) {
this.emitFieldValueChange(this.fieldAttribute, this.value)
}
},
/**
* Select the given option.
*/
selectOption(option) {
if (isNil(option)) {
this.clearSelection()
return
}
this.selectedOption = option
this.value = option.value
if (this.field) {
this.emitFieldValueChange(this.fieldAttribute, this.value)
}
},
/**
* Handle the selection change event.
*/
handleChange(value) {
let selectedOption = find(
this.currentField.options,
v => v.value == value
)
this.selectOption(selectedOption)
},
/**
* Handle on synced field.
*/
onSyncedField() {
let currentSelectedOption = null
let hasValue = false
if (this.selectedOption) {
hasValue = true
currentSelectedOption = find(
this.currentField.options,
v => v.value === this.selectedOption.value
)
}
let selectedOption = find(
this.currentField.options,
v => v.value == this.currentField.value
)
if (isNil(currentSelectedOption)) {
this.clearSelection()
if (this.currentField.value) {
this.selectOption(selectedOption)
} else if (hasValue && !this.currentField.nullable) {
this.selectOption(first(this.currentField.options))
}
return
} else if (
currentSelectedOption &&
selectedOption &&
['create', 'attach'].includes(this.editMode)
) {
this.selectOption(selectedOption)
return
}
this.selectOption(currentSelectedOption)
},
},
computed: {
/**
* Determine if the related resources is searchable
*/
isSearchable() {
return this.currentField.searchable
},
/**
* Return the field options filtered by the search string.
*/
filteredOptions() {
return this.currentField.options.filter(option => {
return (
option.label
.toString()
.toLowerCase()
.indexOf(this.search.toLowerCase()) > -1
)
})
},
/**
* Return the placeholder text for the field.
*/
placeholder() {
return this.currentField.placeholder || this.__('Choose an option')
},
/**
* Determine if the field has a non-empty value.
*/
hasValue() {
return Boolean(
!(this.value === undefined || this.value === null || this.value === '')
)
},
},
}
</script>

View File

@@ -0,0 +1,117 @@
<template>
<DefaultField
:field="field"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<div class="flex items-center">
<input
v-bind="extraAttributes"
ref="theInput"
class="w-full form-control form-input form-control-bordered"
:id="field.uniqueKey"
:dusk="field.attribute"
v-model="value"
:disabled="isReadonly"
/>
<button
class="rounded inline-flex text-sm ml-3 link-default"
v-if="field.showCustomizeButton"
type="button"
@click="toggleCustomizeClick"
>
{{ __('Customize') }}
</button>
</div>
</template>
</DefaultField>
</template>
<script>
import { FormField, HandlesValidationErrors } from '@/mixins'
import debounce from 'lodash/debounce'
export default {
mixins: [HandlesValidationErrors, FormField],
data: () => ({
isListeningToChanges: false,
debouncedHandleChange: null,
}),
mounted() {
if (this.shouldRegisterInitialListener) {
this.registerChangeListener()
}
},
beforeUnmount() {
this.removeChangeListener()
},
methods: {
async fetchPreviewContent(value) {
const {
data: { preview },
} = await Nova.request().post(
`/nova-api/${this.resourceName}/field/${this.fieldAttribute}/preview`,
{ value }
)
return preview
},
registerChangeListener() {
Nova.$on(this.eventName, debounce(this.handleChange, 250))
this.isListeningToChanges = true
},
removeChangeListener() {
if (this.isListeningToChanges === true) {
Nova.$off(this.eventName)
}
},
async handleChange(value) {
this.value = await this.fetchPreviewContent(value)
},
toggleCustomizeClick() {
if (this.field.readonly) {
this.removeChangeListener()
this.isListeningToChanges = false
this.field.readonly = false
this.field.extraAttributes.readonly = false
this.field.showCustomizeButton = false
this.$refs.theInput.focus()
return
}
this.registerChangeListener()
this.field.readonly = true
this.field.extraAttributes.readonly = true
},
},
computed: {
shouldRegisterInitialListener() {
return !this.field.updating
},
eventName() {
return this.getFieldAttributeChangeEventName(this.field.from)
},
extraAttributes() {
return {
...this.field.extraAttributes,
class: this.errorClasses,
}
},
},
}
</script>

View File

@@ -0,0 +1,48 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<input
:id="currentField.uniqueKey"
:type="inputType"
:min="inputMin"
:max="inputMax"
:step="inputStep"
v-model="value"
class="w-full form-control form-input form-control-bordered"
:class="errorClasses"
:placeholder="field.name"
/>
</template>
</DefaultField>
</template>
<script>
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
export default {
mixins: [HandlesValidationErrors, DependentFormField],
computed: {
inputType() {
return this.currentField.type || 'text'
},
inputStep() {
return this.currentField.step
},
inputMin() {
return this.currentField.min
},
inputMax() {
return this.currentField.max
},
},
}
</script>

View File

@@ -0,0 +1,198 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<div class="space-y-4">
<div class="flex items-center">
<SearchSearchInput
ref="searchable"
:dusk="`${field.resourceName}-search-input`"
@input="performSearch"
:error="hasError"
:debounce="field.debounce"
:options="tags"
@selected="selectResource"
trackBy="value"
:disabled="currentlyIsReadonly"
:loading="loading"
class="w-full"
>
<template #option="{ dusk, selected, option }">
<SearchInputResult
:option="option"
:selected="selected"
:with-subtitles="field.withSubtitles"
:dusk="dusk"
/>
</template>
</SearchSearchInput>
<CreateRelationButton
v-if="field.showCreateRelationButton"
v-tooltip="
__('Create :resource', { resource: field.singularLabel })
"
@click="openRelationModal"
:dusk="`${field.attribute}-inline-create`"
tabindex="0"
/>
</div>
<div v-if="value.length > 0" :dusk="`${field.attribute}-selected-tags`">
<TagList
v-if="field.style === 'list'"
:tags="value"
@tag-removed="i => removeResource(i)"
:resource-name="field.resourceName"
:editable="!currentlyIsReadonly"
:with-preview="field.withPreview"
/>
<TagGroup
v-if="field.style === 'group'"
:tags="value"
@tag-removed="i => removeResource(i)"
:resource-name="field.resourceName"
:editable="!currentlyIsReadonly"
:with-preview="field.withPreview"
/>
</div>
</div>
<CreateRelationModal
:resource-name="field.resourceName"
:show="field.showCreateRelationButton && relationModalOpen"
:size="field.modalSize"
@set-resource="handleSetResource"
@create-cancelled="relationModalOpen = false"
/>
</template>
</DefaultField>
</template>
<script>
import {
DependentFormField,
PerformsSearches,
HandlesValidationErrors,
mapProps,
} from '@/mixins'
import { minimum } from '@/util'
import first from 'lodash/first'
import storage from '@/storage/ResourceSearchStorage'
import TagList from '../../components/Tags/TagList'
import SearchInputResult from '../../components/Inputs/SearchInputResult'
import PreviewResourceModal from '../../components/Modals/PreviewResourceModal'
export default {
components: { PreviewResourceModal, SearchInputResult, TagList },
mixins: [DependentFormField, PerformsSearches, HandlesValidationErrors],
props: {
...mapProps(['resourceId']),
},
data: () => ({
relationModalOpen: false,
search: '',
value: [],
tags: [],
loading: false,
}),
mounted() {
if (this.currentField.preload) {
this.getAvailableResources()
}
},
methods: {
/**
* Perform a search to get the relatable resources.
*/
performSearch(search) {
this.search = search
const trimmedSearch = search.trim()
// If the field is set to preload and the user clears the search we
// should reset the field to default and load all of the search results.
this.searchDebouncer(() => {
this.getAvailableResources(trimmedSearch)
}, 500)
},
fill(formData) {
this.fillIfVisible(
formData,
this.currentField.attribute,
this.value.length > 0 ? JSON.stringify(this.value) : ''
)
},
async getAvailableResources(search) {
this.loading = true
const queryParams = {
search: search,
current: null,
first: false,
// withTrashed: true,
}
const { data } = await minimum(
storage.fetchAvailableResources(this.currentField.resourceName, {
params: queryParams,
}),
250
)
this.loading = false
this.tags = data.resources
},
selectResource(resource) {
const found = this.value.filter(t => t.value === resource.value)
if (found.length === 0) {
this.value.push(resource)
}
},
handleSetResource({ id }) {
const queryParams = {
search: '',
current: id,
first: true,
}
storage
.fetchAvailableResources(this.currentField.resourceName, {
params: queryParams,
})
.then(({ data: { resources } }) => {
this.selectResource(first(resources))
})
.finally(() => {
this.closeRelationModal()
})
},
removeResource(index) {
this.value.splice(index, 1)
},
openRelationModal() {
this.relationModalOpen = true
},
closeRelationModal() {
this.relationModalOpen = false
},
},
}
</script>

View File

@@ -0,0 +1,77 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<div class="space-y-1">
<input
v-bind="extraAttributes"
class="w-full form-control form-input form-control-bordered"
@input="handleChange"
:value="value"
:id="currentField.uniqueKey"
:dusk="field.attribute"
:disabled="currentlyIsReadonly"
:maxlength="field.enforceMaxlength ? field.maxlength : -1"
/>
<datalist v-if="suggestions.length > 0" :id="suggestionsId">
<option
:key="suggestion"
v-for="suggestion in suggestions"
:value="suggestion"
/>
</datalist>
<CharacterCounter
v-if="field.maxlength"
:count="value.length"
:limit="field.maxlength"
/>
</div>
</template>
</DefaultField>
</template>
<script>
import {
DependentFormField,
FieldSuggestions,
HandlesValidationErrors,
} from '@/mixins'
export default {
mixins: [DependentFormField, FieldSuggestions, HandlesValidationErrors],
computed: {
defaultAttributes() {
return {
type: this.currentField.type || 'text',
placeholder: this.currentField.placeholder || this.field.name,
class: this.errorClasses,
min: this.currentField.min,
max: this.currentField.max,
step: this.currentField.step,
pattern: this.currentField.pattern,
...this.suggestionsAttributes,
}
},
extraAttributes() {
const attrs = this.currentField.extraAttributes
return {
// Leave the default attributes even though we can now specify
// whatever attributes we like because the old number field still
// uses the old field attributes
...this.defaultAttributes,
...attrs,
}
},
},
}
</script>

View File

@@ -0,0 +1,56 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:full-width-content="fullWidthContent"
:show-help-text="showHelpText"
>
<template #field>
<div class="space-y-1">
<textarea
v-bind="extraAttributes"
class="block w-full form-control form-input form-control-bordered py-3 h-auto"
:id="currentField.uniqueKey"
:dusk="field.attribute"
:value="value"
@input="handleChange"
:maxlength="field.enforceMaxlength ? field.maxlength : -1"
:placeholder="placeholder"
/>
<CharacterCounter
v-if="field.maxlength"
:count="value.length"
:limit="field.maxlength"
/>
</div>
</template>
</DefaultField>
</template>
<script>
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
export default {
mixins: [HandlesValidationErrors, DependentFormField],
computed: {
defaultAttributes() {
return {
rows: this.currentField.rows,
class: this.errorClasses,
placeholder: this.field.name,
}
},
extraAttributes() {
const attrs = this.currentField.extraAttributes
return {
...this.defaultAttributes,
...attrs,
}
},
},
}
</script>

View File

@@ -0,0 +1,116 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:full-width-content="fullWidthContent"
:key="index"
:show-help-text="showHelpText"
>
<template #field>
<div class="rounded-lg" :class="{ disabled: currentlyIsReadonly }">
<Trix
name="trixman"
:value="value"
@change="handleChange"
@file-added="handleFileAdded"
@file-removed="handleFileRemoved"
:class="{ 'form-control-bordered-error': hasError }"
:with-files="currentField.withFiles"
v-bind="currentField.extraAttributes"
:disabled="currentlyIsReadonly"
class="rounded-lg"
/>
</div>
</template>
</DefaultField>
</template>
<script>
import {
DependentFormField,
HandlesFieldAttachments,
HandlesValidationErrors,
} from '@/mixins'
export default {
emits: ['field-changed'],
mixins: [
HandlesValidationErrors,
HandlesFieldAttachments,
DependentFormField,
],
data: () => ({ index: 0 }),
mounted() {
Nova.$on(this.fieldAttributeValueEventName, this.listenToValueChanges)
},
beforeUnmount() {
Nova.$off(this.fieldAttributeValueEventName, this.listenToValueChanges)
this.clearAttachments()
this.clearFilesMarkedForRemoval()
},
methods: {
/**
* Update the field's internal value when it's value changes
*/
handleChange(value) {
this.value = value
this.$emit('field-changed')
},
fill(formData) {
this.fillIfVisible(formData, this.fieldAttribute, this.value || '')
this.fillAttachmentDraftId(formData)
},
/**
* Initiate an attachement upload
*/
handleFileAdded({ attachment }) {
if (attachment.file) {
// Trix provides file if it's an upload
const onCompleted = (path, url) => {
return attachment.setAttributes({
url: url,
href: url,
})
}
const onUploadProgress = progressEvent => {
attachment.setUploadProgress(
Math.round((progressEvent.loaded * 100) / progressEvent.total)
)
}
this.uploadAttachment(attachment.file, {
onCompleted,
onUploadProgress,
})
} else {
// fx 'undo' which restores a previous attachment without a file upload
this.unflagFileForRemoval(attachment.attachment.attributes.values.url)
}
},
handleFileRemoved({ attachment: { attachment } }) {
this.flagFileForRemoval(attachment.attributes.values.url)
},
onSyncedField() {
this.handleChange(this.currentField.value ?? this.value)
this.index++
},
listenToValueChanges(value) {
this.index++
},
},
}
</script>

View File

@@ -0,0 +1,67 @@
<template>
<DefaultField
:field="currentField"
:errors="errors"
:show-help-text="showHelpText"
:full-width-content="fullWidthContent"
>
<template #field>
<input
v-bind="extraAttributes"
class="w-full form-control form-input form-control-bordered"
type="url"
@input="handleChange"
:value="value"
:id="currentField.uniqueKey"
:dusk="field.attribute"
:disabled="currentlyIsReadonly"
:list="`${field.attribute}-list`"
/>
<datalist
v-if="currentField.suggestions && currentField.suggestions.length > 0"
:id="`${field.attribute}-list`"
>
<option
:key="suggestion"
v-for="suggestion in currentField.suggestions"
:value="suggestion"
/>
</datalist>
</template>
</DefaultField>
</template>
<script>
import { DependentFormField, HandlesValidationErrors } from '@/mixins'
export default {
mixins: [HandlesValidationErrors, DependentFormField],
computed: {
defaultAttributes() {
return {
type: this.currentField.type || 'text',
min: this.currentField.min,
max: this.currentField.max,
step: this.currentField.step,
pattern: this.currentField.pattern,
placeholder: this.currentField.placeholder || this.field.name,
class: this.errorClasses,
}
},
extraAttributes() {
const attrs = this.field.extraAttributes
return {
// Leave the default attributes even though we can now specify
// whatever attributes we like because the old number field still
// uses the old field attributes
...this.defaultAttributes,
...attrs,
}
},
},
}
</script>

View File

@@ -0,0 +1,16 @@
<script>
import FileField from '@/fields/Form/FileField'
export default {
extends: FileField,
computed: {
/**
* Determining if the field is a Vapor field.
*/
isVaporField() {
return true
},
},
}
</script>

View File

@@ -0,0 +1,16 @@
<script>
import FileField from '@/fields/Form/FileField'
export default {
extends: FileField,
computed: {
/**
* Determining if the field is a Vapor field.
*/
isVaporField() {
return true
},
},
}
</script>

View 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}`">&mdash;</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>

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

View 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>&mdash;</span>
</span>
</div>
</template>
<script setup>
const props = defineProps({
resource: { type: Object },
resourceName: { type: String },
field: { type: Object },
})
</script>

View File

@@ -0,0 +1,11 @@
<template>
<div :class="`text-${field.textAlign}`">
<IconBoolean :value="field.value" />
</div>
</template>
<script>
export default {
props: ['resourceName', 'field'],
}
</script>

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

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

View 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>&mdash;</p>
</div>
</template>
<script>
import { FieldValue } from '@/mixins'
export default {
mixins: [FieldValue],
props: ['resourceName', 'field'],
}
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div>
<div :class="`text-${field.textAlign}`">
<span v-if="fieldHasValue" class="whitespace-nowrap">
{{ formattedDate }}
</span>
<span v-else>&mdash;</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>

View 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>&mdash;</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>

View 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>&mdash;</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