Последняя версия с сервера прошлого разработчика

This commit is contained in:
2025-07-10 04:35:51 +00:00
commit c731570032
1174 changed files with 134314 additions and 0 deletions

View File

@@ -0,0 +1,396 @@
<template>
<default-field :field="field" :errors="errors" :show-help-text="showHelpText">
<template slot="field">
<div class="flex items-center">
<search-input
v-if="isSearchable && !isLocked && !isReadonly"
:data-testid="`${field.resourceName}-search-input`"
@input="performSearch"
@clear="clearSelection"
@selected="selectResource"
:error="hasError"
:debounce="field.debounce"
:value="selectedResource"
:data="availableResources"
:clearable="field.nullable"
trackBy="value"
class="w-full"
>
<div slot="default" 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>
<div
slot="option"
slot-scope="{ option, selected }"
class="flex items-center"
>
<div v-if="option.avatar" class="mr-3">
<img :src="option.avatar" class="w-8 h-8 rounded-full block" />
</div>
<div>
<div
class="text-sm font-semibold leading-5 text-90"
:class="{ 'text-white': selected }"
>
{{ option.display }}
</div>
<div
v-if="field.withSubtitles"
class="mt-1 text-xs font-semibold leading-5 text-80"
:class="{ 'text-white': selected }"
>
<span v-if="option.subtitle">{{ option.subtitle }}</span>
<span v-else>{{ __('No additional information...') }}</span>
</div>
</div>
</div>
</search-input>
<select-control
v-if="!isSearchable || isLocked || isReadonly"
class="form-control form-select w-full"
:class="{ 'border-danger': hasError }"
:data-testid="`${field.resourceName}-select`"
:dusk="field.attribute"
@change="selectResourceFromSelectControl"
:disabled="isLocked || isReadonly"
:options="availableResources"
:value="selectedResourceId"
:selected="selectedResourceId"
label="display"
>
<option value="" selected :disabled="!field.nullable">
{{ placeholder }}
</option>
</select-control>
<create-relation-button
v-if="canShowNewRelationModal"
@click="openRelationModal"
class="ml-1"
:dusk="`${field.attribute}-inline-create`"
/>
</div>
<portal to="modals" transition="fade-transition">
<create-relation-modal
v-if="relationModalOpen && canShowNewRelationModal"
@set-resource="handleSetResource"
@cancelled-create="closeRelationModal"
:resource-name="field.resourceName"
:resource-id="resourceId"
:via-relationship="viaRelationship"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
width="800"
/>
</portal>
<!-- Trashed State -->
<div v-if="shouldShowTrashed" class="mt-3">
<checkbox-with-label
:dusk="`${field.resourceName}-with-trashed-checkbox`"
:checked="withTrashed"
@input="toggleWithTrashed"
>
{{ __('With Trashed') }}
</checkbox-with-label>
</div>
</template>
</default-field>
</template>
<script>
import _ from 'lodash'
import storage from '@/storage/BelongsToFieldStorage'
import {
FormField,
TogglesTrashed,
PerformsSearches,
HandlesValidationErrors,
} from 'laravel-nova'
export default {
mixins: [
TogglesTrashed,
PerformsSearches,
HandlesValidationErrors,
FormField,
],
props: {
resourceId: {},
},
data: () => ({
availableResources: [],
initializingWithExistingResource: 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.field.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.field.belongsToId
} else if (this.creatingViaRelatedResource) {
// 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 && !this.isSearchable) {
// 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.shouldSelectInitialResource && this.isSearchable) {
// 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 (!this.shouldSelectInitialResource && !this.isSearchable) {
// 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(e) {
this.selectedResourceId = e.target.value
this.selectInitialResource()
if (this.field) {
Nova.$emit(this.field.attribute + '-change', this.selectedResourceId)
}
},
/**
* Fill the forms formData with details from this field
*/
fill(formData) {
formData.append(
this.field.attribute,
this.selectedResource ? this.selectedResource.value : ''
)
formData.append(this.field.attribute + '_trashed', this.withTrashed)
},
/**
* Get the resources that may be related to this resource.
*/
getAvailableResources() {
return storage
.fetchAvailableResources(
this.resourceName,
this.field.attribute,
this.queryParams
)
.then(({ data: { resources, softDeletes, withTrashed } }) => {
if (this.initializingWithExistingResource || !this.isSearchable) {
this.withTrashed = withTrashed
}
// Turn off initializing the existing resource after the first time
this.initializingWithExistingResource = false
this.availableResources = resources
this.softDeletes = softDeletes
})
},
/**
* 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 => r.value == this.selectedResourceId
)
},
/**
* Toggle the trashed state of the search
*/
toggleWithTrashed() {
this.withTrashed = !this.withTrashed
// Reload the data if the component doesn't support searching
if (!this.isSearchable) {
this.getAvailableResources()
}
},
openRelationModal() {
this.relationModalOpen = true
},
closeRelationModal() {
this.relationModalOpen = false
},
handleSetResource({ id }) {
this.closeRelationModal()
this.selectedResourceId = id
this.initializingWithExistingResource = true
this.getAvailableResources().then(() => this.selectInitialResource())
},
},
computed: {
/**
* Determine if we are editing and existing resource
*/
editingExistingResource() {
return Boolean(this.field.belongsToId)
},
/**
* Determine if we are creating a new resource via a parent relation
*/
creatingViaRelatedResource() {
return (
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.creatingViaRelatedResource ||
this.field.value
)
},
/**
* Determine if the related resources is searchable
*/
isSearchable() {
return this.field.searchable
},
/**
* Get the query params for getting available resources
*/
queryParams() {
return {
params: {
current: this.selectedResourceId,
first: this.initializingWithExistingResource,
search: this.search,
withTrashed: this.withTrashed,
resourceId: this.resourceId,
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
viaRelationship: this.viaRelationship,
},
}
},
isLocked() {
return this.viaResource == this.field.resourceName && this.field.reverse
},
isReadonly() {
return (
this.field.readonly || _.get(this.field, 'extraAttributes.readonly')
)
},
shouldShowTrashed() {
return (
this.softDeletes &&
!this.isLocked &&
!this.isReadonly &&
this.field.displaysWithTrashed
)
},
authorizedToCreate() {
return _.find(Nova.config.resources, resource => {
return resource.uriKey == this.field.resourceName
}).authorizedToCreate
},
canShowNewRelationModal() {
return (
this.field.showCreateRelationButton &&
!this.shownViaNewRelationModal &&
!this.isLocked &&
!this.isReadonly &&
this.authorizedToCreate
)
},
/**
* Return the placeholder text for the field.
*/
placeholder() {
return this.field.placeholder || this.__('—')
},
},
}
</script>

View File

@@ -0,0 +1,50 @@
<template>
<default-field :field="field" :errors="errors" :show-help-text="showHelpText">
<template slot="field">
<checkbox
class="mt-2"
@input="toggle"
:id="field.attribute"
:name="field.name"
:checked="checked"
:disabled="isReadonly"
/>
</template>
</default-field>
</template>
<script>
import { FormField, HandlesValidationErrors } from 'laravel-nova'
export default {
mixins: [HandlesValidationErrors, FormField],
data: () => ({
value: false,
}),
mounted() {
this.value = this.field.value || false
this.field.fill = formData => {
formData.append(this.field.attribute, this.trueValue)
}
},
methods: {
toggle() {
this.value = !this.value
},
},
computed: {
checked() {
return Boolean(this.value)
},
trueValue() {
return +this.checked
},
},
}
</script>

View File

