Последняя версия с сервера прошлого разработчика
This commit is contained in:
396
nova/resources/js/components/Form/BelongsToField.vue
Executable file
396
nova/resources/js/components/Form/BelongsToField.vue
Executable 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>
|
||||
50
nova/resources/js/components/Form/BooleanField.vue
Executable file
50
nova/resources/js/components/Form/BooleanField.vue
Executable 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>
|
||||
76
nova/resources/js/components/Form/BooleanGroupField.vue
Executable file
76
nova/resources/js/components/Form/BooleanGroupField.vue
Executable 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>
|
||||
15
nova/resources/js/components/Form/CancelButton.vue
Executable file
15
nova/resources/js/components/Form/CancelButton.vue
Executable 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>
|
||||
141
nova/resources/js/components/Form/CodeField.vue
Executable file
141
nova/resources/js/components/Form/CodeField.vue
Executable 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>
|
||||
16
nova/resources/js/components/Form/CreateRelationButton.vue
Executable file
16
nova/resources/js/components/Form/CreateRelationButton.vue
Executable 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>
|
||||
60
nova/resources/js/components/Form/CurrencyField.vue
Executable file
60
nova/resources/js/components/Form/CurrencyField.vue
Executable 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>
|
||||
82
nova/resources/js/components/Form/DateField.vue
Executable file
82
nova/resources/js/components/Form/DateField.vue
Executable 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>
|
||||
106
nova/resources/js/components/Form/DateTimeField.vue
Executable file
106
nova/resources/js/components/Form/DateTimeField.vue
Executable 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>
|
||||
74
nova/resources/js/components/Form/DefaultField.vue
Executable file
74
nova/resources/js/components/Form/DefaultField.vue
Executable 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 }} <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>
|
||||
12
nova/resources/js/components/Form/FieldWrapper.vue
Executable file
12
nova/resources/js/components/Form/FieldWrapper.vue
Executable 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>
|
||||
339
nova/resources/js/components/Form/FileField.vue
Executable file
339
nova/resources/js/components/Form/FileField.vue
Executable 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>
|
||||
42
nova/resources/js/components/Form/HeadingField.vue
Executable file
42
nova/resources/js/components/Form/HeadingField.vue
Executable 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>
|
||||
7
nova/resources/js/components/Form/HelpText.vue
Executable file
7
nova/resources/js/components/Form/HelpText.vue
Executable file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div class="help-text" v-html="$slots.default[0].text" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {}
|
||||
</script>
|
||||
13
nova/resources/js/components/Form/HiddenField.vue
Executable file
13
nova/resources/js/components/Form/HiddenField.vue
Executable 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>
|
||||
155
nova/resources/js/components/Form/KeyValueField/KeyValueField.vue
Executable file
155
nova/resources/js/components/Form/KeyValueField/KeyValueField.vue
Executable 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>
|
||||
28
nova/resources/js/components/Form/KeyValueField/KeyValueHeader.vue
Executable file
28
nova/resources/js/components/Form/KeyValueField/KeyValueHeader.vue
Executable 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>
|
||||
109
nova/resources/js/components/Form/KeyValueField/KeyValueItem.vue
Executable file
109
nova/resources/js/components/Form/KeyValueField/KeyValueItem.vue
Executable 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>
|
||||
23
nova/resources/js/components/Form/KeyValueField/KeyValueTable.vue
Executable file
23
nova/resources/js/components/Form/KeyValueField/KeyValueTable.vue
Executable 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>
|
||||
15
nova/resources/js/components/Form/Label.vue
Executable file
15
nova/resources/js/components/Form/Label.vue
Executable 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>
|
||||
382
nova/resources/js/components/Form/MarkdownField.vue
Executable file
382
nova/resources/js/components/Form/MarkdownField.vue
Executable 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('', 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>
|
||||
472
nova/resources/js/components/Form/MorphToField.vue
Executable file
472
nova/resources/js/components/Form/MorphToField.vue
Executable 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>
|
||||
95
nova/resources/js/components/Form/Panel.vue
Executable file
95
nova/resources/js/components/Form/Panel.vue
Executable 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>
|
||||
25
nova/resources/js/components/Form/PasswordField.vue
Executable file
25
nova/resources/js/components/Form/PasswordField.vue
Executable 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>
|
||||
394
nova/resources/js/components/Form/PlaceField.vue
Executable file
394
nova/resources/js/components/Form/PlaceField.vue
Executable 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>
|
||||
146
nova/resources/js/components/Form/SelectField.vue
Executable file
146
nova/resources/js/components/Form/SelectField.vue
Executable 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>
|
||||
84
nova/resources/js/components/Form/SlugField.vue
Executable file
84
nova/resources/js/components/Form/SlugField.vue
Executable 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>
|
||||
43
nova/resources/js/components/Form/StatusField.vue
Executable file
43
nova/resources/js/components/Form/StatusField.vue
Executable 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>
|
||||
61
nova/resources/js/components/Form/TextField.vue
Executable file
61
nova/resources/js/components/Form/TextField.vue
Executable 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>
|
||||
46
nova/resources/js/components/Form/TextareaField.vue
Executable file
46
nova/resources/js/components/Form/TextareaField.vue
Executable 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>
|
||||
166
nova/resources/js/components/Form/TrixField.vue
Executable file
166
nova/resources/js/components/Form/TrixField.vue
Executable 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>
|
||||
Reference in New Issue
Block a user