@@ -0,0 +1,76 @@
<template>
<default-field :field="field" :errors="errors" :show-help-text="showHelpText">
<template slot="field">
<checkbox-with-label
class="mt-2"
v-for="option in value"
:key="option.name"
:name="option.name"
:checked="option.checked"
@input="toggle($event, option)"
:disabled="isReadonly"
>
{{ option.label }}
</checkbox-with-label>
</template>
</default-field>
</template>
<script>
import { FormField, HandlesValidationErrors } from 'laravel-nova'
export default {
mixins: [HandlesValidationErrors, FormField],
data: () => ({
value: [],
}),
methods: {
/*
* Set the initial value for the field
*/
setInitialValue() {
this.field.value = this.field.value || {}
this.value = _(this.field.options)
.map(o => {
return {
name: o.name,
label: o.label,
checked: this.field.value[o.name] || false,
}
})
.value()
},
/**
* Provide a function that fills a passed FormData object with the
* field's internal value attribute.
*/
fill(formData) {
formData.append(this.field.attribute, JSON.stringify(this.finalPayload))
},
/**
* Toggle the option's value.
*/
toggle(event, option) {
const firstOption = _(this.value).find(o => o.name == option.name)
firstOption.checked = event.target.checked
},
},
computed: {
/**
* Return the final filtered json object
*/
finalPayload() {
return _(this.value)
.map(o => [o.name, o.checked])
.fromPairs()
.value()
},
},
}
</script>

View File

@@ -0,0 +1,15 @@
<template>
<a
@click="$emit('click')"
tabindex="0"
class="btn btn-link dim cursor-pointer text-80 ml-auto mr-6"
>
{{ __('Cancel') }}
</a>
</template>
<script>
export default {
//
}
</script>

View File

@@ -0,0 +1,141 @@
<template>
<default-field
:field="field"
:errors="errors"
:full-width-content="true"
:show-help-text="showHelpText"
>
<template slot="field">
<div class="form-input form-input-bordered px-0 overflow-hidden">
<textarea ref="theTextarea" />
</div>
</template>
</default-field>
</template>
<style src="codemirror/lib/codemirror.css" />
<style src="codemirror/theme/3024-day.css" />
<style src="codemirror/theme/3024-night.css" />
<style src="codemirror/theme/abcdef.css" />
<style src="codemirror/theme/ambiance-mobile.css" />
<style src="codemirror/theme/ambiance.css" />
<style src="codemirror/theme/base16-dark.css" />
<style src="codemirror/theme/base16-light.css" />
<style src="codemirror/theme/bespin.css" />
<style src="codemirror/theme/blackboard.css" />
<style src="codemirror/theme/cobalt.css" />
<style src="codemirror/theme/colorforth.css" />
<style src="codemirror/theme/darcula.css" />
<style src="codemirror/theme/dracula.css" />
<style src="codemirror/theme/duotone-dark.css" />
<style src="codemirror/theme/duotone-light.css" />
<style src="codemirror/theme/eclipse.css" />
<style src="codemirror/theme/elegant.css" />
<style src="codemirror/theme/erlang-dark.css" />
<style src="codemirror/theme/gruvbox-dark.css" />
<style src="codemirror/theme/hopscotch.css" />
<style src="codemirror/theme/icecoder.css" />
<style src="codemirror/theme/idea.css" />
<style src="codemirror/theme/isotope.css" />
<style src="codemirror/theme/lesser-dark.css" />
<style src="codemirror/theme/liquibyte.css" />
<style src="codemirror/theme/lucario.css" />
<style src="codemirror/theme/material.css" />
<style src="codemirror/theme/mbo.css" />
<style src="codemirror/theme/mdn-like.css" />
<style src="codemirror/theme/midnight.css" />
<style src="codemirror/theme/monokai.css" />
<style src="codemirror/theme/neat.css" />
<style src="codemirror/theme/neo.css" />
<style src="codemirror/theme/night.css" />
<style src="codemirror/theme/oceanic-next.css" />
<style src="codemirror/theme/panda-syntax.css" />
<style src="codemirror/theme/paraiso-dark.css" />
<style src="codemirror/theme/paraiso-light.css" />
<style src="codemirror/theme/pastel-on-dark.css" />
<style src="codemirror/theme/railscasts.css" />
<style src="codemirror/theme/rubyblue.css" />
<style src="codemirror/theme/seti.css" />
<style src="codemirror/theme/shadowfox.css" />
<style src="codemirror/theme/solarized.css" />
<style src="codemirror/theme/ssms.css" />
<style src="codemirror/theme/the-matrix.css" />
<style src="codemirror/theme/tomorrow-night-bright.css" />
<style src="codemirror/theme/tomorrow-night-eighties.css" />
<style src="codemirror/theme/ttcn.css" />
<style src="codemirror/theme/twilight.css" />
<style src="codemirror/theme/vibrant-ink.css" />
<style src="codemirror/theme/xq-dark.css" />
<style src="codemirror/theme/xq-light.css" />
<style src="codemirror/theme/yeti.css" />
<style src="codemirror/theme/zenburn.css" />
<script>
import CodeMirror from 'codemirror'
// Modes
import 'codemirror/mode/markdown/markdown'
import 'codemirror/mode/javascript/javascript'
import 'codemirror/mode/php/php'
import 'codemirror/mode/ruby/ruby'
import 'codemirror/mode/shell/shell'
import 'codemirror/mode/sass/sass'
import 'codemirror/mode/yaml/yaml'
import 'codemirror/mode/yaml-frontmatter/yaml-frontmatter'
import 'codemirror/mode/nginx/nginx'
import 'codemirror/mode/xml/xml'
import 'codemirror/mode/vue/vue'
import 'codemirror/mode/dockerfile/dockerfile'
import 'codemirror/keymap/vim'
import 'codemirror/mode/sql/sql'
import 'codemirror/mode/twig/twig'
import 'codemirror/mode/htmlmixed/htmlmixed'
CodeMirror.defineMode('htmltwig', function (config, parserConfig) {
return CodeMirror.overlayMode(
CodeMirror.getMode(config, parserConfig.backdrop || 'text/html'),
CodeMirror.getMode(config, 'twig')
)
})
import { FormField, HandlesValidationErrors } from 'laravel-nova'
export default {
mixins: [HandlesValidationErrors, FormField],
data: () => ({ codemirror: null }),
/**
* Mount the component.
*/
mounted() {
const config = {
...{
tabSize: 4,
indentWithTabs: true,
lineWrapping: true,
lineNumbers: true,
theme: 'dracula',
...{ readOnly: this.isReadonly },
},
...this.field.options,
}
this.codemirror = CodeMirror.fromTextArea(this.$refs.theTextarea, config)
this.doc.on('change', (cm, changeObj) => {
this.value = cm.getValue()
})
this.doc.setValue(this.field.value)
this.codemirror.setSize('100%', this.field.height)
},
computed: {
doc() {
return this.codemirror.getDoc()
},
},
}
</script>

View File

@@ -0,0 +1,16 @@
<template>
<button
@click="$emit('click')"
type="button"
class="rounded dim font-bold text-sm text-primary inline-flex items-center focus:outline-none active:outline-none focus:shadow-outline active:shadow-outline pl-1 pr-2"
>
<icon type="add" width="24" height="24" view-box="0 0 24 24" />
<span>{{ __('Create') }}</span>
</button>
</template>
<script>
export default {
//
}
</script>

View File

@@ -0,0 +1,60 @@
<template>
<default-field :field="field" :errors="errors" :show-help-text="showHelpText">
<template slot="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-60 px-3 whitespace-no-wrap bg-30 text-80 text-sm font-bold"
>
{{ field.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-input-bordered"
:id="field.attribute"
:dusk="field.attribute"
v-bind="extraAttributes"
:disabled="isReadonly"
@input="handleChange"
:value="value"
/>
</div>
</template>
</default-field>
</template>
<script>
import { FormField, HandlesValidationErrors } from 'laravel-nova'
export default {
mixins: [FormField, HandlesValidationErrors],
props: ['resourceName', 'resourceId', 'field'],
computed: {
defaultAttributes() {
return {
type: 'number',
min: this.field.min,
max: this.field.max,
step: this.field.step,
pattern: this.field.pattern,
placeholder: this.field.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,82 @@
<template>
<default-field :field="field" :errors="errors" :show-help-text="showHelpText">
<template slot="field">
<div class="flex items-center">
<date-time-picker
class="w-full form-control form-input form-input-bordered"
ref="dateTimePicker"
:dusk="field.attribute"
:name="field.name"
:value="value"
:dateFormat="pickerFormat"
:alt-format="pickerDisplayFormat"
:placeholder="placeholder"
:enable-time="false"
:enable-seconds="false"
:first-day-of-week="firstDayOfWeek"
:class="errorClasses"
@change="handleChange"
:disabled="isReadonly"
/>
<a
v-if="field.nullable"
@click.prevent="$refs.dateTimePicker.clear()"
href="#"
:title="__('Clear value')"
tabindex="-1"
class="p-1 px-2 cursor-pointer leading-none focus:outline-none"
:class="{
'text-50': !value.length,
'text-black hover:text-danger': value.length,
}"
>
<icon type="x-circle" width="22" height="22" viewBox="0 0 22 22" />
</a>
</div>
</template>
</default-field>
</template>
<script>
import {
FormField,
HandlesValidationErrors,
InteractsWithDates,
} from 'laravel-nova'
export default {
mixins: [HandlesValidationErrors, FormField, InteractsWithDates],
methods: {
/**
* Update the field's internal value when it's value changes
*/
handleChange(value) {
this.value = value
},
},
computed: {
firstDayOfWeek() {
return this.field.firstDayOfWeek || 0
},
placeholder() {
return this.field.placeholder || moment().format(this.format)
},
format() {
return this.field.format || 'YYYY-MM-DD'
},
pickerFormat() {
return this.field.pickerFormat || 'Y-m-d'
},
pickerDisplayFormat() {
return this.field.pickerDisplayFormat || 'Y-m-d'
},
},
}
</script>

View File

@@ -0,0 +1,106 @@
<template>
<default-field :field="field" :errors="errors" :show-help-text="showHelpText">
<template slot="field">
<div class="flex items-center">
<date-time-picker
class="w-full form-control form-input form-input-bordered"
ref="dateTimePicker"
:dusk="field.attribute"
:name="field.name"
:placeholder="placeholder"
:dateFormat="pickerFormat"
:alt-format="pickerDisplayFormat"
:value="localizedValue"
:twelve-hour-time="usesTwelveHourTime"
:first-day-of-week="firstDayOfWeek"
:class="errorClasses"
@change="handleChange"
:disabled="isReadonly"
/>
<a
v-if="field.nullable"
@click.prevent="$refs.dateTimePicker.clear()"
href="#"
:title="__('Clear value')"
tabindex="-1"
class="p-1 px-2 cursor-pointer leading-none focus:outline-none"
:class="{
'text-50': !value.length,
'text-black hover:text-danger': value.length,
}"
>
<icon type="x-circle" width="22" height="22" viewBox="0 0 22 22" />
</a>
<span class="text-80 text-sm ml-2">({{ userTimezone }})</span>
</div>
</template>
</default-field>
</template>
<script>
import {
FormField,
HandlesValidationErrors,
InteractsWithDates,
} from 'laravel-nova'
export default {
mixins: [HandlesValidationErrors, FormField, InteractsWithDates],
data: () => ({ localizedValue: '' }),
methods: {
/*
* Set the initial value for the field
*/
setInitialValue() {
// Set the initial value of the field
this.value = this.field.value || ''
// If the field has a value let's convert it from the app's timezone
// into the user's local time to display in the field
if (this.value !== '') {
this.localizedValue = this.fromAppTimezone(this.value)
}
},
/**
* On save, populate our form data
*/
fill(formData) {
formData.append(this.field.attribute, this.value || '')
},
/**
* Update the field's internal value when it's value changes
*/
handleChange(value) {
this.value = this.toAppTimezone(value)
},
},
computed: {
firstDayOfWeek() {
return this.field.firstDayOfWeek || 0
},
format() {
return this.field.format || 'YYYY-MM-DD HH:mm:ss'
},
placeholder() {
return this.field.placeholder || moment().format(this.format)
},
pickerFormat() {
return this.field.pickerFormat || 'Y-m-d H:i:S'
},
pickerDisplayFormat() {
return this.field.pickerDisplayFormat || 'Y-m-d H:i:S'
},
},
}
</script>

View File

@@ -0,0 +1,74 @@
<template>
<field-wrapper :stacked="field.stacked">
<div class="px-8" :class="field.stacked ? 'pt-6 w-full' : 'py-6 w-1/5'">
<slot>
<form-label
:label-for="field.attribute"
:class="{ 'mb-2': showHelpText && field.helpText }"
>
{{ fieldLabel }}&nbsp;<span
v-if="field.required"
class="text-danger text-sm"
>{{ __('*') }}</span
>
</form-label>
</slot>
</div>
<div class="py-6 px-8" :class="fieldClasses">
<slot name="field" />
<help-text
class="error-text mt-2 text-danger"
v-if="showErrors && hasError"
>
{{ firstError }}
</help-text>
<help-text class="help-text mt-2" v-if="showHelpText">
{{ field.helpText }}
</help-text>
</div>
</field-wrapper>
</template>
<script>
import { HandlesValidationErrors, mapProps } from 'laravel-nova'
export default {
mixins: [HandlesValidationErrors],
props: {
field: { type: Object, required: true },
fieldName: { type: String },
showErrors: { type: Boolean, default: true },
fullWidthContent: { type: Boolean, default: false },
...mapProps(['showHelpText']),
},
computed: {
/**
* Return the label that should be used for the field.
*/
fieldLabel() {
// If the field name is purposefully an empty string, then let's show it as such
if (this.fieldName === '') {
return ''
}
return this.fieldName || this.field.name || this.field.singularLabel
},
/**
* Return the classes that should be used for the field content.
*/
fieldClasses() {
return this.fullWidthContent
? this.field.stacked
? 'w-full'
: 'w-4/5'
: 'w-1/2'
},
},
}
</script>

View File

@@ -0,0 +1,12 @@
<template functional>
<div class="flex border-b border-40" :class="{ 'flex-col': props.stacked }">
<slot />
</div>
</template>
<script>
export default {
props: {
stacked: { type: Boolean, default: false },
},
}
</script>

View File

@@ -0,0 +1,339 @@
<template>
<default-field
:field="field"
:errors="errors"
:full-width-content="true"
:show-help-text="!isReadonly && showHelpText"
>
<template slot="field">
<div v-if="hasValue" :class="{ 'mb-6': !isReadonly }">
<template v-if="shouldShowLoader">
<ImageLoader
:src="imageUrl"
:maxWidth="maxWidth"
:rounded="field.rounded"
@missing="value => (missing = value)"
/>
</template>
<template v-if="field.value && !imageUrl">
<card
class="flex item-center relative border border-lg border-50 overflow-hidden p-4"
>
<span class="truncate mr-3"> {{ field.value }} </span>
<DeleteButton
:dusk="field.attribute + '-internal-delete-link'"
class="ml-auto"
v-if="shouldShowRemoveButton"
@click="confirmRemoval"
/>
</card>
</template>
<p
v-if="imageUrl && !isReadonly"
class="mt-3 flex items-center text-sm"
>
<DeleteButton
:dusk="field.attribute + '-delete-link'"
v-if="shouldShowRemoveButton"
@click="confirmRemoval"
>
<span class="class ml-2 mt-1"> {{ __('Delete') }} </span>
</DeleteButton>
</p>
<portal to="modals">
<confirm-upload-removal-modal
v-if="removeModalOpen"
@confirm="removeFile"
@close="closeRemoveModal"
/>
</portal>
</div>
<p v-if="!hasValue && isReadonly" class="pt-2 text-sm text-90">
{{ __('This file field is read-only.') }}
</p>
<span
v-if="shouldShowField"
class="form-file mr-4"
:class="{ 'opacity-75': isReadonly }"
>
<input
ref="fileField"
:dusk="field.attribute"
class="form-file-input select-none"
type="file"
:id="idAttr"
name="name"
@change="fileChange"
:disabled="isReadonly || uploading"
:accept="field.acceptedTypes"
/>
<label
:for="labelFor"
class="form-file-btn btn btn-default btn-primary select-none"
>
<span v-if="uploading"
>{{ __('Uploading') }} ({{ uploadProgress }}%)</span
>
<span v-else>{{ __('Choose File') }}</span>
</label>
</span>
<span v-if="shouldShowField" class="text-90 text-sm select-none">
{{ currentLabel }}
</span>
<p v-if="hasError" class="text-xs mt-2 text-danger">{{ firstError }}</p>
</template>
</default-field>
</template>
<script>
import ImageLoader from '@/components/ImageLoader'
import DeleteButton from '@/components/DeleteButton'
import { FormField, HandlesValidationErrors, Errors } from 'laravel-nova'
import Vapor from 'laravel-vapor'
export default {
props: [
'resourceId',
'relatedResourceName',
'relatedResourceId',
'viaRelationship',
],
mixins: [HandlesValidationErrors, FormField],
components: { DeleteButton, ImageLoader },
data: () => ({
file: null,
fileName: '',
removeModalOpen: false,
missing: false,
deleted: false,
uploadErrors: new Errors(),
vaporFile: {
key: '',
uuid: '',
filename: '',
extension: '',
},
uploading: false,
uploadProgress: 0,
}),
mounted() {
this.field.fill = formData => {
let attribute = this.field.attribute
if (this.file && !this.isVaporField) {
formData.append(attribute, this.file, this.fileName)
}
if (this.file && this.isVaporField) {
formData.append(attribute, this.fileName)
formData.append('vaporFile[' + attribute + '][key]', this.vaporFile.key)
formData.append(
'vaporFile[' + attribute + '][uuid]',
this.vaporFile.uuid
)
formData.append(
'vaporFile[' + attribute + '][filename]',
this.vaporFile.filename
)
formData.append(
'vaporFile[' + attribute + '][extension]',
this.vaporFile.extension
)
}
}
},
methods: {
/**
* Respond to the file change
*/
fileChange(event) {
let path = event.target.value
let fileName = path.match(/[^\\/]*$/)[0]
this.fileName = fileName
let extension = fileName.split('.').pop()
this.file = this.$refs.fileField.files[0]
if (this.isVaporField) {
this.uploading = true
this.$emit('file-upload-started')
Vapor.store(this.$refs.fileField.files[0], {
progress: progress => {
this.uploadProgress = Math.round(progress * 100)
},
}).then(response => {
this.vaporFile.key = response.key
this.vaporFile.uuid = response.uuid
this.vaporFile.filename = fileName
this.vaporFile.extension = extension
this.uploading = false
this.uploadProgress = 0
this.$emit('file-upload-finished')
})
}
},
/**
* Confirm removal of the linked file
*/
confirmRemoval() {
this.removeModalOpen = true
},
/**
* Close the upload removal modal
*/
closeRemoveModal() {
this.removeModalOpen = false
},
/**
* Remove the linked file from storage
*/
async removeFile() {
this.uploadErrors = new Errors()
const {
resourceName,
resourceId,
relatedResourceName,
relatedResourceId,
viaRelationship,
} = this
const attribute = this.field.attribute
const uri =
this.viaRelationship &&
this.relatedResourceName &&
this.relatedResourceId
? `/nova-api/${resourceName}/${resourceId}/${relatedResourceName}/${relatedResourceId}/field/${attribute}?viaRelationship=${viaRelationship}`
: `/nova-api/${resourceName}/${resourceId}/field/${attribute}`
try {
await Nova.request().delete(uri)
this.closeRemoveModal()
this.deleted = true
this.$emit('file-deleted')
Nova.success(this.__('The file was deleted!'))
} catch (error) {
this.closeRemoveModal()
if (error.response.status == 422) {
this.uploadErrors = new Errors(error.response.data.errors)
}
}
},
},
computed: {
/**
* 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 current label of the file field.
*/
currentLabel() {
return this.fileName || this.__('no file selected')
},
/**
* 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.field.attribute}`
},
/**
* 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.isReadonly)
},
/**
* Determine whether the field should show the remove button.
*/
shouldShowRemoveButton() {
return Boolean(this.field.deletable && !this.isReadonly)
},
/**
* Return the preview or thumbnail URL for the field.
*/
imageUrl() {
return this.field.previewUrl || this.field.thumbnailUrl
},
/**
* Determine the maximum width of the field.
*/
maxWidth() {
return this.field.maxWidth || 320
},
/**
* Determing if the field is a Vapor field.
*/
isVaporField() {
return this.field.component == 'vapor-file-field'
},
},
}
</script>

View File

@@ -0,0 +1,42 @@
<template>
<field-wrapper>
<div v-if="shouldDisplayAsHtml" v-html="field.value" :class="classes" />
<div v-else :class="classes">
<p>{{ field.value }}</p>
</div>
</field-wrapper>
</template>
<script>
export default {
props: {
resourceName: {
type: String,
require: true,
},
field: {
type: Object,
require: true,
},
},
created() {
this.field.fill = () => {}
},
computed: {
classes: () => [
'bg-20',
'remove-last-margin-bottom',
'leading-normal',
'w-full',
'py-4',
'px-8',
],
shouldDisplayAsHtml() {
return this.field.asHtml || false
},
},
}
</script>

View File

@@ -0,0 +1,7 @@
<template>
<div class="help-text" v-html="$slots.default[0].text" />
</template>
<script>
export default {}
</script>

View File

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

View File

@@ -0,0 +1,155 @@
<template>
<default-field
:field="field"
:errors="errors"
:full-width-content="true"
:show-help-text="showHelpText"
>
<template slot="field">
<KeyValueTable
:edit-mode="!field.readonly"
:can-delete-row="field.canDeleteRow"
>
<KeyValueHeader
:key-label="field.keyLabel"
:value-label="field.valueLabel"
/>
<div class="bg-white overflow-hidden key-value-items">
<KeyValueItem
v-for="(item, index) in theData"
:index="index"
@remove-row="removeRow"
:item.sync="item"
:key="item.id"
:ref="item.id"
:read-only="field.readonly"
:read-only-keys="field.readonlyKeys"
:can-delete-row="field.canDeleteRow"
/>
</div>
</KeyValueTable>
<div
class="mr-11"
v-if="!field.readonly && !field.readonlyKeys && field.canAddRow"
>
<button
@click="addRowAndSelect"
:dusk="`${field.attribute}-add-key-value`"
type="button"
class="btn btn-link dim cursor-pointer rounded-lg mx-auto text-primary mt-3 px-3 rounded-b-lg flex items-center"
>
<icon type="add" width="24" height="24" view-box="0 0 24 24" />
<span class="ml-1">{{ field.actionText }}</span>
</button>
</div>
</template>
</default-field>
</template>
<script>
import { FormField, HandlesValidationErrors } from 'laravel-nova'
import KeyValueItem from '@/components/Form/KeyValueField/KeyValueItem'
import KeyValueHeader from '@/components/Form/KeyValueField/KeyValueHeader'
import KeyValueTable from '@/components/Form/KeyValueField/KeyValueTable'
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, FormField],
components: { KeyValueTable, KeyValueHeader, KeyValueItem },
data: () => ({ theData: [] }),
mounted() {
this.theData = _.map(this.value || {}, (value, key) => ({
id: guid(),
key: `${key}`,
value,
}))
if (this.theData.length == 0) {
this.addRow()
}
},
methods: {
/**
* Provide a function that fills a passed FormData object with the
* field's internal value attribute.
*/
fill(formData) {
formData.append(this.field.attribute, 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].$refs.keyField.select()
})
},
},
computed: {
/**
* Return the final filtered json object
*/
finalPayload() {
return _(this.theData)
.map(row => (row && row.key ? [row.key, row.value] : undefined))
.reject(row => row === undefined)
.fromPairs()
.value()
},
},
}
</script>

View File

@@ -0,0 +1,28 @@
<template>
<div class="bg-30 rounded-t-lg flex border-b border-50">
<div
class="bg-clip w-48 uppercase font-bold text-xs text-80 tracking-wide px-3 py-3"
>
{{ keyLabel }}
</div>
<div
class="bg-clip flex-grow uppercase font-bold text-xs text-80 tracking-wide px-3 py-3 border-l border-50"
>
{{ valueLabel }}
</div>
</div>
</template>
<script>
export default {
props: {
keyLabel: {
type: String,
},
valueLabel: {
type: String,
},
},
}
</script>

View File

@@ -0,0 +1,109 @@
<template>
<div v-if="isNotObject" class="flex items-center key-value-item">
<div class="flex flex-grow border-b border-50 key-value-fields">
<div
class="w-48 cursor-text"
:class="{ 'bg-30': readOnlyKeys || !isEditable }"
>
<textarea
:dusk="`key-value-key-${index}`"
v-model="item.key"
@focus="handleKeyFieldFocus"
ref="keyField"
type="text"
class="font-mono text-sm resize-none block min-h-input w-full form-control form-input form-input-row py-4 text-90"
:disabled="!isEditable || readOnlyKeys"
style="background-clip: border-box"
:class="{
'bg-white': !isEditable || readOnlyKeys,
'hover:bg-20 focus:bg-white': isEditable && !readOnlyKeys,
}"
/>
</div>
<div @click="handleValueFieldFocus" class="flex-grow border-l border-50">
<textarea
:dusk="`key-value-value-${index}`"
v-model="item.value"
@focus="handleValueFieldFocus"
ref="valueField"
type="text"
class="font-mono text-sm block min-h-input w-full form-control form-input form-input-row py-4 text-90"
:disabled="!isEditable"
:class="{
'bg-white': !isEditable,
'hover:bg-20 focus:bg-white': isEditable,
}"
/>
</div>
</div>
<div
v-if="isEditable && canDeleteRow"
class="flex justify-center h-11 w-11 absolute"
style="right: -50px"
>
<button
@click="$emit('remove-row', item.id)"
:dusk="`remove-key-value-${index}`"
type="button"
tabindex="-1"
class="flex appearance-none cursor-pointer text-70 hover:text-primary active:outline-none active:shadow-outline focus:outline-none focus:shadow-outline"
title="Delete"
>
<icon />
</button>
</div>
</div>
</template>
<script>
import autosize from 'autosize'
export default {
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-30 bg-clip border border-60"
: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,15 @@
<template>
<label :for="labelFor" class="inline-block text-80 pt-2 leading-tight">
<slot />
</label>
</template>
<script>
export default {
props: {
labelFor: {
type: String,
},
},
}
</script>

View File

@@ -0,0 +1,382 @@
<template>
<default-field
:field="field"
:errors="errors"
:full-width-content="true"
:show-help-text="showHelpText"
>
<template slot="field">
<div
class="bg-white rounded-lg overflow-hidden"
:class="{
'markdown-fullscreen fixed pin z-50': isFullScreen,
'form-input form-input-bordered px-0': !isFullScreen,
'form-control-focus': isFocused,
'border-danger': errors.has('body'),
}"
>
<header
class="flex items-center content-center justify-between border-b border-60"
:class="{ 'bg-30': isReadonly }"
>
<ul class="w-full flex items-center content-center list-reset">
<button
:class="{
'text-primary font-bold': this.mode == 'write',
}"
@click.prevent="write"
class="ml-1 text-90 px-3 py-2"
>
{{ __('Write') }}
</button>
<button
:class="{
'text-primary font-bold': this.mode == 'preview',
}"
@click.prevent="preview"
class="text-90 px-3 py-2"
>
{{ __('Preview') }}
</button>
</ul>
<ul v-if="!isReadonly" class="flex items-center list-reset">
<button
:key="tool.action"
@click.prevent="callAction(tool.action)"
v-for="tool in tools"
class="rounded-none ico-button inline-flex items-center justify-center px-2 text-sm text-80 border-l border-60"
>
<component
:is="tool.icon"
class="fill-80 w-editor-icon h-editor-icon"
/>
</button>
</ul>
</header>
<div
v-show="mode == 'write'"
class="flex markdown-content relative p-4"
:class="{ 'readonly bg-30': isReadonly }"
>
<textarea ref="theTextarea" :class="{ 'bg-30': isReadonly }" />
</div>
<div
v-if="mode == 'preview'"
class="markdown overflow-scroll p-4"
v-html="previewContent"
></div>
</div>
</template>
</default-field>
</template>
<script>
import _ from 'lodash'
const md = require('markdown-it')()
import CodeMirror from 'codemirror'
import 'codemirror/mode/markdown/markdown'
import { FormField, HandlesValidationErrors } from 'laravel-nova'
const actions = {
bold() {
if (!this.isEditable) return
this.insertAround('**', '**')
},
italicize() {
if (!this.isEditable) return
this.insertAround('*', '*')
},
image() {
if (!this.isEditable) return
this.insertBefore('![](url)', 2)
},
link() {
if (!this.isEditable) return
this.insertAround('[', '](url)')
},
toggleFullScreen() {
this.fullScreen = !this.fullScreen
this.$nextTick(() => this.codemirror.refresh())
},
fullScreen() {
this.fullScreen = true
},
exitFullScreen() {
this.fullScreen = false
},
}
const keyMaps = {
'Cmd-B': 'bold',
'Cmd-I': 'italicize',
'Cmd-Alt-I': 'image',
'Cmd-K': 'link',
F11: 'fullScreen',
Esc: 'exitFullScreen',
}
export default {
mixins: [HandlesValidationErrors, FormField],
data: () => ({
fullScreen: false,
isFocused: false,
codemirror: null,
mode: 'write',
tools: [
{
name: 'bold',
action: 'bold',
className: 'fa fa-bold',
icon: 'editor-bold',
},
{
name: 'italicize',
action: 'italicize',
className: 'fa fa-italic',
icon: 'editor-italic',
},
{
name: 'link',
action: 'link',
className: 'fa fa-link',
icon: 'editor-link',
},
{
name: 'image',
action: 'image',
className: 'fa fa-image',
icon: 'editor-image',
},
{
name: 'fullScreen',
action: 'toggleFullScreen',
className: 'fa fa-expand',
icon: 'editor-fullscreen',
},
],
}),
mounted() {
this.codemirror = CodeMirror.fromTextArea(this.$refs.theTextarea, {
tabSize: 4,
indentWithTabs: true,
lineWrapping: true,
mode: 'markdown',
viewportMargin: Infinity,
extraKeys: {
Enter: 'newlineAndIndentContinueMarkdownList',
..._.map(this.tools, tool => {
return tool.action
}),
},
...{ readOnly: this.isReadonly },
})
_.each(keyMaps, (action, map) => {
const realMap = map.replace(
'Cmd-',
CodeMirror.keyMap['default'] == CodeMirror.keyMap.macDefault
? 'Cmd-'
: 'Ctrl-'
)
this.codemirror.options.extraKeys[realMap] = actions[keyMaps[map]].bind(
this
)
})
this.doc.on('change', (cm, changeObj) => {
this.value = cm.getValue()
})
this.codemirror.on('focus', () => (this.isFocused = true))
this.codemirror.on('blur', () => (this.isFocused = false))
if (this.field.value) {
this.doc.setValue(this.field.value)
}
Nova.$on(this.field.attribute + '-value', value => {
this.doc.setValue(value)
this.$nextTick(() => this.codemirror.refresh())
})
this.$nextTick(() => this.codemirror.refresh())
},
methods: {
focus() {
this.codemirror.focus()
},
write() {
this.mode = 'write'
this.$nextTick(() => {
this.codemirror.refresh()
})
},
preview() {
this.mode = 'preview'
},
insert(insertion) {
this.doc.replaceRange(insertion, {
line: this.cursor.line,
ch: this.cursor.ch,
})
},
insertAround(start, end) {
if (this.doc.somethingSelected()) {
const selection = this.doc.getSelection()
this.doc.replaceSelection(start + selection + end)
} else {
this.doc.replaceRange(start + end, {
line: this.cursor.line,
ch: this.cursor.ch,
})
this.doc.setCursor({
line: this.cursor.line,
ch: this.cursor.ch - end.length,
})
}
},
insertBefore(insertion, cursorOffset) {
if (this.doc.somethingSelected()) {
const selects = this.doc.listSelections()
selects.forEach(selection => {
const pos = [selection.head.line, selection.anchor.line].sort()
for (let i = pos[0]; i <= pos[1]; i++) {
this.doc.replaceRange(insertion, { line: i, ch: 0 })
}
this.doc.setCursor({ line: pos[0], ch: cursorOffset || 0 })
})
} else {
this.doc.replaceRange(insertion, {
line: this.cursor.line,
ch: 0,
})
this.doc.setCursor({
line: this.cursor.line,
ch: cursorOffset || 0,
})
}
},
callAction(action) {
if (!this.isReadonly) {
this.focus()
actions[action].call(this)
}
},
},
computed: {
doc() {
return this.codemirror.getDoc()
},
isFullScreen() {
return this.fullScreen == true
},
cursor() {
return this.doc.getCursor()
},
rawContent() {
return this.codemirror.getValue()
},
previewContent() {
return md.render(this.rawContent || '')
},
isEditable() {
return !this.isReadonly && this.mode == 'write'
},
},
}
</script>
<style src="codemirror/lib/codemirror.css" />
<style>
.ico-button {
width: 35px;
height: 35px;
}
.ico-button:hover {
color: var(--primary);
}
.ico-button:active {
color: var(--brand-80);
}
.cm-fat-cursor .CodeMirror-cursor {
background: #000;
}
.cm-s-default .cm-header {
color: black;
}
.cm-s-default .cm-link {
color: var(--primary);
}
.CodeMirror-line {
color: var(--gray-60);
}
.cm-s-default .cm-variable-2 {
color: var(--gray-60);
}
.cm-s-default .cm-quote {
color: var(--gray-60);
}
.cm-s-default .cm-comment {
color: var(--gray-60);
}
.cm-s-default .cm-string {
color: var(--gray-40);
}
.cm-s-default .cm-url {
color: var(--gray-40);
}
.CodeMirror {
height: auto;
font: 14px/1.5 Menlo, Consolas, Monaco, 'Andale Mono', monospace;
box-sizing: border-box;
width: 100%;
}
.readonly > .CodeMirror {
background-color: var(--30) !important;
}
.markdown-fullscreen .markdown-content {
height: calc(100vh - 30px);
}
.markdown-fullscreen .CodeMirror {
height: 100%;
}
</style>

View File

@@ -0,0 +1,472 @@
<template>
<div>
<default-field
:field="field"
:show-errors="false"
:field-name="fieldName"
:show-help-text="field.helpText != null"
>
<select
v-if="hasMorphToTypes"
:disabled="isLocked || isReadonly"
:data-testid="`${field.attribute}-type`"
:dusk="`${field.attribute}-type`"
slot="field"
:value="resourceType"
@change="refreshResourcesForTypeChange"
class="block w-full form-control form-input form-input-bordered form-select mb-3"
>
<option value="" selected :disabled="!field.nullable">
{{ __('Choose Type') }}
</option>
<option
v-for="option in field.morphToTypes"
:key="option.value"
:value="option.value"
:selected="resourceType == option.value"
>
{{ option.singularLabel }}
</option>
</select>
<label v-else slot="field" class="flex items-center select-none mt-3">
{{ __('There are no available options for this resource.') }}
</label>
</default-field>
<default-field
:field="field"
:errors="errors"
:show-help-text="false"
:field-name="fieldTypeName"
v-if="hasMorphToTypes"
>
<template slot="field">
<div class="flex items-center mb-3">
<search-input
class="w-full"
v-if="isSearchable && !isLocked && !isReadonly"
:data-testid="`${field.attribute}-search-input`"
:disabled="!resourceType || isLocked || isReadonly"
@input="performSearch"
@clear="clearSelection"
@selected="selectResource"
:debounce="field.debounce"
:value="selectedResource"
:data="availableResources"
:clearable="field.nullable"
trackBy="value"
>
<div
slot="default"
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>
<div
slot="option"
slot-scope="{ option, selected }"
class="flex items-center"
>
<div v-if="option.avatar" class="mr-3">
<img :src="option.avatar" class="w-8 h-8 rounded-full block" />
</div>
<div>
<div
class="text-sm font-semibold leading-5 text-90"
:class="{ 'text-white': selected }"
>
{{ option.display }}
</div>
<div
v-if="field.withSubtitles"
class="mt-1 text-xs font-semibold leading-5 text-80"
:class="{ 'text-white': selected }"
>
<span v-if="option.subtitle">{{ option.subtitle }}</span>
<span v-else>{{ __('No additional information...') }}</span>
</div>
</div>
</div>
</search-input>
<select-control
v-if="!isSearchable || isLocked"
class="form-control form-select w-full"
:class="{ 'border-danger': hasError }"
:dusk="`${field.attribute}-select`"
@change="selectResourceFromSelectControl"
:disabled="!resourceType || isLocked || isReadonly"
:options="availableResources"
:selected="selectedResourceId"
label="display"
>
<option
value=""
:disabled="!field.nullable"
:selected="selectedResourceId == ''"
>
{{ __('Choose') }} {{ fieldTypeName }}
</option>
</select-control>
<create-relation-button
v-if="canShowNewRelationModal"
@click="openRelationModal"
class="ml-1"
:dusk="`${field.attribute}-inline-create`"
/>
</div>
<portal to="modals" transition="fade-transition">
<create-relation-modal
v-if="relationModalOpen && !shownViaNewRelationModal"
@set-resource="handleSetResource"
@cancelled-create="closeRelationModal"
:resource-name="resourceType"
:via-relationship="viaRelationship"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
width="800"
/>
</portal>
<!-- Trashed State -->
<div v-if="shouldShowTrashed">
<checkbox-with-label
:dusk="field.attribute + '-with-trashed-checkbox'"
:checked="withTrashed"
@input="toggleWithTrashed"
>
{{ __('With Trashed') }}
</checkbox-with-label>
</div>
</template>
</default-field>
</div>
</template>
<script>
import _ from 'lodash'
import storage from '@/storage/MorphToFieldStorage'
import {
FormField,
PerformsSearches,
TogglesTrashed,
HandlesValidationErrors,
} from 'laravel-nova'
export default {
mixins: [
PerformsSearches,
TogglesTrashed,
HandlesValidationErrors,
FormField,
],
data: () => ({
resourceType: '',
initializingWithExistingResource: false,
softDeletes: false,
selectedResourceId: null,
selectedResource: null,
search: '',
relationModalOpen: false,
withTrashed: false,
}),
/**
* Mount the component.
*/
mounted() {
this.selectedResourceId = this.field.value
if (this.editingExistingResource) {
this.initializingWithExistingResource = true
this.resourceType = this.field.morphToType
this.selectedResourceId = this.field.morphToId
} else if (this.creatingViaRelatedResource) {
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
},
methods: {
/**
* Select a resource using the <select> control
*/
selectResourceFromSelectControl(e) {
this.selectedResourceId = e.target.value
this.selectInitialResource()
if (this.field) {
Nova.$emit(this.field.attribute + '-change', this.selectedResourceId)
}
},
/**
* Fill the forms formData with details from this field
*/
fill(formData) {
if (this.selectedResource && this.resourceType) {
formData.append(this.field.attribute, this.selectedResource.value)
formData.append(this.field.attribute + '_type', this.resourceType)
} else {
formData.append(this.field.attribute, '')
formData.append(this.field.attribute + '_type', '')
}
formData.append(this.field.attribute + '_trashed', this.withTrashed)
},
/**
* Get the resources that may be related to this resource.
*/
getAvailableResources(search = '') {
return storage
.fetchAvailableResources(
this.resourceName,
this.field.attribute,
this.queryParams
)
.then(({ data: { resources, softDeletes, withTrashed } }) => {
if (this.initializingWithExistingResource || !this.isSearchable) {
this.withTrashed = withTrashed
}
this.initializingWithExistingResource = false
this.availableResources = resources
this.softDeletes = softDeletes
})
},
/**
* 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
this.availableResources = []
this.selectedResource = ''
this.selectedResourceId = ''
this.withTrashed = false
// if (this.resourceType == '') {
this.softDeletes = false
// } else if (this.field.searchable) {
this.determineIfSoftDeletes()
// }
if (!this.isSearchable && this.resourceType) {
this.getAvailableResources()
}
},
/**
* Toggle the trashed state of the search
*/
toggleWithTrashed() {
this.withTrashed = !this.withTrashed
// Reload the data if the component doesn't support searching
if (!this.isSearchable) {
this.getAvailableResources()
}
},
openRelationModal() {
this.relationModalOpen = true
},
closeRelationModal() {
this.relationModalOpen = false
},
handleSetResource({ id }) {
this.closeRelationModal()
this.selectedResourceId = id
this.getAvailableResources().then(() => this.selectInitialResource())
},
},
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
*/
creatingViaRelatedResource() {
return Boolean(
_.find(
this.field.morphToTypes,
type => type.value == this.viaResource
) &&
this.viaResource &&
this.viaResourceId
)
},
/**
* Determine if we should select an initial resource when mounting this field
*/
shouldSelectInitialResource() {
return Boolean(
this.editingExistingResource ||
this.creatingViaRelatedResource ||
Boolean(this.field.value && this.field.defaultResource)
)
},
/**
* Determine if the related resources is searchable
*/
isSearchable() {
return Boolean(this.field.searchable)
},
shouldLoadFirstResource() {
return (
this.isSearchable &&
this.shouldSelectInitialResource &&
this.initializingWithExistingResource
)
},
/**
* Get the query params for getting available resources
*/
queryParams() {
return {
params: {
type: this.resourceType,
current: this.selectedResourceId,
first: this.shouldLoadFirstResource,
search: this.search,
withTrashed: this.withTrashed,
viaResource: this.viaResource,
viaResourceId: this.viaResourceId,
viaRelationship: this.viaRelationship,
},
}
},
/**
* Determine if the field is locked
*/
isLocked() {
return Boolean(this.viaResource && this.field.reverse)
},
/**
* 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.field.morphToTypes, type => {
return type.value == this.resourceType
}).singularLabel
}
return ''
},
/**
* Determine if the field is set to readonly.
*/
isReadonly() {
return (
this.field.readonly || _.get(this.field, 'extraAttributes.readonly')
)
},
/**
* Determine whether there are any morph to types.
*/
hasMorphToTypes() {
return this.field.morphToTypes.length > 0
},
authorizedToCreate() {
return _.find(Nova.config.resources, resource => {
return resource.uriKey == this.resourceType
}).authorizedToCreate
},
canShowNewRelationModal() {
return (
this.field.showCreateRelationButton &&
this.resourceType &&
!this.shownViaNewRelationModal &&
!this.isLocked &&
!this.isReadonly &&
this.authorizedToCreate
)
},
shouldShowTrashed() {
return (
this.softDeletes &&
!this.isLocked &&
!this.isReadonly &&
this.field.displaysWithTrashed
)
},
},
}
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div v-if="panel.fields.length > 0">
<heading :level="1" :class="panel.helpText ? 'mb-2' : 'mb-3'">{{
panel.name
}}</heading>
<p
v-if="panel.helpText"
class="text-80 text-sm font-semibold italic mb-3"
v-html="panel.helpText"
></p>
<card>
<component
:class="{
'remove-bottom-border': index == panel.fields.length - 1,
}"
v-for="(field, index) in panel.fields"
:key="index"
:is="`${mode}-${field.component}`"
:errors="validationErrors"
:resource-id="resourceId"
:resource-name="resourceName"
:field="field"
:via-resource="viaResource"
:via-resource-id="viaResourceId"
:via-relationship="viaRelationship"
:shown-via-new-relation-modal="shownViaNewRelationModal"
@field-changed="$emit('field-changed')"
@file-deleted="$emit('update-last-retrieved-at-timestamp')"
@file-upload-started="$emit('file-upload-started')"
@file-upload-finished="$emit('file-upload-finished')"
:show-help-text="field.helpText != null"
/>
</card>
</div>
</template>
<script>
export default {
name: 'FormPanel',
props: {
shownViaNewRelationModal: {
type: Boolean,
default: false,
},
panel: {
type: Object,
required: true,
},
name: {
default: 'Panel',
},
mode: {
type: String,
default: 'form',
},
fields: {
type: Array,
default: [],
},
validationErrors: {
type: Object,
required: true,
},
resourceName: {
type: String,
required: true,
},
resourceId: {
type: [Number, String],
},
viaResource: {
type: String,
},
viaResourceId: {
type: [Number, String],
},
viaRelationship: {
type: String,
},
},
}
</script>

View File

@@ -0,0 +1,25 @@
<template>
<default-field :field="field" :errors="errors" :show-help-text="showHelpText">
<template slot="field">
<input
:id="field.attribute"
:dusk="field.attribute"
type="password"
v-model="value"
class="w-full form-control form-input form-input-bordered"
:class="errorClasses"
:placeholder="field.name"
autocomplete="new-password"
:disabled="isReadonly"
/>
</template>
</default-field>
</template>
<script>
import { FormField, HandlesValidationErrors } from 'laravel-nova'
export default {
mixins: [HandlesValidationErrors, FormField],
}
</script>

View File

@@ -0,0 +1,394 @@
<template>
<default-field :field="field" :errors="errors" :show-help-text="showHelpText">
<template slot="field">
<input
:ref="field.attribute"
:id="field.attribute"
:dusk="field.attribute"
type="text"
v-model="value"
class="w-full form-control form-input form-input-bordered"
:class="errorClasses"
:placeholder="field.name"
:disabled="isReadonly"
/>
</template>
</default-field>
</template>
<script>
import { FormField, HandlesValidationErrors } from 'laravel-nova'
export default {
mixins: [HandlesValidationErrors, FormField],
/**
* Mount the component.
*/
mounted() {
this.setInitialValue()
this.field.fill = this.fill
Nova.$on(this.field.attribute + '-value', value => {
this.value = value
})
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.field.attribute],
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
Nova.$emit(this.field.secondAddressLine + '-value', '')
Nova.$emit(this.field.city + '-value', e.suggestion.city)
Nova.$emit(
this.field.state + '-value',
this.parseState(
e.suggestion.administrative,
e.suggestion.countryCode
)
)
Nova.$emit(this.field.postalCode + '-value', e.suggestion.postcode)
Nova.$emit(this.field.suburb + '-value', e.suggestion.suburb)
Nova.$emit(
this.field.country + '-value',
e.suggestion.countryCode.toUpperCase()
)
Nova.$emit(this.field.latitude + '-value', e.suggestion.latlng.lat)
Nova.$emit(this.field.longitude + '-value', e.suggestion.latlng.lng)
})
})
placesAutocomplete.on('clear', () => {
this.$nextTick(() => {
this.value = ''
Nova.$emit(this.field.secondAddressLine + '-value', '')
Nova.$emit(this.field.city + '-value', '')
Nova.$emit(this.field.state + '-value', '')
Nova.$emit(this.field.postalCode + '-value', '')
Nova.$emit(this.field.suburb + '-value', '')
Nova.$emit(this.field.country + '-value', '')
Nova.$emit(this.field.latitude + '-value', '')
Nova.$emit(this.field.longitude + '-value', '')
})
})
},
/**
* 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,146 @@
<template>
<default-field :field="field" :errors="errors" :show-help-text="showHelpText">
<template slot="field">
<!-- Search Input -->
<search-input
v-if="!isReadonly && isSearchable"
@input="performSearch"
@clear="clearSelection"
@selected="selectOption"
:error="hasError"
:value="selectedOption"
:data="filteredOptions"
:clearable="field.nullable"
trackBy="value"
class="w-full"
>
<!-- The Selected Option Slot -->
<div slot="default" v-if="selectedOption" class="flex items-center">
{{ selectedOption.label }}
</div>
<!-- Options List Slot -->
<div
slot="option"
slot-scope="{ option, selected }"
class="flex items-center text-sm font-semibold leading-5 text-90"
:class="{ 'text-white': selected }"
>
{{ option.label }}
</div>
</search-input>
<!-- Select Input Field -->
<select-control
v-else
:id="field.attribute"
:dusk="field.attribute"
@change="handleChange"
:value="this.value"
class="w-full form-control form-select"
:class="errorClasses"
:options="field.options"
:disabled="isReadonly"
>
<option value="" selected :disabled="!field.nullable">
{{ placeholder }}
</option>
</select-control>
</template>
</default-field>
</template>
<script>
import { FormField, HandlesValidationErrors } from 'laravel-nova'
export default {
mixins: [HandlesValidationErrors, FormField],
data: () => ({
selectedOption: null,
search: '',
}),
created() {
if (this.field.value && this.isSearchable) {
this.selectedOption = _(this.field.options).find(
v => v.value == this.field.value
)
}
},
methods: {
/**
* 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) {
formData.append(this.field.attribute, 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 = ''
this.value = ''
},
/**
* Select the given option.
*/
selectOption(option) {
this.selectedOption = option
this.value = option.value
},
/**
* Handle the selection change event.
*/
handleChange(e) {
this.value = e.target.value
if (this.field) {
Nova.$emit(this.field.attribute + '-change', this.value)
}
},
},
computed: {
/**
* 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.toLowerCase().indexOf(this.search.toLowerCase()) > -1
)
})
},
/**
* Return the placeholder text for the field.
*/
placeholder() {
return this.field.placeholder || this.__('Choose an option')
},
},
}
</script>

View File

@@ -0,0 +1,84 @@
<template>
<default-field :field="field" :errors="errors" :show-help-text="showHelpText">
<template slot="field">
<div class="flex items-center">
<input
ref="theInput"
class="w-full form-control form-input form-input-bordered"
:id="field.attribute"
:dusk="field.attribute"
v-model="value"
:disabled="isReadonly"
v-bind="extraAttributes"
/>
<button
class="btn btn-link rounded px-1 py-1 inline-flex text-sm text-primary ml-1 mt-2"
v-if="field.showCustomizeButton"
type="button"
@click="toggleCustomizeClick"
>
{{ __('Customize') }}
</button>
</div>
</template>
</default-field>
</template>
<script>
import { FormField, HandlesValidationErrors } from 'laravel-nova'
import slugify from '@/util/slugify'
export default {
mixins: [HandlesValidationErrors, FormField],
mounted() {
if (this.shouldRegisterInitialListener) {
this.registerChangeListener()
}
},
methods: {
changeListener(value) {
return value => {
this.value = slugify(value, this.field.separator)
}
},
registerChangeListener() {
Nova.$on(this.eventName, value => {
this.value = slugify(value, this.field.separator)
})
},
toggleCustomizeClick() {
if (this.field.readonly) {
Nova.$off(this.eventName)
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.field.from}-change`
},
extraAttributes() {
return this.field.extraAttributes || {}
},
},
}
</script>

View File

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

View File

@@ -0,0 +1,61 @@
<template>
<default-field :field="field" :errors="errors" :show-help-text="showHelpText">
<template slot="field">
<input
class="w-full form-control form-input form-input-bordered"
@input="handleChange"
:value="value"
:id="field.attribute"
:dusk="field.attribute"
v-bind="extraAttributes"
:disabled="isReadonly"
:list="`${field.attribute}-list`"
/>
<datalist
v-if="field.suggestions && field.suggestions.length > 0"
:id="`${field.attribute}-list`"
>
<option
:key="suggestion"
v-for="suggestion in field.suggestions"
:value="suggestion"
/>
</datalist>
</template>
</default-field>
</template>
<script>
import { FormField, HandlesValidationErrors } from 'laravel-nova'
export default {
mixins: [HandlesValidationErrors, FormField],
computed: {
defaultAttributes() {
return {
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,
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,46 @@
<template>
<default-field
:field="field"
:errors="errors"
:full-width-content="true"
:show-help-text="showHelpText"
>
<template slot="field">
<textarea
class="w-full form-control form-input form-input-bordered py-3 h-auto"
:id="field.attribute"
:dusk="field.attribute"
v-bind="extraAttributes"
:value="value"
@input="handleChange"
/>
</template>
</default-field>
</template>
<script>
import { FormField, HandlesValidationErrors } from 'laravel-nova'
export default {
mixins: [FormField, HandlesValidationErrors],
computed: {
defaultAttributes() {
return {
rows: this.field.rows,
class: this.errorClasses,
placeholder: this.field.name,
}
},
extraAttributes() {
const attrs = this.field.extraAttributes
return {
...this.defaultAttributes,
...attrs,
}
},
},
}
</script>

View File

@@ -0,0 +1,166 @@
<template>
<default-field
:field="field"
:errors="errors"
:full-width-content="true"
:key="index"
:show-help-text="showHelpText"
>
<template slot="field">
<div class="rounded-lg" :class="{ disabled: isReadonly }">
<trix
name="trixman"
:value="value"
@change="handleChange"
@file-add="handleFileAdd"
@file-remove="handleFileRemove"
:class="{ 'border-danger': hasError }"
:with-files="field.withFiles"
v-bind="extraAttributes"
:disabled="isReadonly"
class="rounded-lg"
/>
</div>
</template>
</default-field>
</template>
<script>
import Trix from '../Trix'
import { FormField, HandlesValidationErrors } from 'laravel-nova'
export default {
mixins: [HandlesValidationErrors, FormField],
components: { Trix },
data: () => ({ draftId: uuidv4(), index: 0 }),
beforeDestroy() {
this.cleanUp()
},
mounted() {
Nova.$on(this.field.attribute + '-value', value => {
this.value = value
this.index++
})
},
methods: {
/**
* Update the field's internal value when it's value changes
*/
handleChange(value) {
this.value = value
this.$emit('field-changed')
},
fill(formData) {
formData.append(this.field.attribute, this.value || '')
formData.append(this.field.attribute + 'DraftId', this.draftId)
},
/**
* Initiate an attachement upload
*/
handleFileAdd({ attachment }) {
if (attachment.file) {
this.uploadAttachment(attachment)
}
},
/**
* Upload an attachment
*/
uploadAttachment(attachment) {
const data = new FormData()
data.append('Content-Type', attachment.file.type)
data.append('attachment', attachment.file)
data.append('draftId', this.draftId)
Nova.request()
.post(
`/nova-api/${this.resourceName}/trix-attachment/${this.field.attribute}`,
data,
{
onUploadProgress: function (progressEvent) {
attachment.setUploadProgress(
Math.round((progressEvent.loaded * 100) / progressEvent.total)
)
},
}
)
.then(({ data: { url } }) => {
return attachment.setAttributes({
url: url,
href: url,
})
})
.catch(error => {
this.$toasted.show(
__('An error occured while uploading your file.'),
{ type: 'error' }
)
})
},
/**
* Remove an attachment from the server
*/
handleFileRemove({ attachment: { attachment } }) {
Nova.request()
.delete(
`/nova-api/${this.resourceName}/trix-attachment/${this.field.attribute}`,
{
params: {
attachmentUrl: attachment.attributes.values.url,
},
}
)
.then(response => {})
.catch(error => {})
},
/**
* Purge pending attachments for the draft
*/
cleanUp() {
if (this.field.withFiles) {
Nova.request()
.delete(
`/nova-api/${this.resourceName}/trix-attachment/${this.field.attribute}/${this.draftId}`
)
.then(response => {})
.catch(error => {})
}
},
},
computed: {
defaultAttributes() {
return {
placeholder: this.field.placeholder || this.field.name,
}
},
extraAttributes() {
const attrs = this.field.extraAttributes
return {
...this.defaultAttributes,
...attrs,
}
},
},
}
function uuidv4() {
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
(
c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
).toString(16)
)
}
</script>