Последняя версия с сервера прошлого разработчика
This commit is contained in:
1002
nova/resources/css/app.css
vendored
Executable file
1002
nova/resources/css/app.css
vendored
Executable file
File diff suppressed because it is too large
Load Diff
226
nova/resources/js/Nova.js
vendored
Executable file
226
nova/resources/js/Nova.js
vendored
Executable file
@@ -0,0 +1,226 @@
|
||||
import Vue from 'vue'
|
||||
import Meta from 'vue-meta'
|
||||
import store from '@/store'
|
||||
import Toasted from 'vue-toasted'
|
||||
import router from '@/router'
|
||||
import axios from '@/util/axios'
|
||||
import numbro from '@/util/numbro'
|
||||
import PortalVue from 'portal-vue'
|
||||
import Loading from '@/components/Loading'
|
||||
import AsyncComputed from 'vue-async-computed'
|
||||
import resources from '@/store/resources'
|
||||
import VTooltip from 'v-tooltip'
|
||||
import Mousetrap from 'mousetrap'
|
||||
|
||||
Vue.use(Meta)
|
||||
Vue.use(PortalVue)
|
||||
Vue.use(AsyncComputed)
|
||||
Vue.use(VTooltip)
|
||||
|
||||
Vue.use(Toasted, {
|
||||
router,
|
||||
theme: 'nova',
|
||||
position: 'bottom-right',
|
||||
duration: 6000,
|
||||
})
|
||||
|
||||
export default class Nova {
|
||||
constructor(config) {
|
||||
this.bus = new Vue()
|
||||
this.bootingCallbacks = []
|
||||
this.config = config
|
||||
this.useShortcuts = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback to be called before Nova starts. This is used to bootstrap
|
||||
* addons, tools, custom fields, or anything else Nova needs
|
||||
*/
|
||||
booting(callback) {
|
||||
this.bootingCallbacks.push(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all of the booting callbacks.
|
||||
*/
|
||||
boot() {
|
||||
this.bootingCallbacks.forEach(callback => callback(Vue, router, store))
|
||||
this.bootingCallbacks = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the built-in Vuex modules for each resource
|
||||
*/
|
||||
registerStoreModules() {
|
||||
this.config.resources.forEach(resource => {
|
||||
store.registerModule(resource.uriKey, resources)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Nova app by calling each of the tool's callbacks and then creating
|
||||
* the underlying Vue instance.
|
||||
*/
|
||||
liftOff() {
|
||||
let _this = this
|
||||
|
||||
let mousetrapDefaultStopCallback = Mousetrap.prototype.stopCallback
|
||||
|
||||
Mousetrap.prototype.stopCallback = function (e, element, combo) {
|
||||
if (!_this.useShortcuts) {
|
||||
return true
|
||||
}
|
||||
|
||||
return mousetrapDefaultStopCallback.call(this, e, element, combo)
|
||||
}
|
||||
|
||||
Mousetrap.init()
|
||||
|
||||
this.boot()
|
||||
this.registerStoreModules()
|
||||
|
||||
this.app = new Vue({
|
||||
el: '#nova',
|
||||
name: 'Nova',
|
||||
router,
|
||||
store,
|
||||
components: { Loading },
|
||||
metaInfo: {
|
||||
titleTemplate: `%s | ${window.config.appName}`,
|
||||
},
|
||||
mounted: function () {
|
||||
this.$loading = this.$refs.loading
|
||||
|
||||
_this.$on('error', message => {
|
||||
this.$toasted.show(message, { type: 'error' })
|
||||
})
|
||||
|
||||
_this.$on('token-expired', () => {
|
||||
this.$toasted.show(this.__('Sorry, your session has expired.'), {
|
||||
action: {
|
||||
onClick: () => location.reload(),
|
||||
text: this.__('Reload'),
|
||||
},
|
||||
duration: null,
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an axios instance configured to make requests to Nova's API
|
||||
* and handle certain response codes.
|
||||
*/
|
||||
request(options) {
|
||||
if (options !== undefined) {
|
||||
return axios(options)
|
||||
}
|
||||
|
||||
return axios
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number using numbro.js for consistent number formatting.
|
||||
*/
|
||||
formatNumber(number, format) {
|
||||
const num = numbro(number)
|
||||
|
||||
if (format !== undefined) {
|
||||
return num.format(format)
|
||||
}
|
||||
|
||||
return num.format()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a listener on Nova's built-in event bus
|
||||
*/
|
||||
$on(...args) {
|
||||
this.bus.$on(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a one-time listener on the event bus
|
||||
*/
|
||||
$once(...args) {
|
||||
this.bus.$once(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister an listener on the event bus
|
||||
*/
|
||||
$off(...args) {
|
||||
this.bus.$off(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event on the event bus
|
||||
*/
|
||||
$emit(...args) {
|
||||
this.bus.$emit(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if Nova is missing the requested resource with the given uri key
|
||||
*/
|
||||
missingResource(uriKey) {
|
||||
return _.find(this.config.resources, r => r.uriKey == uriKey) == undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an error message to the user.
|
||||
*
|
||||
* @param {string} message
|
||||
*/
|
||||
error(message) {
|
||||
Vue.toasted.show(message, { type: 'error' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a success message to the user.
|
||||
*
|
||||
* @param {string} message
|
||||
*/
|
||||
success(message) {
|
||||
Vue.toasted.show(message, { type: 'success' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a warning message to the user.
|
||||
*
|
||||
* @param {string} message
|
||||
*/
|
||||
warning(message) {
|
||||
Vue.toasted.show(message, { type: 'warning' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a keyboard shortcut.
|
||||
*/
|
||||
addShortcut(keys, callback) {
|
||||
Mousetrap.bind(keys, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbind a keyboard shortcut.
|
||||
*/
|
||||
disableShortcut(keys) {
|
||||
Mousetrap.unbind(keys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause all keyboard shortcuts.
|
||||
*/
|
||||
pauseShortcuts() {
|
||||
this.useShortcuts = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume all keyboard shortcuts.
|
||||
*/
|
||||
resumeShortcuts() {
|
||||
this.useShortcuts = true
|
||||
}
|
||||
}
|
||||
30
nova/resources/js/__tests__/ActionSelector.spec.js
vendored
Executable file
30
nova/resources/js/__tests__/ActionSelector.spec.js
vendored
Executable file
@@ -0,0 +1,30 @@
|
||||
import { mount, shallowMount, createLocalVue } from '@vue/test-utils'
|
||||
import PortalVue from 'portal-vue'
|
||||
import ActionSelector from '@/components/ActionSelector'
|
||||
import flushPromises from 'flush-promises'
|
||||
|
||||
const localVue = createLocalVue()
|
||||
localVue.use(PortalVue)
|
||||
|
||||
describe('ActionSelector', () => {
|
||||
test('it renders correctly with actions and pivot action', () => {
|
||||
const wrapper = mount(ActionSelector, {
|
||||
localVue,
|
||||
propsData: {
|
||||
selectedResources: [1, 2, 3],
|
||||
resourceName: 'posts',
|
||||
actions: [
|
||||
{ uriKey: 'action-1', name: 'Action 1' },
|
||||
{ uriKey: 'action-2', name: 'Action 2' },
|
||||
],
|
||||
pivotActions: [
|
||||
{ uriKey: 'action-3', name: 'Action 3' },
|
||||
{ uriKey: 'action-4', name: 'Action 4' },
|
||||
],
|
||||
pivotName: 'Pivot',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
24
nova/resources/js/__tests__/__snapshots__/ActionSelector.spec.js.snap
Executable file
24
nova/resources/js/__tests__/__snapshots__/ActionSelector.spec.js.snap
Executable file
@@ -0,0 +1,24 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ActionSelector it renders correctly with actions and pivot action 1`] = `
|
||||
<div>
|
||||
<div class="mr-3">
|
||||
<select data-testid="action-select" class="form-control form-select mr-2">
|
||||
<option value="" disabled="disabled" selected="selected">Select Action</option>
|
||||
<optgroup label="Resource">
|
||||
<option value="action-1">
|
||||
Action 1
|
||||
</option>
|
||||
<option value="action-2">
|
||||
Action 2
|
||||
</option>
|
||||
</optgroup>
|
||||
<!---->
|
||||
</select>
|
||||
<button data-testid="action-confirm" disabled="disabled" class="btn btn-default btn-primary btn-disabled">
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
<div class="v-portal" style="display: none;"></div>
|
||||
</div>
|
||||
`;
|
||||
349
nova/resources/js/__tests__/fields/BelongsToField.spec.js
vendored
Executable file
349
nova/resources/js/__tests__/fields/BelongsToField.spec.js
vendored
Executable file
@@ -0,0 +1,349 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import BelongsToField from '@/components/Form/BelongsToField'
|
||||
import flushPromises from 'flush-promises'
|
||||
|
||||
jest.mock('@/storage/BelongsToFieldStorage')
|
||||
|
||||
describe('BelongsToField', () => {
|
||||
test('when creating it fetches all resources on mount if the field is not searchable', async () => {
|
||||
const wrapper = shallowMount(BelongsToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'User',
|
||||
belongsToId: '',
|
||||
attribute: 'user',
|
||||
searchable: false,
|
||||
resourceName: 'users',
|
||||
},
|
||||
resourceName: 'posts',
|
||||
viaResource: '',
|
||||
viaResourceId: '',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.editingExistingResource).toBe(false)
|
||||
expect(wrapper.vm.selectedResourceId).toBe(null)
|
||||
expect(wrapper.vm.isSearchable).toBe(false)
|
||||
expect(wrapper.vm.shouldSelectInitialResource).toBe(false)
|
||||
expect(wrapper.vm.queryParams).toEqual({
|
||||
params: {
|
||||
current: null,
|
||||
first: false,
|
||||
search: '',
|
||||
withTrashed: false,
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.availableResources).toEqual([
|
||||
{ value: 1 },
|
||||
{ value: 2 },
|
||||
{ value: 3 },
|
||||
])
|
||||
expect(wrapper.vm.selectedResource).toEqual(null)
|
||||
})
|
||||
|
||||
test('when creating it doesnt fetch resources on mount if the field is searchable', async () => {
|
||||
const wrapper = shallowMount(BelongsToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'User',
|
||||
belongsToId: '',
|
||||
attribute: 'user',
|
||||
searchable: true,
|
||||
resourceName: 'users',
|
||||
},
|
||||
resourceName: 'posts',
|
||||
viaResource: '',
|
||||
viaResourceId: '',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.editingExistingResource).toBe(false)
|
||||
expect(wrapper.vm.selectedResourceId).toEqual(null)
|
||||
expect(wrapper.vm.shouldSelectInitialResource).toBe(false)
|
||||
expect(wrapper.vm.initializingWithExistingResource).toBe(false)
|
||||
expect(wrapper.vm.isSearchable).toBe(true)
|
||||
expect(wrapper.vm.queryParams).toEqual({
|
||||
params: {
|
||||
current: null,
|
||||
first: false,
|
||||
search: '',
|
||||
withTrashed: false,
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.availableResources).toEqual([])
|
||||
expect(wrapper.vm.selectedResource).toEqual(null)
|
||||
})
|
||||
|
||||
test('when creating via a related resource it selects the related resource on mount if the field is searchable', async () => {
|
||||
const wrapper = shallowMount(BelongsToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'Post',
|
||||
belongsToId: '',
|
||||
attribute: 'post',
|
||||
searchable: true,
|
||||
resourceName: 'posts',
|
||||
},
|
||||
resourceName: 'comments',
|
||||
viaResource: 'posts',
|
||||
viaResourceId: 1,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.editingExistingResource).toBe(false)
|
||||
expect(wrapper.vm.selectedResourceId).toEqual(1)
|
||||
expect(wrapper.vm.shouldSelectInitialResource).toBe(true)
|
||||
expect(wrapper.vm.initializingWithExistingResource).toBe(true)
|
||||
expect(wrapper.vm.isSearchable).toBe(true)
|
||||
expect(wrapper.vm.queryParams).toEqual({
|
||||
params: { current: 1, first: true, search: '', withTrashed: false },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.availableResources).toEqual([{ value: 1 }])
|
||||
expect(wrapper.vm.selectedResource).toEqual({ value: 1 })
|
||||
})
|
||||
|
||||
test('if editing an existing resource it selects the related resource on mount if the field is not searchable', async () => {
|
||||
const wrapper = shallowMount(BelongsToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'User',
|
||||
belongsToId: 1,
|
||||
attribute: 'user',
|
||||
searchable: false,
|
||||
resourceName: 'users',
|
||||
},
|
||||
resourceName: 'posts',
|
||||
viaResource: '',
|
||||
viaResourceId: '',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.editingExistingResource).toBe(true)
|
||||
expect(wrapper.vm.selectedResourceId).toEqual(1)
|
||||
expect(wrapper.vm.shouldSelectInitialResource).toBe(true)
|
||||
expect(wrapper.vm.initializingWithExistingResource).toBe(false)
|
||||
expect(wrapper.vm.isSearchable).toBe(false)
|
||||
expect(wrapper.vm.queryParams).toEqual({
|
||||
params: {
|
||||
current: 1,
|
||||
first: false,
|
||||
search: '',
|
||||
withTrashed: false,
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.availableResources).toEqual([
|
||||
{ value: 1 },
|
||||
{ value: 2 },
|
||||
{ value: 3 },
|
||||
])
|
||||
expect(wrapper.vm.selectedResource).toEqual({ value: 1 })
|
||||
})
|
||||
|
||||
test('if editing an existing resource it selects the related resource on mount if the field is searchable', async () => {
|
||||
const wrapper = shallowMount(BelongsToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'User',
|
||||
belongsToId: 1,
|
||||
attribute: 'user',
|
||||
searchable: true,
|
||||
resourceName: 'users',
|
||||
},
|
||||
resourceName: 'posts',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.editingExistingResource).toBe(true)
|
||||
expect(wrapper.vm.selectedResourceId).toEqual(1)
|
||||
expect(wrapper.vm.shouldSelectInitialResource).toBe(true)
|
||||
expect(wrapper.vm.initializingWithExistingResource).toBe(true)
|
||||
expect(wrapper.vm.isSearchable).toBe(true)
|
||||
expect(wrapper.vm.queryParams).toEqual({
|
||||
params: { current: 1, first: true, search: '', withTrashed: false },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.availableResources).toEqual([{ value: 1 }])
|
||||
expect(wrapper.vm.selectedResourceId).toEqual(1)
|
||||
expect(wrapper.vm.selectedResource).toEqual({ value: 1 })
|
||||
|
||||
// Ensure it turns off selection of the initial resource after fetching the first time
|
||||
expect(wrapper.vm.initializingWithExistingResource).toBe(false)
|
||||
})
|
||||
|
||||
test('it determines if its related resource soft deletes', async () => {
|
||||
const wrapper = shallowMount(BelongsToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'User',
|
||||
belongsToId: 1,
|
||||
attribute: 'user',
|
||||
searchable: true,
|
||||
resourceName: 'users',
|
||||
},
|
||||
resourceName: 'videos',
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.softDeletes).toBe(true)
|
||||
})
|
||||
|
||||
test('including trashed resources in the available resources can be enabled when a resource supports soft-deleting', async () => {
|
||||
const wrapper = shallowMount(BelongsToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'User',
|
||||
belongsToId: '',
|
||||
attribute: 'user',
|
||||
searchable: true,
|
||||
resourceName: 'users',
|
||||
},
|
||||
resourceName: 'videos',
|
||||
},
|
||||
})
|
||||
|
||||
wrapper.vm.enableWithTrashed()
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.softDeletes).toBe(true)
|
||||
expect(wrapper.vm.withTrashed).toBe(true)
|
||||
expect(wrapper.vm.queryParams).toEqual({
|
||||
params: {
|
||||
current: null,
|
||||
first: false,
|
||||
search: '',
|
||||
withTrashed: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('including trashed resources in the available resources cannot be enabled when a resource doesnt support soft-deleting', async () => {
|
||||
const wrapper = shallowMount(BelongsToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'Author',
|
||||
belongsToId: '',
|
||||
attribute: 'author',
|
||||
searchable: false,
|
||||
resourceName: 'authors', // This resource doesnt support soft-deleting
|
||||
},
|
||||
resourceName: 'videos',
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.softDeletes).toBe(false)
|
||||
expect(wrapper.vm.withTrashed).toBe(false)
|
||||
expect(wrapper.vm.queryParams).toEqual({
|
||||
params: {
|
||||
current: null,
|
||||
first: false,
|
||||
search: '',
|
||||
withTrashed: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('including trashed resources in the available resources is disabled by default', async () => {
|
||||
const wrapper = shallowMount(BelongsToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'User',
|
||||
belongsToId: '',
|
||||
attribute: 'user',
|
||||
searchable: true,
|
||||
resourceName: 'users',
|
||||
},
|
||||
resourceName: 'videos',
|
||||
},
|
||||
})
|
||||
|
||||
// wrapper.vm.enableWithTrashed()
|
||||
expect(wrapper.vm.softDeletes).toBe(false)
|
||||
expect(wrapper.vm.withTrashed).toBe(false)
|
||||
|
||||
expect(wrapper.vm.queryParams).toEqual({
|
||||
params: {
|
||||
current: null,
|
||||
first: false,
|
||||
search: '',
|
||||
withTrashed: false,
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
test('it determines if its related resource doesnt soft delete', async () => {
|
||||
const wrapper = shallowMount(BelongsToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'Author',
|
||||
belongsToId: 1,
|
||||
attribute: 'author',
|
||||
searchable: true,
|
||||
resourceName: 'authors',
|
||||
},
|
||||
resourceName: 'videos',
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.softDeletes).toBe(false)
|
||||
})
|
||||
|
||||
test('it correctly handles filling the formData object for submit', async () => {
|
||||
const wrapper = shallowMount(BelongsToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'User',
|
||||
belongsToId: 1,
|
||||
attribute: 'user',
|
||||
searchable: true,
|
||||
resourceName: 'users',
|
||||
},
|
||||
resourceName: 'videos',
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const expectedFormData = new FormData()
|
||||
expectedFormData.append('user', 1)
|
||||
expectedFormData.append('user_trashed', false)
|
||||
|
||||
const formData = new FormData()
|
||||
wrapper.vm.field.fill(formData)
|
||||
|
||||
expect(formData).toEqual(expectedFormData)
|
||||
})
|
||||
})
|
||||
603
nova/resources/js/__tests__/fields/MorphToField.spec.js
vendored
Executable file
603
nova/resources/js/__tests__/fields/MorphToField.spec.js
vendored
Executable file
@@ -0,0 +1,603 @@
|
||||
import Vue from 'vue'
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils'
|
||||
import MorphToField from '@/components/Form/MorphToField'
|
||||
import flushPromises from 'flush-promises'
|
||||
|
||||
jest.mock('@/storage/MorphToFieldStorage')
|
||||
|
||||
describe('MorphToField', () => {
|
||||
/**
|
||||
* Note: This field doesn't support loading all resources initially unless we're editing or
|
||||
* coming in via a related resource because otherwise we don't know which type to load them for
|
||||
*/
|
||||
test('when creating it has the correct initial state if its not searchable', async () => {
|
||||
const wrapper = shallowMount(MorphToField, {
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'Commentable',
|
||||
attribute: 'commentable',
|
||||
searchable: false,
|
||||
morphToRelationship: 'commentable',
|
||||
morphToId: '',
|
||||
morphToType: '',
|
||||
morphToTypes: [
|
||||
{
|
||||
display: 'Post',
|
||||
type: 'App\\Nova\\Post',
|
||||
value: 'posts',
|
||||
},
|
||||
{
|
||||
display: 'Video',
|
||||
type: 'App\\Nova\\Video',
|
||||
value: 'videos',
|
||||
},
|
||||
],
|
||||
},
|
||||
resourceName: '',
|
||||
viaRelationship: '',
|
||||
viaResource: '',
|
||||
viaResourceId: '',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.initializingWithExistingResource).toBe(false)
|
||||
expect(wrapper.vm.editingExistingResource).toBe(false)
|
||||
expect(wrapper.vm.withTrashed).toBe(false)
|
||||
expect(wrapper.vm.resourceType).toBe('')
|
||||
expect(wrapper.vm.isSearchable).toBe(false)
|
||||
expect(wrapper.vm.shouldSelectInitialResource).toBe(false)
|
||||
expect(wrapper.vm.queryParams).toEqual({
|
||||
params: {
|
||||
type: '',
|
||||
current: null,
|
||||
first: false,
|
||||
search: '',
|
||||
withTrashed: false,
|
||||
},
|
||||
})
|
||||
expect(wrapper.vm.availableResources).toEqual([])
|
||||
expect(wrapper.vm.selectedResource).toEqual(null)
|
||||
expect(wrapper.vm.selectedResourceId).toBe(null)
|
||||
})
|
||||
|
||||
test('when creating it has the correct initial state if it is searchable', async () => {
|
||||
const wrapper = shallowMount(MorphToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'Commentable',
|
||||
attribute: 'commentable',
|
||||
searchable: true,
|
||||
morphToRelationship: 'commentable',
|
||||
morphToId: '',
|
||||
morphToType: '',
|
||||
morphToTypes: [
|
||||
{
|
||||
display: 'Post',
|
||||
type: 'App\\Nova\\Post',
|
||||
value: 'posts',
|
||||
},
|
||||
{
|
||||
display: 'Video',
|
||||
type: 'App\\Nova\\Video',
|
||||
value: 'videos',
|
||||
},
|
||||
],
|
||||
// resourceName: 'users',
|
||||
},
|
||||
resourceName: '',
|
||||
viaRelationship: '',
|
||||
viaResource: '',
|
||||
viaResourceId: '',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.initializingWithExistingResource).toBe(false)
|
||||
expect(wrapper.vm.editingExistingResource).toBe(false)
|
||||
expect(wrapper.vm.resourceType).toBe('')
|
||||
expect(wrapper.vm.withTrashed).toBe(false)
|
||||
expect(wrapper.vm.isSearchable).toBe(true)
|
||||
expect(wrapper.vm.shouldSelectInitialResource).toBe(false)
|
||||
expect(wrapper.vm.queryParams).toEqual({
|
||||
params: {
|
||||
type: '',
|
||||
current: null,
|
||||
first: false,
|
||||
search: '',
|
||||
withTrashed: false,
|
||||
},
|
||||
})
|
||||
expect(wrapper.vm.availableResources).toEqual([])
|
||||
expect(wrapper.vm.selectedResource).toEqual(null)
|
||||
expect(wrapper.vm.selectedResourceId).toBe(null)
|
||||
})
|
||||
|
||||
test('when creating via a related resource and is not searchable it loads the related type all related resources and selects the related resource', async () => {
|
||||
const wrapper = shallowMount(MorphToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'Commentable',
|
||||
attribute: 'commentable',
|
||||
searchable: false,
|
||||
morphToRelationship: 'commentable',
|
||||
morphToId: '',
|
||||
morphToType: '',
|
||||
morphToTypes: [
|
||||
{
|
||||
display: 'Post',
|
||||
type: 'App\\Nova\\Post',
|
||||
value: 'posts',
|
||||
},
|
||||
{
|
||||
display: 'Video',
|
||||
type: 'App\\Nova\\Video',
|
||||
value: 'videos',
|
||||
},
|
||||
],
|
||||
// resourceName: 'users',
|
||||
},
|
||||
resourceName: '',
|
||||
viaRelationship: '',
|
||||
viaResource: 'posts',
|
||||
viaResourceId: 1,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.initializingWithExistingResource).toBe(true)
|
||||
expect(wrapper.vm.editingExistingResource).toBe(false)
|
||||
expect(wrapper.vm.resourceType).toBe('posts')
|
||||
expect(wrapper.vm.withTrashed).toBe(false)
|
||||
expect(wrapper.vm.isSearchable).toBe(false)
|
||||
expect(wrapper.vm.shouldSelectInitialResource).toBe(true)
|
||||
expect(wrapper.vm.queryParams).toEqual({
|
||||
params: {
|
||||
type: 'posts',
|
||||
current: 1,
|
||||
first: false,
|
||||
search: '',
|
||||
withTrashed: false,
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.availableResources).toEqual([
|
||||
{ value: 1 },
|
||||
{ value: 2 },
|
||||
{ value: 3 },
|
||||
])
|
||||
expect(wrapper.vm.softDeletes).toBe(true)
|
||||
expect(wrapper.vm.selectedResource).toEqual({ value: 1 })
|
||||
expect(wrapper.vm.selectedResourceId).toBe(1)
|
||||
})
|
||||
|
||||
test('when creating via a related resource and it is searchable, it only loads the related type and selects the related resource', async () => {
|
||||
const wrapper = shallowMount(MorphToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'Commentable',
|
||||
attribute: 'commentable',
|
||||
searchable: true,
|
||||
morphToRelationship: 'commentable',
|
||||
morphToId: '',
|
||||
morphToType: '',
|
||||
morphToTypes: [
|
||||
{
|
||||
display: 'Post',
|
||||
type: 'App\\Nova\\Post',
|
||||
value: 'posts',
|
||||
},
|
||||
{
|
||||
display: 'Video',
|
||||
type: 'App\\Nova\\Video',
|
||||
value: 'videos',
|
||||
},
|
||||
],
|
||||
// resourceName: 'users',
|
||||
},
|
||||
resourceName: '',
|
||||
viaRelationship: '',
|
||||
viaResource: 'posts',
|
||||
viaResourceId: 1,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.initializingWithExistingResource).toBe(true)
|
||||
expect(wrapper.vm.editingExistingResource).toBe(false)
|
||||
expect(wrapper.vm.resourceType).toBe('posts')
|
||||
expect(wrapper.vm.isSearchable).toBe(true)
|
||||
expect(wrapper.vm.withTrashed).toBe(false)
|
||||
expect(wrapper.vm.shouldSelectInitialResource).toBe(true)
|
||||
expect(wrapper.vm.queryParams).toEqual({
|
||||
params: {
|
||||
type: 'posts',
|
||||
current: 1,
|
||||
first: true,
|
||||
search: '',
|
||||
withTrashed: false,
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.availableResources).toEqual([{ value: 1 }])
|
||||
expect(wrapper.vm.softDeletes).toBe(true)
|
||||
expect(wrapper.vm.selectedResource).toEqual({ value: 1 })
|
||||
expect(wrapper.vm.selectedResourceId).toBe(1)
|
||||
})
|
||||
|
||||
test('when editing an existing resource and the field is not searchable it selects the related resource type and resource and loads all resources', async () => {
|
||||
const wrapper = shallowMount(MorphToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'Commentable',
|
||||
attribute: 'commentable',
|
||||
searchable: false,
|
||||
morphToRelationship: 'commentable',
|
||||
morphToId: 1,
|
||||
morphToType: 'posts',
|
||||
morphToTypes: [
|
||||
{
|
||||
display: 'Post',
|
||||
type: 'App\\Nova\\Post',
|
||||
value: 'posts',
|
||||
},
|
||||
{
|
||||
display: 'Video',
|
||||
type: 'App\\Nova\\Video',
|
||||
value: 'videos',
|
||||
},
|
||||
],
|
||||
// resourceName: 'users',
|
||||
},
|
||||
resourceName: '',
|
||||
viaRelationship: '',
|
||||
viaResource: '',
|
||||
viaResourceId: null,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.initializingWithExistingResource).toBe(true)
|
||||
expect(wrapper.vm.editingExistingResource).toBe(true)
|
||||
expect(wrapper.vm.resourceType).toBe('posts')
|
||||
expect(wrapper.vm.withTrashed).toBe(false)
|
||||
expect(wrapper.vm.isSearchable).toBe(false)
|
||||
expect(wrapper.vm.shouldSelectInitialResource).toBe(true)
|
||||
expect(wrapper.vm.queryParams).toEqual({
|
||||
params: {
|
||||
type: 'posts',
|
||||
current: 1,
|
||||
first: false,
|
||||
search: '',
|
||||
withTrashed: false,
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.availableResources).toEqual([
|
||||
{ value: 1 },
|
||||
{ value: 2 },
|
||||
{ value: 3 },
|
||||
])
|
||||
expect(wrapper.vm.softDeletes).toBe(true)
|
||||
expect(wrapper.vm.selectedResource).toEqual({ value: 1 })
|
||||
expect(wrapper.vm.selectedResourceId).toBe(1)
|
||||
})
|
||||
|
||||
test('when editing an existing resource and the field is searchable it selects the related resource type and resource and doesnt load all the resources', async () => {
|
||||
const wrapper = shallowMount(MorphToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'Commentable',
|
||||
attribute: 'commentable',
|
||||
searchable: true,
|
||||
morphToRelationship: 'commentable',
|
||||
morphToId: 1,
|
||||
morphToType: 'posts',
|
||||
morphToTypes: [
|
||||
{
|
||||
display: 'Post',
|
||||
type: 'App\\Nova\\Post',
|
||||
value: 'posts',
|
||||
},
|
||||
{
|
||||
display: 'Video',
|
||||
type: 'App\\Nova\\Video',
|
||||
value: 'videos',
|
||||
},
|
||||
],
|
||||
// resourceName: 'users',
|
||||
},
|
||||
resourceName: '',
|
||||
viaRelationship: '',
|
||||
viaResource: '',
|
||||
viaResourceId: null,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.initializingWithExistingResource).toBe(true)
|
||||
expect(wrapper.vm.editingExistingResource).toBe(true)
|
||||
expect(wrapper.vm.resourceType).toBe('posts')
|
||||
expect(wrapper.vm.withTrashed).toBe(false)
|
||||
expect(wrapper.vm.isSearchable).toBe(true)
|
||||
expect(wrapper.vm.shouldSelectInitialResource).toBe(true)
|
||||
expect(wrapper.vm.queryParams).toEqual({
|
||||
params: {
|
||||
type: 'posts',
|
||||
current: 1,
|
||||
first: true,
|
||||
search: '',
|
||||
withTrashed: false,
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.availableResources).toEqual([{ value: 1 }])
|
||||
expect(wrapper.vm.softDeletes).toBe(true)
|
||||
expect(wrapper.vm.selectedResource).toEqual({ value: 1 })
|
||||
expect(wrapper.vm.selectedResourceId).toBe(1)
|
||||
})
|
||||
|
||||
test('it determines if its related resource soft deletes', async () => {
|
||||
const wrapper = shallowMount(MorphToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'Commentable',
|
||||
attribute: 'commentable',
|
||||
searchable: false,
|
||||
morphToRelationship: 'commentable',
|
||||
morphToId: null,
|
||||
morphToType: null,
|
||||
morphToTypes: [
|
||||
{
|
||||
display: 'Post',
|
||||
type: 'App\\Nova\\Post',
|
||||
value: 'posts',
|
||||
},
|
||||
{
|
||||
display: 'Video',
|
||||
type: 'App\\Nova\\Video',
|
||||
value: 'videos',
|
||||
},
|
||||
],
|
||||
// resourceName: 'users',
|
||||
},
|
||||
resourceName: '',
|
||||
viaRelationship: '',
|
||||
viaResource: 'posts',
|
||||
viaResourceId: 1,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.softDeletes).toBe(false)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.softDeletes).toBe(true)
|
||||
})
|
||||
|
||||
test('including trashed resources in the available resources can be enabled when a resource supports soft-deleting', async () => {
|
||||
const wrapper = shallowMount(MorphToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'Commentable',
|
||||
attribute: 'commentable',
|
||||
searchable: false,
|
||||
morphToRelationship: 'commentable',
|
||||
morphToId: null,
|
||||
morphToType: null,
|
||||
morphToTypes: [
|
||||
{
|
||||
display: 'Post',
|
||||
type: 'App\\Nova\\Post',
|
||||
value: 'posts',
|
||||
},
|
||||
{
|
||||
display: 'Video',
|
||||
type: 'App\\Nova\\Video',
|
||||
value: 'videos',
|
||||
},
|
||||
],
|
||||
},
|
||||
resourceName: '',
|
||||
viaRelationship: '',
|
||||
viaResource: 'posts',
|
||||
viaResourceId: 1,
|
||||
},
|
||||
})
|
||||
|
||||
wrapper.vm.enableWithTrashed()
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.softDeletes).toBe(true)
|
||||
expect(wrapper.vm.withTrashed).toBe(true)
|
||||
expect(wrapper.vm.queryParams).toEqual({
|
||||
params: {
|
||||
current: 1,
|
||||
first: false,
|
||||
search: '',
|
||||
type: 'posts',
|
||||
withTrashed: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('including trashed resources in the available resources cannot be enabled when a resource doesnt support soft-deleting', async () => {
|
||||
const wrapper = shallowMount(MorphToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'Commentable',
|
||||
attribute: 'commentable',
|
||||
searchable: false,
|
||||
morphToRelationship: 'commentable',
|
||||
morphToId: null,
|
||||
morphToType: null,
|
||||
morphToTypes: [
|
||||
{
|
||||
display: 'Post',
|
||||
type: 'App\\Nova\\Post',
|
||||
value: 'posts',
|
||||
},
|
||||
{
|
||||
display: 'Video',
|
||||
type: 'App\\Nova\\Video',
|
||||
value: 'videos',
|
||||
},
|
||||
],
|
||||
},
|
||||
resourceName: '',
|
||||
viaRelationship: '',
|
||||
viaResource: 'videos',
|
||||
viaResourceId: 1,
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.softDeletes).toBe(false)
|
||||
expect(wrapper.vm.withTrashed).toBe(false)
|
||||
expect(wrapper.vm.queryParams).toEqual({
|
||||
params: {
|
||||
current: 1,
|
||||
first: false,
|
||||
search: '',
|
||||
type: 'videos',
|
||||
withTrashed: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('including trashed resources in the available resources is disabled by default', async () => {
|
||||
const wrapper = shallowMount(MorphToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'Commentable',
|
||||
attribute: 'commentable',
|
||||
searchable: false,
|
||||
morphToRelationship: 'commentable',
|
||||
morphToId: null,
|
||||
morphToType: null,
|
||||
morphToTypes: [
|
||||
{
|
||||
display: 'Post',
|
||||
type: 'App\\Nova\\Post',
|
||||
value: 'posts',
|
||||
},
|
||||
{
|
||||
display: 'Video',
|
||||
type: 'App\\Nova\\Video',
|
||||
value: 'videos',
|
||||
},
|
||||
],
|
||||
},
|
||||
resourceName: '',
|
||||
viaRelationship: '',
|
||||
viaResource: 'videos',
|
||||
viaResourceId: 1,
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.withTrashed).toBe(false)
|
||||
})
|
||||
|
||||
test('it determines if its related resource doesnt soft delete', async () => {
|
||||
const wrapper = shallowMount(MorphToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'Commentable',
|
||||
attribute: 'commentable',
|
||||
searchable: false,
|
||||
morphToRelationship: 'commentable',
|
||||
morphToId: null,
|
||||
morphToType: null,
|
||||
morphToTypes: [
|
||||
{
|
||||
display: 'Post',
|
||||
type: 'App\\Nova\\Post',
|
||||
value: 'posts',
|
||||
},
|
||||
{
|
||||
display: 'Video',
|
||||
type: 'App\\Nova\\Video',
|
||||
value: 'videos',
|
||||
},
|
||||
],
|
||||
},
|
||||
resourceName: '',
|
||||
viaRelationship: '',
|
||||
viaResource: 'posts',
|
||||
viaResourceId: 1,
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.softDeletes).toBe(true)
|
||||
|
||||
wrapper.setData({ resourceType: 'videos' })
|
||||
wrapper.vm.determineIfSoftDeletes()
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.softDeletes).toBe(false)
|
||||
})
|
||||
|
||||
test('it correctly handles filling the formData object for submit', async () => {
|
||||
const wrapper = shallowMount(MorphToField, {
|
||||
stubs: ['default-field'],
|
||||
propsData: {
|
||||
field: {
|
||||
name: 'Commentable',
|
||||
attribute: 'commentable',
|
||||
searchable: false,
|
||||
morphToRelationship: 'commentable',
|
||||
morphToId: null,
|
||||
morphToType: null,
|
||||
morphToTypes: [
|
||||
{
|
||||
display: 'Post',
|
||||
type: 'App\\Nova\\Post',
|
||||
value: 'posts',
|
||||
},
|
||||
{
|
||||
display: 'Video',
|
||||
type: 'App\\Nova\\Video',
|
||||
value: 'videos',
|
||||
},
|
||||
],
|
||||
},
|
||||
resourceName: '',
|
||||
viaRelationship: '',
|
||||
viaResource: 'posts',
|
||||
viaResourceId: 1,
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const expectedFormData = new FormData()
|
||||
expectedFormData.append('commentable', 1)
|
||||
expectedFormData.append('commentable_type', 'posts')
|
||||
expectedFormData.append('commentable_trashed', false)
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
wrapper.vm.field.fill(formData)
|
||||
|
||||
expect(formData).toEqual(expectedFormData)
|
||||
})
|
||||
})
|
||||
66
nova/resources/js/__tests__/views/ResourceIndex.spec.js
vendored
Executable file
66
nova/resources/js/__tests__/views/ResourceIndex.spec.js
vendored
Executable file
@@ -0,0 +1,66 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { createRenderer } from 'vue-server-renderer'
|
||||
import Index from '@/views/Index.vue'
|
||||
|
||||
// Create a renderer for snapshot testing
|
||||
const renderer = createRenderer()
|
||||
|
||||
// Nova global mock
|
||||
// class Nova {
|
||||
// constructor(config) {}
|
||||
|
||||
// request() {
|
||||
// return {
|
||||
// get() {
|
||||
// return { data: {} }
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// global.Nova = new Nova()
|
||||
|
||||
describe('Index.vue', () => {
|
||||
it('renders', () => {
|
||||
const wrapper = shallowMount(Index, {
|
||||
stubs: ['loading-view', 'cards'],
|
||||
propsData: {
|
||||
resourceName: 'posts',
|
||||
},
|
||||
})
|
||||
renderer.renderToString(wrapper.vm, (err, str) => {
|
||||
if (err) throw new Error(err)
|
||||
expect(str).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders after loading', () => {
|
||||
const wrapper = shallowMount(Index, {
|
||||
stubs: ['loading-view', 'cards'],
|
||||
propsData: {
|
||||
resourceName: 'posts',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.initialLoading).toEqual(false)
|
||||
})
|
||||
|
||||
it('should show its cards', () => {
|
||||
const $route = { params: { resourceName: 'posts' } }
|
||||
const wrapper = shallowMount(Index, {
|
||||
stubs: ['loading-view', 'cards'],
|
||||
mocks: {
|
||||
$route,
|
||||
},
|
||||
propsData: {
|
||||
resourceName: 'posts',
|
||||
},
|
||||
})
|
||||
|
||||
// wrapper.setData({
|
||||
// cards: [{}],
|
||||
// })
|
||||
|
||||
expect(wrapper.vm.shouldShowCards).toEqual(true)
|
||||
})
|
||||
})
|
||||
46
nova/resources/js/__tests__/views/UpdateAttached.spec.js
vendored
Executable file
46
nova/resources/js/__tests__/views/UpdateAttached.spec.js
vendored
Executable file
@@ -0,0 +1,46 @@
|
||||
import { mount, shallowMount, createLocalVue } from '@vue/test-utils'
|
||||
import UpdateAttached from '@/views/UpdateAttached'
|
||||
// import flushPromises from 'flush-promises'
|
||||
|
||||
describe('UpdateAttached', () => {
|
||||
test('it loads all the available resources if its not searchable', () => {
|
||||
window.Nova = {}
|
||||
window.Nova.config = {
|
||||
resources: [
|
||||
{
|
||||
uriKey: 'users',
|
||||
label: 'Users',
|
||||
singularLabel: 'User',
|
||||
authorizedToCreate: true,
|
||||
searchable: false,
|
||||
},
|
||||
{
|
||||
uriKey: 'roles',
|
||||
label: 'Roles',
|
||||
singularLabel: 'Role',
|
||||
authorizedToCreate: true,
|
||||
searchable: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const wrapper = mount(UpdateAttached, {
|
||||
propsData: {
|
||||
resourceName: 'users',
|
||||
resourceId: 100,
|
||||
relatedResourceName: 'roles',
|
||||
relatedResourceId: 25,
|
||||
// viaResource: {},
|
||||
// viaResourceId: {},
|
||||
// viaRelationship: {},
|
||||
// polymorphic: false,
|
||||
},
|
||||
})
|
||||
|
||||
wrapper.setData({
|
||||
field: {},
|
||||
})
|
||||
|
||||
// expect(wrapper).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Index.vue renders 1`] = `<loading-view-stub></loading-view-stub>`;
|
||||
@@ -0,0 +1,3 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`UpdateAttached it loads all the available resources if its not searchable 1`] = `<div class="card overflow-hidden"></div>`;
|
||||
42
nova/resources/js/app.js
vendored
Executable file
42
nova/resources/js/app.js
vendored
Executable file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* First we will load all of this project's JavaScript dependencies which
|
||||
* includes Vue and other libraries. It is a great starting point when
|
||||
* building robust, powerful web applications using Vue and Laravel.
|
||||
*/
|
||||
import Vue from 'vue'
|
||||
import Nova from './Nova'
|
||||
import './plugins'
|
||||
import Localization from '@/mixins/Localization'
|
||||
import ThemingClasses from '@/mixins/ThemingClasses'
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
Vue.mixin(Localization)
|
||||
|
||||
/**
|
||||
* If configured, register a global mixin to add theming-friendly CSS
|
||||
* classnames to Nova's built-in Vue components. This allows the user
|
||||
* to fully customize Nova's theme to their project's branding.
|
||||
*/
|
||||
if (window.config.themingClasses) {
|
||||
Vue.mixin(ThemingClasses)
|
||||
}
|
||||
|
||||
/**
|
||||
* Next, we'll setup some of Nova's Vue components that need to be global
|
||||
* so that they are always available. Then, we will be ready to create
|
||||
* the actual Vue instance and start up this JavaScript application.
|
||||
*/
|
||||
import './fields'
|
||||
import './components'
|
||||
|
||||
/**
|
||||
* Finally, we'll create this Vue Router instance and register all of the
|
||||
* Nova routes. Once that is complete, we will create the Vue instance
|
||||
* and hand this router to the Vue instance. Then Nova is all ready!
|
||||
*/
|
||||
;(function () {
|
||||
this.CreateNova = function (config) {
|
||||
return new Nova(config)
|
||||
}
|
||||
}.call(window))
|
||||
204
nova/resources/js/components.js
vendored
Executable file
204
nova/resources/js/components.js
vendored
Executable file
@@ -0,0 +1,204 @@
|
||||
import Vue from 'vue'
|
||||
Vue.config.ignoredElements = ['trix-editor']
|
||||
|
||||
import Add from '@/components/Icons/Add'
|
||||
import ActionSelector from '@/components/ActionSelector'
|
||||
import BasePartitionMetric from '@/components/Metrics/Base/PartitionMetric'
|
||||
import BaseTrendMetric from '@/components/Metrics/Base/TrendMetric'
|
||||
import BaseValueMetric from '@/components/Metrics/Base/ValueMetric'
|
||||
import Bold from '@/components/Icons/Editor/Bold'
|
||||
import BooleanIcon from '@/components/Icons/BooleanIcon'
|
||||
import CancelButton from '@/components/Form/CancelButton'
|
||||
import Card from '@/components/Card'
|
||||
import Cards from '@/components/Cards'
|
||||
import CheckCircle from '@/components/Icons/CheckCircle'
|
||||
import CardWrapper from '@/components/CardWrapper'
|
||||
import Checkbox from '@/components/Index/Checkbox'
|
||||
import CheckboxWithLabel from '@/components/CheckboxWithLabel'
|
||||
import ConfirmActionModal from '@/components/Modals/ConfirmActionModal'
|
||||
import ConfirmUploadRemovalModal from '@/components/Modals/ConfirmUploadRemovalModal'
|
||||
import CreateResourceButton from '@/components/CreateResourceButton'
|
||||
import CustomAttachHeader from '@/components/CustomAttachHeader'
|
||||
import CreateRelationModal from '@/components/Modals/CreateRelationModal'
|
||||
import CreateRelationButton from '@/components/Form/CreateRelationButton'
|
||||
import CreateForm from '@/components/CreateForm'
|
||||
import CustomCreateHeader from '@/components/CustomCreateHeader'
|
||||
import CustomDashboardHeader from '@/components/CustomDashboardHeader'
|
||||
import CustomDetailHeader from '@/components/CustomDetailHeader'
|
||||
import CustomDetailToolbar from '@/components/CustomDetailToolbar'
|
||||
import CustomLensHeader from '@/components/CustomLensHeader'
|
||||
import CustomIndexHeader from '@/components/CustomIndexHeader'
|
||||
import CustomIndexToolbar from '@/components/CustomIndexToolbar'
|
||||
import CustomUpdateHeader from '@/components/CustomUpdateHeader'
|
||||
import CustomUpdateAttachedHeader from '@/components/CustomUpdateAttachedHeader'
|
||||
import Delete from '@/components/Icons/Delete'
|
||||
import Menu from '@/components/Icons/Menu'
|
||||
import DeleteMenu from '@/components/DeleteMenu'
|
||||
import DeleteResourceModal from '@/components/Modals/DeleteResourceModal'
|
||||
import Download from '@/components/Icons/Download'
|
||||
import Dropdown from '@/components/Dropdown'
|
||||
import DropdownMenu from '@/components/DropdownMenu'
|
||||
import DropdownTrigger from '@/components/DropdownTrigger'
|
||||
import Edit from '@/components/Icons/Edit'
|
||||
import Error403 from '@/views/Error403'
|
||||
import Error404 from '@/views/Error404'
|
||||
import Excerpt from '@/components/Excerpt'
|
||||
import FadeTransition from '@/components/FadeTransition'
|
||||
import FakeCheckbox from '@/components/Index/FakeCheckbox'
|
||||
import Filter from '@/components/Icons/Filter'
|
||||
import FilterMenu from '@/components/FilterMenu'
|
||||
import FormPanel from '@/components/Form/Panel'
|
||||
import ForceDelete from '@/components/Icons/ForceDelete'
|
||||
import FullScreen from '@/components/Icons/Editor/FullScreen'
|
||||
import GlobalSearch from '@/components/GlobalSearch'
|
||||
import Heading from '@/components/Heading'
|
||||
import HelpCard from '@/components/Cards/HelpCard'
|
||||
import HelpText from '@/components/Form/HelpText'
|
||||
import HelpIcon from '@/components/Icons/Help'
|
||||
import Icon from '@/components/Icons/Icon'
|
||||
import Image from '@/components/Icons/Editor/Image'
|
||||
import Index from './views/Index'
|
||||
import Italic from '@/components/Icons/Editor/Italic'
|
||||
import Label from '@/components/Form/Label'
|
||||
import Lens from '@/views/Lens'
|
||||
import LensSelector from '@/components/LensSelector'
|
||||
import Link from '@/components/Icons/Editor/Link'
|
||||
import Loader from '@/components/Icons/Loader'
|
||||
import LoadingCard from '@/components/LoadingCard'
|
||||
import LoadingView from '@/components/LoadingView'
|
||||
import More from '@/components/Icons/More'
|
||||
import Modal from '@/components/Modal'
|
||||
import PaginationLoadMore from '@/components/Pagination/PaginationLoadMore'
|
||||
import PaginationLinks from '@/components/Pagination/PaginationLinks'
|
||||
import PaginationSimple from '@/components/Pagination/PaginationSimple'
|
||||
import PanelItem from '@/components/PanelItem'
|
||||
import PartitionMetric from '@/components/Metrics/PartitionMetric'
|
||||
import Play from '@/components/Icons/Play'
|
||||
import ProgressButton from '@/components/ProgressButton'
|
||||
import Refresh from '@/components/Icons/Refresh'
|
||||
import ResourcePollingButton from '@/components/ResourcePollingButton'
|
||||
import ResourceTable from '@/components/ResourceTable'
|
||||
import ResourceTableRow from '@/components/Index/ResourceTableRow'
|
||||
import InlineActionSelector from '@/components/Index/InlineActionSelector'
|
||||
import Restore from '@/components/Icons/Restore'
|
||||
import RestoreResourceModal from '@/components/Modals/RestoreResourceModal'
|
||||
import ScrollWrap from '@/components/ScrollWrap'
|
||||
import Search from '@/components/Icons/Search'
|
||||
import SearchInput from '@/components/SearchInput'
|
||||
import SortableIcon from '@/components/Index/SortableIcon'
|
||||
import TrendMetric from '@/components/Metrics/TrendMetric'
|
||||
import Tooltip from '@/components/Tooltip'
|
||||
import TooltipContent from '@/components/TooltipContent'
|
||||
import ValidationErrors from '@/components/ValidationErrors'
|
||||
import ValueMetric from '@/components/Metrics/ValueMetric'
|
||||
import View from '@/components/Icons/View'
|
||||
import XCircle from '@/components/Icons/XCircle'
|
||||
|
||||
import SelectFilter from '@/components/Filters/SelectFilter'
|
||||
import BooleanFilter from '@/components/Filters/BooleanFilter'
|
||||
import DateFilter from '@/components/Filters/DateFilter'
|
||||
|
||||
import SelectControl from '@/components/Controls/SelectControl'
|
||||
import DateTimePicker from '@/components/DateTimePicker'
|
||||
|
||||
Vue.component('action-selector', ActionSelector)
|
||||
Vue.component('boolean-icon', BooleanIcon)
|
||||
Vue.component('base-partition-metric', BasePartitionMetric)
|
||||
Vue.component('base-trend-metric', BaseTrendMetric)
|
||||
Vue.component('base-value-metric', BaseValueMetric)
|
||||
Vue.component('card', Card)
|
||||
Vue.component('card-wrapper', CardWrapper)
|
||||
Vue.component('cards', Cards)
|
||||
Vue.component('cancel-button', CancelButton)
|
||||
Vue.component('checkbox', Checkbox)
|
||||
Vue.component('checkbox-with-label', CheckboxWithLabel)
|
||||
Vue.component('confirm-action-modal', ConfirmActionModal)
|
||||
Vue.component('confirm-upload-removal-modal', ConfirmUploadRemovalModal)
|
||||
Vue.component('create-resource-button', CreateResourceButton)
|
||||
Vue.component('custom-attach-header', CustomAttachHeader)
|
||||
Vue.component('custom-create-header', CustomCreateHeader)
|
||||
Vue.component('custom-dashboard-header', CustomDashboardHeader)
|
||||
Vue.component('custom-detail-header', CustomDetailHeader)
|
||||
Vue.component('custom-detail-toolbar', CustomDetailToolbar)
|
||||
Vue.component('custom-lens-header', CustomLensHeader)
|
||||
Vue.component('custom-index-header', CustomIndexHeader)
|
||||
Vue.component('custom-index-toolbar', CustomIndexToolbar)
|
||||
Vue.component('custom-update-header', CustomUpdateHeader)
|
||||
Vue.component('custom-update-attached-header', CustomUpdateAttachedHeader)
|
||||
Vue.component('create-relation-modal', CreateRelationModal)
|
||||
Vue.component('create-relation-button', CreateRelationButton)
|
||||
Vue.component('create-form', CreateForm)
|
||||
Vue.component('delete-menu', DeleteMenu)
|
||||
Vue.component('delete-resource-modal', DeleteResourceModal)
|
||||
Vue.component('dropdown', Dropdown)
|
||||
Vue.component('dropdown-menu', DropdownMenu)
|
||||
Vue.component('dropdown-trigger', DropdownTrigger)
|
||||
Vue.component('editor-bold', Bold)
|
||||
Vue.component('editor-fullscreen', FullScreen)
|
||||
Vue.component('editor-image', Image)
|
||||
Vue.component('editor-italic', Italic)
|
||||
Vue.component('editor-link', Link)
|
||||
Vue.component('error-403', Error403)
|
||||
Vue.component('error-404', Error404)
|
||||
Vue.component('excerpt', Excerpt)
|
||||
Vue.component('fake-checkbox', FakeCheckbox)
|
||||
Vue.component('filter-menu', FilterMenu)
|
||||
Vue.component('form-label', Label)
|
||||
Vue.component('global-search', GlobalSearch)
|
||||
Vue.component('heading', Heading)
|
||||
Vue.component('help', HelpCard)
|
||||
Vue.component('help-text', HelpText)
|
||||
Vue.component('icon', Icon)
|
||||
Vue.component('icon-add', Add)
|
||||
Vue.component('icon-check-circle', CheckCircle)
|
||||
Vue.component('icon-x-circle', XCircle)
|
||||
Vue.component('icon-delete', Delete)
|
||||
Vue.component('icon-download', Download)
|
||||
Vue.component('icon-edit', Edit)
|
||||
Vue.component('icon-filter', Filter)
|
||||
Vue.component('icon-force-delete', ForceDelete)
|
||||
Vue.component('icon-help', HelpIcon)
|
||||
Vue.component('icon-more', More)
|
||||
Vue.component('icon-play', Play)
|
||||
Vue.component('icon-refresh', Refresh)
|
||||
Vue.component('icon-restore', Restore)
|
||||
Vue.component('icon-search', Search)
|
||||
Vue.component('icon-view', View)
|
||||
Vue.component('icon-menu', Menu)
|
||||
Vue.component('inline-action-selector', InlineActionSelector)
|
||||
Vue.component('lens', Lens)
|
||||
Vue.component('lens-selector', LensSelector)
|
||||
Vue.component('loader', Loader)
|
||||
Vue.component('loading-card', LoadingCard)
|
||||
Vue.component('loading-view', LoadingView)
|
||||
Vue.component('modal', Modal)
|
||||
Vue.component('pagination-load-more', PaginationLoadMore)
|
||||
Vue.component('pagination-links', PaginationLinks)
|
||||
Vue.component('pagination-simple', PaginationSimple)
|
||||
Vue.component('panel-item', PanelItem)
|
||||
Vue.component('form-panel', FormPanel)
|
||||
Vue.component('partition-metric', PartitionMetric)
|
||||
Vue.component('progress-button', ProgressButton)
|
||||
Vue.component('resource-index', Index)
|
||||
Vue.component('resource-table', ResourceTable)
|
||||
Vue.component('resource-table-row', ResourceTableRow)
|
||||
Vue.component('restore-resource-modal', RestoreResourceModal)
|
||||
Vue.component('tooltip', Tooltip)
|
||||
Vue.component('tooltip-content', TooltipContent)
|
||||
Vue.component('scroll-wrap', ScrollWrap)
|
||||
Vue.component('search-input', SearchInput)
|
||||
Vue.component('sortable-icon', SortableIcon)
|
||||
Vue.component('trend-metric', TrendMetric)
|
||||
Vue.component('validation-errors', ValidationErrors)
|
||||
Vue.component('value-metric', ValueMetric)
|
||||
|
||||
Vue.component('date-filter', DateFilter)
|
||||
Vue.component('select-filter', SelectFilter)
|
||||
Vue.component('boolean-filter', BooleanFilter)
|
||||
|
||||
Vue.component('select-control', SelectControl)
|
||||
Vue.component('date-time-picker', DateTimePicker)
|
||||
|
||||
Vue.component('fade-transition', FadeTransition)
|
||||
|
||||
Vue.component('resource-polling-button', ResourcePollingButton)
|
||||
124
nova/resources/js/components/ActionSelector.vue
Executable file
124
nova/resources/js/components/ActionSelector.vue
Executable file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="actions.length > 0 || availablePivotActions.length > 0"
|
||||
class="flex items-center mr-3"
|
||||
>
|
||||
<select
|
||||
data-testid="action-select"
|
||||
dusk="action-select"
|
||||
ref="selectBox"
|
||||
v-model="selectedActionKey"
|
||||
class="form-control form-select mr-2"
|
||||
>
|
||||
<option value="" disabled selected>{{ __('Select Action') }}</option>
|
||||
|
||||
<optgroup
|
||||
v-if="availableActions.length > 0"
|
||||
:label="resourceInformation.singularLabel"
|
||||
>
|
||||
<option
|
||||
v-for="action in availableActions"
|
||||
:value="action.uriKey"
|
||||
:key="action.urikey"
|
||||
:selected="action.uriKey == selectedActionKey"
|
||||
>
|
||||
{{ action.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
|
||||
<optgroup
|
||||
class="pivot-option-group"
|
||||
:label="pivotName"
|
||||
v-if="availablePivotActions.length > 0"
|
||||
>
|
||||
<option
|
||||
v-for="action in availablePivotActions"
|
||||
:value="action.uriKey"
|
||||
:key="action.urikey"
|
||||
:selected="action.uriKey == selectedActionKey"
|
||||
>
|
||||
{{ action.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
|
||||
<button
|
||||
data-testid="action-confirm"
|
||||
dusk="run-action-button"
|
||||
@click.prevent="determineActionStrategy"
|
||||
:disabled="!selectedAction"
|
||||
class="btn btn-default btn-primary flex items-center justify-center px-3"
|
||||
:class="{ 'btn-disabled': !selectedAction }"
|
||||
:title="__('Run Action')"
|
||||
>
|
||||
<icon type="play" class="text-white" style="margin-left: 7px" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Action Confirmation Modal -->
|
||||
<portal to="modals" transition="fade-transition">
|
||||
<component
|
||||
v-if="confirmActionModalOpened"
|
||||
class="text-left"
|
||||
:is="selectedAction.component"
|
||||
:working="working"
|
||||
:selected-resources="selectedResources"
|
||||
:resource-name="resourceName"
|
||||
:action="selectedAction"
|
||||
:errors="errors"
|
||||
@confirm="executeAction"
|
||||
@close="closeConfirmationModal"
|
||||
/>
|
||||
|
||||
<component
|
||||
:is="actionResponseData.modal"
|
||||
@close="closeActionResponseModal"
|
||||
v-if="showActionResponseModal"
|
||||
:data="actionResponseData"
|
||||
/>
|
||||
</portal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _ from 'lodash'
|
||||
import HandlesActions from '@/mixins/HandlesActions'
|
||||
import { InteractsWithResourceInformation } from 'laravel-nova'
|
||||
|
||||
export default {
|
||||
mixins: [InteractsWithResourceInformation, HandlesActions],
|
||||
|
||||
props: {
|
||||
selectedResources: {
|
||||
type: [Array, String],
|
||||
default: () => [],
|
||||
},
|
||||
pivotActions: {},
|
||||
pivotName: String,
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
showActionResponseModal: false,
|
||||
actionResponseData: {},
|
||||
}),
|
||||
|
||||
watch: {
|
||||
/**
|
||||
* Watch the actions property for changes.
|
||||
*/
|
||||
actions() {
|
||||
this.selectedActionKey = ''
|
||||
this.initializeActionFields()
|
||||
},
|
||||
|
||||
/**
|
||||
* Watch the pivot actions property for changes.
|
||||
*/
|
||||
pivotActions() {
|
||||
this.selectedActionKey = ''
|
||||
this.initializeActionFields()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
24
nova/resources/js/components/Badge.vue
Executable file
24
nova/resources/js/components/Badge.vue
Executable file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<span
|
||||
class="whitespace-no-wrap px-2 py-1 rounded-full uppercase text-xs font-bold"
|
||||
:class="extraClasses"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
|
||||
extraClasses: {
|
||||
type: [Array, String],
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
53
nova/resources/js/components/BooleanOption.vue
Executable file
53
nova/resources/js/components/BooleanOption.vue
Executable file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div>
|
||||
<checkbox-with-label
|
||||
class="m-2"
|
||||
:checked="isChecked"
|
||||
@input="updateCheckedState(option.value, $event.target.checked)"
|
||||
>
|
||||
{{ option.name }}
|
||||
</checkbox-with-label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Checkbox from '@/components/Index/Checkbox'
|
||||
|
||||
export default {
|
||||
components: { Checkbox },
|
||||
|
||||
props: {
|
||||
resourceName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filter: Object,
|
||||
option: Object,
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateCheckedState(optionKey, checked) {
|
||||
let oldValue = this.filter.currentValue
|
||||
let newValue = { ...oldValue, [optionKey]: checked }
|
||||
|
||||
this.$store.commit(`${this.resourceName}/updateFilterState`, {
|
||||
filterClass: this.filter.class,
|
||||
value: newValue,
|
||||
})
|
||||
|
||||
this.$emit('change')
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
isChecked() {
|
||||
return (
|
||||
this.$store.getters[`${this.resourceName}/filterOptionValue`](
|
||||
this.filter.class,
|
||||
this.option.value
|
||||
) == true
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
3
nova/resources/js/components/Card.vue
Executable file
3
nova/resources/js/components/Card.vue
Executable file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div class="card"><slot /></div>
|
||||
</template>
|
||||
78
nova/resources/js/components/CardWrapper.vue
Executable file
78
nova/resources/js/components/CardWrapper.vue
Executable file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
class="px-3 mb-6"
|
||||
:class="widthClass"
|
||||
:key="`${card.component}.${card.uriKey}`"
|
||||
>
|
||||
<component
|
||||
:class="cardSizeClass"
|
||||
:is="card.component"
|
||||
:card="card"
|
||||
:resource="resource"
|
||||
:resourceName="resourceName"
|
||||
:resourceId="resourceId"
|
||||
:lens="lens"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { CardSizes } from 'laravel-nova'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
card: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
size: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
resource: {
|
||||
type: Object,
|
||||
},
|
||||
|
||||
resourceName: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
resourceId: {
|
||||
type: [Number, String],
|
||||
},
|
||||
|
||||
lens: {
|
||||
lens: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* The class given to the card wrappers based on its width
|
||||
*/
|
||||
widthClass() {
|
||||
// return 'w-full'
|
||||
// If we're passing in 'large' as the value we want to force the
|
||||
// cards to be given the `w-full` class, otherwise we're letting
|
||||
// the card decide for itself based on its configuration
|
||||
return this.size == 'large' ? 'w-full' : calculateCardWidth(this.card)
|
||||
},
|
||||
|
||||
/**
|
||||
* The class given to the card based on its size
|
||||
*/
|
||||
cardSizeClass() {
|
||||
return this.size !== 'large' ? 'card-panel' : ''
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function calculateCardWidth(card) {
|
||||
// If the card's width is found in the accepted sizes return that class,
|
||||
// or return the default 1/3 class
|
||||
return CardSizes.indexOf(card.width) !== -1 ? `w-${card.width}` : 'w-1/3'
|
||||
}
|
||||
</script>
|
||||
62
nova/resources/js/components/Cards.vue
Executable file
62
nova/resources/js/components/Cards.vue
Executable file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div v-if="filteredCards.length > 0" class="flex flex-wrap -mx-3">
|
||||
<card-wrapper
|
||||
v-for="card in filteredCards"
|
||||
:card="card"
|
||||
:size="size"
|
||||
:resource="resource"
|
||||
:resource-name="resourceName"
|
||||
:resource-id="resourceId"
|
||||
:key="`${card.component}.${card.uriKey}`"
|
||||
:lens="lens"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
cards: Array,
|
||||
|
||||
size: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
resource: {
|
||||
type: Object,
|
||||
},
|
||||
|
||||
resourceName: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
resourceId: {
|
||||
type: [Number, String],
|
||||
},
|
||||
|
||||
onlyOnDetail: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
lens: {
|
||||
lens: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Determine whether to show the cards based on their onlyOnDetail configuration
|
||||
*/
|
||||
filteredCards() {
|
||||
if (this.onlyOnDetail) {
|
||||
return _.filter(this.cards, c => c.onlyOnDetail == true)
|
||||
}
|
||||
|
||||
return _.filter(this.cards, c => c.onlyOnDetail == false)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
225
nova/resources/js/components/Cards/HelpCard.vue
Executable file
225
nova/resources/js/components/Cards/HelpCard.vue
Executable file
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div class="flex justify-center items-centers">
|
||||
<div class="w-full max-w-xl">
|
||||
<heading class="flex mb-3">Get Started</heading>
|
||||
<p class="text-90 leading-tight mb-8">
|
||||
Welcome to Nova! Get familiar with Nova and explore its features in the
|
||||
documentation:
|
||||
</p>
|
||||
|
||||
<card>
|
||||
<table class="w-full" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td class="align-top w-1/2 border-r border-b border-50">
|
||||
<a :href="resources" class="no-underline dim flex p-6">
|
||||
<div class="flex justify-center w-11 flex-no-shrink mr-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
>
|
||||
<path
|
||||
fill="var(--primary)"
|
||||
d="M31.51 25.86l7.32 7.31c1.0110617 1.0110616 1.4059262 2.4847161 1.035852 3.865852-.3700742 1.3811359-1.4488641 2.4599258-2.83 2.83-1.3811359.3700742-2.8547904-.0247903-3.865852-1.035852l-7.31-7.32c-7.3497931 4.4833975-16.89094893 2.7645226-22.21403734-4.0019419-5.3230884-6.7664645-4.74742381-16.4441086 1.34028151-22.53181393C11.0739495-1.11146115 20.7515936-1.68712574 27.5180581 3.63596266 34.2845226 8.95905107 36.0033975 18.5002069 31.52 25.85l-.01.01zm-3.99 4.5l7.07 7.05c.7935206.6795536 1.9763883.6338645 2.7151264-.1048736.7387381-.7387381.7844272-1.9216058.1048736-2.7151264l-7.06-7.07c-.8293081 1.0508547-1.7791453 2.0006919-2.83 2.83v.01zM17 32c8.2842712 0 15-6.7157288 15-15 0-8.28427125-6.7157288-15-15-15C8.71572875 2 2 8.71572875 2 17c0 8.2842712 6.71572875 15 15 15zm0-2C9.82029825 30 4 24.1797017 4 17S9.82029825 4 17 4c7.1797017 0 13 5.8202983 13 13s-5.8202983 13-13 13zm0-2c6.0751322 0 11-4.9248678 11-11S23.0751322 6 17 6 6 10.9248678 6 17s4.9248678 11 11 11z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<heading :level="3" class="mb-3">Resources</heading>
|
||||
<p class="text-90 leading-normal">
|
||||
Nova's resource manager allows you to quickly view and
|
||||
manage your Eloquent model records directly from Nova's
|
||||
intuitive interface.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<td class="align-top w-1/2 border-b border-50">
|
||||
<a :href="actions" class="no-underline dim flex p-6">
|
||||
<div class="flex justify-center w-11 flex-no-shrink mr-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="44"
|
||||
height="44"
|
||||
viewBox="0 0 44 44"
|
||||
>
|
||||
<path
|
||||
fill="var(--primary)"
|
||||
d="M22 44C9.8497355 44 0 34.1502645 0 22S9.8497355 0 22 0s22 9.8497355 22 22-9.8497355 22-22 22zm0-2c11.045695 0 20-8.954305 20-20S33.045695 2 22 2 2 10.954305 2 22s8.954305 20 20 20zm3-24h5c.3638839-.0007291.6994429.1962627.8761609.5143551.176718.3180924.1666987.707072-.0261609 1.0156449l-10 16C20.32 36.38 19 36 19 35v-9h-5c-.3638839.0007291-.6994429-.1962627-.8761609-.5143551-.176718-.3180924-.1666987-.707072.0261609-1.0156449l10-16C23.68 7.62 25 8 25 9v9zm3.2 2H24c-.5522847 0-1-.4477153-1-1v-6.51L15.8 24H20c.5522847 0 1 .4477153 1 1v6.51L28.2 20z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<heading :level="3" class="mb-3">Actions</heading>
|
||||
<p class="text-90 leading-normal">
|
||||
Actions perform tasks on a single record or an entire batch
|
||||
of records. Have an action that takes a while? No problem.
|
||||
Nova can queue them using Laravel's powerful queue system.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="align-top w-1/2 border-r border-b border-50">
|
||||
<a :href="filters" class="no-underline dim flex p-6">
|
||||
<div class="flex justify-center w-11 flex-no-shrink mr-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="38"
|
||||
height="38"
|
||||
viewBox="0 0 38 38"
|
||||
>
|
||||
<path
|
||||
fill="var(--primary)"
|
||||
d="M36 4V2H2v6.59l13.7 13.7c.1884143.1846305.296243.4362307.3.7v11.6l6-6v-5.6c.003757-.2637693.1115857-.5153695.3-.7L36 8.6V6H19c-.5522847 0-1-.44771525-1-1s.4477153-1 1-1h17zM.3 9.7C.11158574 9.51536954.00375705 9.26376927 0 9V1c0-.55228475.44771525-1 1-1h36c.5522847 0 1 .44771525 1 1v8c-.003757.26376927-.1115857.51536954-.3.7L24 23.42V29c-.003757.2637693-.1115857.5153695-.3.7l-8 8c-.2857003.2801197-.7108712.3629755-1.0808485.210632C14.2491743 37.7582884 14.0056201 37.4000752 14 37V23.4L.3 9.71V9.7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<heading :level="3" class="mb-3">Filters</heading>
|
||||
<p class="text-90 leading-normal">
|
||||
Write custom filters for your resource indexes to offer your
|
||||
users quick glances at different segments of your data.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<td class="align-top w-1/2 border-b border-50">
|
||||
<a :href="lenses" class="no-underline dim flex p-6">
|
||||
<div class="flex justify-center w-11 flex-no-shrink mr-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="36"
|
||||
height="36"
|
||||
viewBox="0 0 36 36"
|
||||
>
|
||||
<path
|
||||
fill="var(--primary)"
|
||||
d="M4 8C1.790861 8 0 6.209139 0 4s1.790861-4 4-4 4 1.790861 4 4-1.790861 4-4 4zm0-2c1.1045695 0 2-.8954305 2-2s-.8954305-2-2-2-2 .8954305-2 2 .8954305 2 2 2zm0 16c-2.209139 0-4-1.790861-4-4s1.790861-4 4-4 4 1.790861 4 4-1.790861 4-4 4zm0-2c1.1045695 0 2-.8954305 2-2s-.8954305-2-2-2-2 .8954305-2 2 .8954305 2 2 2zm0 16c-2.209139 0-4-1.790861-4-4s1.790861-4 4-4 4 1.790861 4 4-1.790861 4-4 4zm0-2c1.1045695 0 2-.8954305 2-2s-.8954305-2-2-2-2 .8954305-2 2 .8954305 2 2 2zm9-31h22c.5522847 0 1 .44771525 1 1s-.4477153 1-1 1H13c-.5522847 0-1-.44771525-1-1s.4477153-1 1-1zm0 14h22c.5522847 0 1 .4477153 1 1s-.4477153 1-1 1H13c-.5522847 0-1-.4477153-1-1s.4477153-1 1-1zm0 14h22c.5522847 0 1 .4477153 1 1s-.4477153 1-1 1H13c-.5522847 0-1-.4477153-1-1s.4477153-1 1-1z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<heading :level="3" class="mb-3">Lenses</heading>
|
||||
<p class="text-90 leading-normal">
|
||||
Need to customize a resource list a little more than a
|
||||
filter can provide? No problem. Add lenses to your resource
|
||||
to take full control over the entire Eloquent query.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="align-top w-1/2 border-r border-b border-50">
|
||||
<a :href="metrics" class="no-underline dim flex p-6">
|
||||
<div class="flex justify-center w-11 flex-no-shrink mr-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="37"
|
||||
height="36"
|
||||
viewBox="0 0 37 36"
|
||||
>
|
||||
<path
|
||||
fill="var(--primary)"
|
||||
d="M2 27h3c1.1045695 0 2 .8954305 2 2v5c0 1.1045695-.8954305 2-2 2H2c-1.1045695 0-2-.8954305-2-2v-5c0-1.1.9-2 2-2zm0 2v5h3v-5H2zm10-11h3c1.1045695 0 2 .8954305 2 2v14c0 1.1045695-.8954305 2-2 2h-3c-1.1045695 0-2-.8954305-2-2V20c0-1.1.9-2 2-2zm0 2v14h3V20h-3zM22 9h3c1.1045695 0 2 .8954305 2 2v23c0 1.1045695-.8954305 2-2 2h-3c-1.1045695 0-2-.8954305-2-2V11c0-1.1.9-2 2-2zm0 2v23h3V11h-3zM32 0h3c1.1045695 0 2 .8954305 2 2v32c0 1.1045695-.8954305 2-2 2h-3c-1.1045695 0-2-.8954305-2-2V2c0-1.1.9-2 2-2zm0 2v32h3V2h-3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<heading :level="3" class="mb-3">Metrics</heading>
|
||||
<p class="text-90 leading-normal">
|
||||
Nova makes it painless to quickly display custom metrics for
|
||||
your application. To put the cherry on top, we’ve included
|
||||
query helpers to make it all easy as pie.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<td class="align-top w-1/2 border-b border-50">
|
||||
<a :href="cards" class="no-underline dim flex p-6">
|
||||
<div class="flex justify-center w-11 flex-no-shrink mr-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="36"
|
||||
height="36"
|
||||
viewBox="0 0 36 36"
|
||||
>
|
||||
<path
|
||||
fill="var(--primary)"
|
||||
d="M29 7h5c.5522847 0 1 .44771525 1 1s-.4477153 1-1 1h-5v5c0 .5522847-.4477153 1-1 1s-1-.4477153-1-1V9h-5c-.5522847 0-1-.44771525-1-1s.4477153-1 1-1h5V2c0-.55228475.4477153-1 1-1s1 .44771525 1 1v5zM4 0h8c2.209139 0 4 1.790861 4 4v8c0 2.209139-1.790861 4-4 4H4c-2.209139 0-4-1.790861-4-4V4c0-2.209139 1.790861-4 4-4zm0 2c-1.1045695 0-2 .8954305-2 2v8c0 1.1.9 2 2 2h8c1.1045695 0 2-.8954305 2-2V4c0-1.1045695-.8954305-2-2-2H4zm20 18h8c2.209139 0 4 1.790861 4 4v8c0 2.209139-1.790861 4-4 4h-8c-2.209139 0-4-1.790861-4-4v-8c0-2.209139 1.790861-4 4-4zm0 2c-1.1045695 0-2 .8954305-2 2v8c0 1.1.9 2 2 2h8c1.1045695 0 2-.8954305 2-2v-8c0-1.1045695-.8954305-2-2-2h-8zM4 20h8c2.209139 0 4 1.790861 4 4v8c0 2.209139-1.790861 4-4 4H4c-2.209139 0-4-1.790861-4-4v-8c0-2.209139 1.790861-4 4-4zm0 2c-1.1045695 0-2 .8954305-2 2v8c0 1.1.9 2 2 2h8c1.1045695 0 2-.8954305 2-2v-8c0-1.1045695-.8954305-2-2-2H4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<heading :level="3" class="mb-3">Cards</heading>
|
||||
<p class="text-90 leading-normal">
|
||||
Nova offers CLI generators for scaffolding your own custom
|
||||
cards. We’ll give you a Vue component and infinite
|
||||
possibilities.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Help',
|
||||
|
||||
props: {
|
||||
card: Object,
|
||||
},
|
||||
|
||||
methods: {
|
||||
link(path) {
|
||||
return `https://nova.laravel.com/docs/${this.version}/${path}`
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
resources() {
|
||||
return this.link('resources')
|
||||
},
|
||||
actions() {
|
||||
return this.link('actions/defining-actions.html')
|
||||
},
|
||||
filters() {
|
||||
return this.link('filters/defining-filters.html')
|
||||
},
|
||||
lenses() {
|
||||
return this.link('lenses/defining-lenses.html')
|
||||
},
|
||||
metrics() {
|
||||
return this.link('metrics/defining-metrics.html')
|
||||
},
|
||||
cards() {
|
||||
return this.link('customization/cards.html')
|
||||
},
|
||||
version() {
|
||||
const parts = window.Nova.config.version.split('.')
|
||||
parts.splice(-2)
|
||||
|
||||
return `${parts}.0`
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
25
nova/resources/js/components/CheckboxWithLabel.vue
Executable file
25
nova/resources/js/components/CheckboxWithLabel.vue
Executable file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<label class="flex items-center select-none">
|
||||
<checkbox
|
||||
@input="$emit('input', $event)"
|
||||
:checked="checked"
|
||||
class="mr-2"
|
||||
:name="name"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<slot />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
checked: Boolean,
|
||||
name: { type: String, required: false },
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
68
nova/resources/js/components/Controls/SelectControl.vue
Executable file
68
nova/resources/js/components/Controls/SelectControl.vue
Executable file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<select v-bind="$attrs" :value="value" v-on="inputListeners">
|
||||
<slot />
|
||||
<template v-for="(options, group) in groupedOptions">
|
||||
<optgroup :label="group" v-if="group">
|
||||
<option v-for="option in options" v-bind="attrsFor(option)">
|
||||
{{ labelFor(option) }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<template v-else>
|
||||
<option v-for="option in options" v-bind="attrsFor(option)">
|
||||
{{ labelFor(option) }}
|
||||
</option>
|
||||
</template>
|
||||
</template>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
options: {
|
||||
default: [],
|
||||
},
|
||||
selected: {},
|
||||
label: {
|
||||
default: 'label',
|
||||
},
|
||||
value: {},
|
||||
},
|
||||
|
||||
computed: {
|
||||
groupedOptions() {
|
||||
return _.groupBy(this.options, option => option.group || '')
|
||||
},
|
||||
|
||||
inputListeners() {
|
||||
return _.assign({}, this.$listeners, {
|
||||
change: event => {
|
||||
this.$emit('input', event.target.value)
|
||||
this.$emit('change', event)
|
||||
},
|
||||
input: event => {
|
||||
this.$emit('input', event.target.value)
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
labelFor(option) {
|
||||
return this.label instanceof Function
|
||||
? this.label(option)
|
||||
: option[this.label]
|
||||
},
|
||||
|
||||
attrsFor(option) {
|
||||
return _.assign(
|
||||
{},
|
||||
option.attrs || {},
|
||||
{ value: option.value },
|
||||
this.selected !== void 0
|
||||
? { selected: this.selected == option.value }
|
||||
: {}
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
374
nova/resources/js/components/CreateForm.vue
Executable file
374
nova/resources/js/components/CreateForm.vue
Executable file
@@ -0,0 +1,374 @@
|
||||
<template>
|
||||
<loading-view :loading="loading">
|
||||
<custom-create-header class="mb-3" :resource-name="resourceName" />
|
||||
|
||||
<form
|
||||
v-if="panels"
|
||||
@submit="submitViaCreateResource"
|
||||
@change="onUpdateFormStatus"
|
||||
autocomplete="off"
|
||||
ref="form"
|
||||
>
|
||||
<form-panel
|
||||
class="mb-8"
|
||||
v-for="panel in panelsWithFields"
|
||||
@field-changed="onUpdateFormStatus"
|
||||
@file-upload-started="handleFileUploadStarted"
|
||||
@file-upload-finished="handleFileUploadFinished"
|
||||
:shown-via-new-relation-modal="shownViaNewRelationModal"
|
||||
:panel="panel"
|
||||
:name="panel.name"
|
||||
:key="panel.name"
|
||||
:resource-name="resourceName"
|
||||
:fields="panel.fields"
|
||||
mode="form"
|
||||
:validation-errors="validationErrors"
|
||||
:via-resource="viaResource"
|
||||
:via-resource-id="viaResourceId"
|
||||
:via-relationship="viaRelationship"
|
||||
/>
|
||||
|
||||
<!-- Create Button -->
|
||||
<div class="flex items-center">
|
||||
<cancel-button @click="$emit('cancelled-create')" />
|
||||
|
||||
<progress-button
|
||||
v-if="shouldShowAddAnotherButton"
|
||||
dusk="create-and-add-another-button"
|
||||
class="mr-3"
|
||||
@click.native="submitViaCreateResourceAndAddAnother"
|
||||
:disabled="isWorking"
|
||||
:processing="wasSubmittedViaCreateResourceAndAddAnother"
|
||||
>
|
||||
{{ __('Create & Add Another') }}
|
||||
</progress-button>
|
||||
|
||||
<progress-button
|
||||
dusk="create-button"
|
||||
type="submit"
|
||||
:disabled="isWorking"
|
||||
:processing="wasSubmittedViaCreateResource"
|
||||
>
|
||||
{{ createButtonLabel }}
|
||||
</progress-button>
|
||||
</div>
|
||||
</form>
|
||||
</loading-view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapProps,
|
||||
Errors,
|
||||
Minimum,
|
||||
InteractsWithResourceInformation,
|
||||
} from 'laravel-nova'
|
||||
import HandlesUploads from '@/mixins/HandlesUploads'
|
||||
|
||||
export default {
|
||||
mixins: [InteractsWithResourceInformation, HandlesUploads],
|
||||
|
||||
metaInfo() {
|
||||
if (this.shouldOverrideMeta && this.resourceInformation) {
|
||||
return {
|
||||
title: this.__('Create :resource', {
|
||||
resource: this.resourceInformation.singularLabel,
|
||||
}),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'form',
|
||||
validator: val => ['modal', 'form'].includes(val),
|
||||
},
|
||||
|
||||
updateFormStatus: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
|
||||
...mapProps([
|
||||
'resourceName',
|
||||
'viaResource',
|
||||
'viaResourceId',
|
||||
'viaRelationship',
|
||||
'shouldOverrideMeta',
|
||||
]),
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
relationResponse: null,
|
||||
loading: true,
|
||||
submittedViaCreateResourceAndAddAnother: false,
|
||||
submittedViaCreateResource: false,
|
||||
fields: [],
|
||||
panels: [],
|
||||
validationErrors: new Errors(),
|
||||
}),
|
||||
|
||||
async created() {
|
||||
if (Nova.missingResource(this.resourceName))
|
||||
return this.$router.push({ name: '404' })
|
||||
|
||||
// If this create is via a relation index, then let's grab the field
|
||||
// and use the label for that as the one we use for the title and buttons
|
||||
if (this.isRelation) {
|
||||
const { data } = await Nova.request().get(
|
||||
'/nova-api/' + this.viaResource + '/field/' + this.viaRelationship,
|
||||
{
|
||||
params: {
|
||||
resourceName: this.resourceName,
|
||||
viaResource: this.viaResource,
|
||||
viaResourceId: this.viaResourceId,
|
||||
viaRelationship: this.viaRelationship,
|
||||
},
|
||||
}
|
||||
)
|
||||
this.relationResponse = data
|
||||
|
||||
if (this.isHasOneRelationship && this.alreadyFilled) {
|
||||
Nova.error(this.__('The HasOne relationship has already been filled.'))
|
||||
|
||||
this.$router.push({
|
||||
name: 'detail',
|
||||
params: {
|
||||
resourceId: this.viaResourceId,
|
||||
resourceName: this.viaResource,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (this.isHasOneThroughRelationship && this.alreadyFilled) {
|
||||
Nova.error(
|
||||
this.__('The HasOneThrough relationship has already been filled.')
|
||||
)
|
||||
|
||||
this.$router.push({
|
||||
name: 'detail',
|
||||
params: {
|
||||
resourceId: this.viaResourceId,
|
||||
resourceName: this.viaResource,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.getFields()
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Get the available fields for the resource.
|
||||
*/
|
||||
async getFields() {
|
||||
this.panels = []
|
||||
this.fields = []
|
||||
|
||||
const {
|
||||
data: { panels, fields },
|
||||
} = await Nova.request().get(
|
||||
`/nova-api/${this.resourceName}/creation-fields`,
|
||||
{
|
||||
params: {
|
||||
editing: true,
|
||||
editMode: 'create',
|
||||
viaResource: this.viaResource,
|
||||
viaResourceId: this.viaResourceId,
|
||||
viaRelationship: this.viaRelationship,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
this.panels = panels
|
||||
this.fields = fields
|
||||
this.loading = false
|
||||
},
|
||||
|
||||
async submitViaCreateResource(e) {
|
||||
e.preventDefault()
|
||||
this.submittedViaCreateResource = true
|
||||
this.submittedViaCreateResourceAndAddAnother = false
|
||||
await this.createResource()
|
||||
},
|
||||
|
||||
async submitViaCreateResourceAndAddAnother() {
|
||||
this.submittedViaCreateResourceAndAddAnother = true
|
||||
this.submittedViaCreateResource = false
|
||||
await this.createResource()
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new resource instance using the provided data.
|
||||
*/
|
||||
async createResource() {
|
||||
this.isWorking = true
|
||||
|
||||
if (this.$refs.form.reportValidity()) {
|
||||
try {
|
||||
const {
|
||||
data: { redirect, id },
|
||||
} = await this.createRequest()
|
||||
|
||||
this.canLeave = true
|
||||
|
||||
Nova.success(
|
||||
this.__('The :resource was created!', {
|
||||
resource: this.resourceInformation.singularLabel.toLowerCase(),
|
||||
})
|
||||
)
|
||||
|
||||
if (this.submittedViaCreateResource) {
|
||||
this.$emit('resource-created', { id, redirect })
|
||||
} else {
|
||||
// Reset the form by refetching the fields
|
||||
this.getFields()
|
||||
this.validationErrors = new Errors()
|
||||
this.submittedViaCreateAndAddAnother = false
|
||||
this.submittedViaCreateResource = false
|
||||
this.isWorking = false
|
||||
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
window.scrollTo(0, 0)
|
||||
|
||||
this.submittedViaCreateAndAddAnother = false
|
||||
this.submittedViaCreateResource = true
|
||||
this.isWorking = false
|
||||
|
||||
if (this.resourceInformation.preventFormAbandonment) {
|
||||
this.canLeave = false
|
||||
}
|
||||
|
||||
if (error.response.status == 422) {
|
||||
this.validationErrors = new Errors(error.response.data.errors)
|
||||
Nova.error(this.__('There was a problem submitting the form.'))
|
||||
} else {
|
||||
Nova.error(
|
||||
this.__('There was a problem submitting the form.') +
|
||||
' "' +
|
||||
error.response.statusText +
|
||||
'"'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.submittedViaCreateAndAddAnother = false
|
||||
this.submittedViaCreateResource = true
|
||||
this.isWorking = false
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a create request for this resource
|
||||
*/
|
||||
createRequest() {
|
||||
return Nova.request().post(
|
||||
`/nova-api/${this.resourceName}`,
|
||||
this.createResourceFormData(),
|
||||
{
|
||||
params: {
|
||||
editing: true,
|
||||
editMode: 'create',
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Create the form data for creating the resource.
|
||||
*/
|
||||
createResourceFormData() {
|
||||
return _.tap(new FormData(), formData => {
|
||||
_.each(this.fields, field => {
|
||||
field.fill(formData)
|
||||
})
|
||||
|
||||
formData.append('viaResource', this.viaResource)
|
||||
formData.append('viaResourceId', this.viaResourceId)
|
||||
formData.append('viaRelationship', this.viaRelationship)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Prevent accidental abandonment only if form was changed.
|
||||
*/
|
||||
onUpdateFormStatus() {
|
||||
if (this.resourceInformation.preventFormAbandonment) {
|
||||
this.updateFormStatus()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
wasSubmittedViaCreateResource() {
|
||||
return this.isWorking && this.submittedViaCreateResource
|
||||
},
|
||||
|
||||
wasSubmittedViaCreateResourceAndAddAnother() {
|
||||
return this.isWorking && this.submittedViaCreateResourceAndAddAnother
|
||||
},
|
||||
|
||||
panelsWithFields() {
|
||||
return _.map(this.panels, panel => {
|
||||
return {
|
||||
...panel,
|
||||
fields: _.filter(this.fields, field => field.panel == panel.name),
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
singularName() {
|
||||
if (this.relationResponse) {
|
||||
return this.relationResponse.singularLabel
|
||||
}
|
||||
|
||||
return this.resourceInformation.singularLabel
|
||||
},
|
||||
|
||||
createButtonLabel() {
|
||||
return this.resourceInformation.createButtonLabel
|
||||
},
|
||||
|
||||
isRelation() {
|
||||
return Boolean(this.viaResourceId && this.viaRelationship)
|
||||
},
|
||||
|
||||
shownViaNewRelationModal() {
|
||||
return this.mode == 'modal'
|
||||
},
|
||||
|
||||
inFormMode() {
|
||||
return this.mode == 'form'
|
||||
},
|
||||
|
||||
canAddMoreResources() {
|
||||
return this.authorizedToCreate
|
||||
},
|
||||
|
||||
alreadyFilled() {
|
||||
return this.relationResponse && this.relationResponse.alreadyFilled
|
||||
},
|
||||
|
||||
isHasOneRelationship() {
|
||||
return this.relationResponse && this.relationResponse.hasOneRelationship
|
||||
},
|
||||
|
||||
isHasOneThroughRelationship() {
|
||||
return (
|
||||
this.relationResponse && this.relationResponse.hasOneThroughRelationship
|
||||
)
|
||||
},
|
||||
|
||||
shouldShowAddAnotherButton() {
|
||||
return (
|
||||
Boolean(this.inFormMode && !this.alreadyFilled) &&
|
||||
!Boolean(this.isHasOneRelationship || this.isHasOneThroughRelationship)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
96
nova/resources/js/components/CreateResourceButton.vue
Executable file
96
nova/resources/js/components/CreateResourceButton.vue
Executable file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div v-if="shouldShowButtons">
|
||||
<!-- Attach Related Models -->
|
||||
<router-link
|
||||
v-if="shouldShowAttachButton"
|
||||
dusk="attach-button"
|
||||
:class="classes"
|
||||
:to="{
|
||||
name: 'attach',
|
||||
params: {
|
||||
resourceName: viaResource,
|
||||
resourceId: viaResourceId,
|
||||
relatedResourceName: resourceName,
|
||||
},
|
||||
query: {
|
||||
viaRelationship: viaRelationship,
|
||||
polymorphic: relationshipType == 'morphToMany' ? '1' : '0',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<slot> {{ __('Attach :resource', { resource: singularName }) }}</slot>
|
||||
</router-link>
|
||||
|
||||
<!-- Create Related Models -->
|
||||
<router-link
|
||||
v-else-if="shouldShowCreateButton"
|
||||
dusk="create-button"
|
||||
:class="classes"
|
||||
:to="{
|
||||
name: 'create',
|
||||
params: {
|
||||
resourceName: resourceName,
|
||||
},
|
||||
query: {
|
||||
viaResource: viaResource,
|
||||
viaResourceId: viaResourceId,
|
||||
viaRelationship: viaRelationship,
|
||||
},
|
||||
}"
|
||||
>
|
||||
{{ label }}
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
classes: { default: 'btn btn-default btn-primary' },
|
||||
label: {},
|
||||
singularName: {},
|
||||
resourceName: {},
|
||||
viaResource: {},
|
||||
viaResourceId: {},
|
||||
viaRelationship: {},
|
||||
relationshipType: {},
|
||||
authorizedToCreate: {},
|
||||
authorizedToRelate: {},
|
||||
alreadyFilled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Determine if any buttons should be displayed.
|
||||
*/
|
||||
shouldShowButtons() {
|
||||
return this.shouldShowAttachButton || this.shouldShowCreateButton
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine if the attach button should be displayed.
|
||||
*/
|
||||
shouldShowAttachButton() {
|
||||
return (
|
||||
(this.relationshipType == 'belongsToMany' ||
|
||||
this.relationshipType == 'morphToMany') &&
|
||||
this.authorizedToRelate
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine if the create button should be displayed.
|
||||
*/
|
||||
shouldShowCreateButton() {
|
||||
return (
|
||||
this.authorizedToCreate &&
|
||||
this.authorizedToRelate &&
|
||||
!this.alreadyFilled
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
9
nova/resources/js/components/CustomAttachHeader.vue
Executable file
9
nova/resources/js/components/CustomAttachHeader.vue
Executable file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId'],
|
||||
}
|
||||
</script>
|
||||
9
nova/resources/js/components/CustomCreateHeader.vue
Executable file
9
nova/resources/js/components/CustomCreateHeader.vue
Executable file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName'],
|
||||
}
|
||||
</script>
|
||||
9
nova/resources/js/components/CustomDashboardHeader.vue
Executable file
9
nova/resources/js/components/CustomDashboardHeader.vue
Executable file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['dashboardName'],
|
||||
}
|
||||
</script>
|
||||
9
nova/resources/js/components/CustomDetailHeader.vue
Executable file
9
nova/resources/js/components/CustomDetailHeader.vue
Executable file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId'],
|
||||
}
|
||||
</script>
|
||||
9
nova/resources/js/components/CustomDetailToolbar.vue
Executable file
9
nova/resources/js/components/CustomDetailToolbar.vue
Executable file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="flex w-full justify-end items-center" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId'],
|
||||
}
|
||||
</script>
|
||||
9
nova/resources/js/components/CustomIndexHeader.vue
Executable file
9
nova/resources/js/components/CustomIndexHeader.vue
Executable file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resourceName'],
|
||||
}
|
||||
</script>
|
||||
9
nova/resources/js/components/CustomIndexToolbar.vue
Executable file
9
nova/resources/js/components/CustomIndexToolbar.vue
Executable file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="flex w-full justify-end items-center mx-3" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resourceName'],
|
||||
}
|
||||
</script>
|
||||
9
nova/resources/js/components/CustomLensHeader.vue
Executable file
9
nova/resources/js/components/CustomLensHeader.vue
Executable file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resourceName'],
|
||||
}
|
||||
</script>
|
||||
9
nova/resources/js/components/CustomUpdateAttachedHeader.vue
Executable file
9
nova/resources/js/components/CustomUpdateAttachedHeader.vue
Executable file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId'],
|
||||
}
|
||||
</script>
|
||||
9
nova/resources/js/components/CustomUpdateHeader.vue
Executable file
9
nova/resources/js/components/CustomUpdateHeader.vue
Executable file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId'],
|
||||
}
|
||||
</script>
|
||||
107
nova/resources/js/components/DateTimePicker.vue
Executable file
107
nova/resources/js/components/DateTimePicker.vue
Executable file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<input
|
||||
:disabled="disabled"
|
||||
:class="{ '!cursor-not-allowed': disabled }"
|
||||
:value="value"
|
||||
ref="datePicker"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import flatpickr from 'flatpickr'
|
||||
import 'flatpickr/dist/themes/airbnb.css'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
required: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: () => {
|
||||
return moment().format('YYYY-MM-DD HH:mm:ss')
|
||||
},
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
dateFormat: {
|
||||
type: String,
|
||||
default: 'Y-m-d H:i:S',
|
||||
},
|
||||
altFormat: {
|
||||
type: String,
|
||||
default: 'Y-m-d H:i:S',
|
||||
},
|
||||
twelveHourTime: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
enableTime: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
enableSeconds: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
firstDayOfWeek: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
|
||||
data: () => ({ flatpickr: null }),
|
||||
|
||||
watch: {
|
||||
value: function (newValue, oldValue) {
|
||||
if (this.flatpickr) {
|
||||
this.flatpickr.setDate(newValue)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$nextTick(() => this.createFlatpickr())
|
||||
},
|
||||
|
||||
methods: {
|
||||
createFlatpickr() {
|
||||
this.flatpickr = flatpickr(this.$refs.datePicker, {
|
||||
enableTime: this.enableTime,
|
||||
enableSeconds: this.enableSeconds,
|
||||
onClose: this.onChange,
|
||||
onChange: this.onChange,
|
||||
dateFormat: this.dateFormat,
|
||||
altInput: true,
|
||||
altFormat: this.altFormat,
|
||||
allowInput: true,
|
||||
// static: true,
|
||||
time_24hr: !this.twelveHourTime,
|
||||
locale: { firstDayOfWeek: this.firstDayOfWeek },
|
||||
})
|
||||
},
|
||||
|
||||
onChange(event) {
|
||||
this.$emit('change', this.$refs.datePicker.value)
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.flatpickr.clear()
|
||||
},
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.flatpickr.destroy()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.\!cursor-not-allowed {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
</style>
|
||||
16
nova/resources/js/components/DeleteButton.vue
Executable file
16
nova/resources/js/components/DeleteButton.vue
Executable file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
@keydown.enter.prevent="$emit('click')"
|
||||
@click.prevent="$emit('click')"
|
||||
tabindex="0"
|
||||
class="cursor-pointer dim btn btn-link text-primary inline-flex items-center"
|
||||
>
|
||||
<icon type="delete" view-box="0 0 20 20" width="16" height="16" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {}
|
||||
</script>
|
||||
231
nova/resources/js/components/DeleteMenu.vue
Executable file
231
nova/resources/js/components/DeleteMenu.vue
Executable file
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div>
|
||||
<dropdown class="ml-3 bg-30 hover:bg-40 rounded">
|
||||
<dropdown-trigger class="px-3">
|
||||
<icon type="delete" class="text-80" />
|
||||
</dropdown-trigger>
|
||||
|
||||
<dropdown-menu slot="menu" direction="rtl" width="250">
|
||||
<div class="px-3">
|
||||
<!-- Delete Menu -->
|
||||
<button
|
||||
dusk="delete-selected-button"
|
||||
class="text-left w-full leading-normal dim my-2"
|
||||
@click="confirmDeleteSelectedResources"
|
||||
v-if="authorizedToDeleteSelectedResources || allMatchingSelected"
|
||||
>
|
||||
{{ __(viaManyToMany ? 'Detach Selected' : 'Delete Selected') }}
|
||||
({{ selectedResourcesCount }})
|
||||
</button>
|
||||
|
||||
<!-- Restore Resources -->
|
||||
<button
|
||||
dusk="restore-selected-button"
|
||||
class="text-left w-full leading-normal dim text-90 my-2"
|
||||
@click="confirmRestore"
|
||||
v-if="
|
||||
softDeletes &&
|
||||
!viaManyToMany &&
|
||||
(softDeletedResourcesSelected || allMatchingSelected) &&
|
||||
(authorizedToRestoreSelectedResources || allMatchingSelected)
|
||||
"
|
||||
>
|
||||
{{ __('Restore Selected') }} ({{ selectedResourcesCount }})
|
||||
</button>
|
||||
|
||||
<!-- Force Delete Resources -->
|
||||
<button
|
||||
dusk="force-delete-selected-button"
|
||||
class="text-left w-full leading-normal dim text-90 my-2"
|
||||
@click="confirmForceDeleteSelectedResources"
|
||||
v-if="
|
||||
softDeletes &&
|
||||
!viaManyToMany &&
|
||||
(authorizedToForceDeleteSelectedResources || allMatchingSelected)
|
||||
"
|
||||
>
|
||||
{{ __('Force Delete Selected') }} ({{ selectedResourcesCount }})
|
||||
</button>
|
||||
</div>
|
||||
</dropdown-menu>
|
||||
</dropdown>
|
||||
|
||||
<portal
|
||||
to="modals"
|
||||
v-if="
|
||||
deleteSelectedModalOpen ||
|
||||
forceDeleteSelectedModalOpen ||
|
||||
restoreModalOpen
|
||||
"
|
||||
>
|
||||
<delete-resource-modal
|
||||
v-if="deleteSelectedModalOpen"
|
||||
@confirm="deleteSelectedResources"
|
||||
@close="closeDeleteSelectedModal"
|
||||
:mode="viaManyToMany ? 'detach' : 'delete'"
|
||||
/>
|
||||
|
||||
<delete-resource-modal
|
||||
v-if="forceDeleteSelectedModalOpen"
|
||||
@confirm="forceDeleteSelectedResources"
|
||||
@close="closeForceDeleteSelectedModal"
|
||||
mode="delete"
|
||||
>
|
||||
<div slot-scope="{ uppercaseMode, mode }" class="p-8">
|
||||
<heading :level="2" class="mb-6">{{
|
||||
__('Force Delete Resource')
|
||||
}}</heading>
|
||||
<p class="text-80 leading-normal">
|
||||
{{
|
||||
__(
|
||||
'Are you sure you want to force delete the selected resources?'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</delete-resource-modal>
|
||||
|
||||
<restore-resource-modal
|
||||
v-if="restoreModalOpen"
|
||||
@confirm="restoreSelectedResources"
|
||||
@close="closeRestoreModal"
|
||||
/>
|
||||
</portal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: [
|
||||
'softDeletes',
|
||||
'resources',
|
||||
'selectedResources',
|
||||
'viaManyToMany',
|
||||
'allMatchingResourceCount',
|
||||
'allMatchingSelected',
|
||||
|
||||
'authorizedToDeleteSelectedResources',
|
||||
'authorizedToForceDeleteSelectedResources',
|
||||
'authorizedToDeleteAnyResources',
|
||||
'authorizedToForceDeleteAnyResources',
|
||||
'authorizedToRestoreSelectedResources',
|
||||
'authorizedToRestoreAnyResources',
|
||||
],
|
||||
|
||||
data: () => ({
|
||||
deleteSelectedModalOpen: false,
|
||||
forceDeleteSelectedModalOpen: false,
|
||||
restoreModalOpen: false,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*/
|
||||
mounted() {
|
||||
document.addEventListener('keydown', this.handleEscape)
|
||||
|
||||
Nova.$on('close-dropdowns', () => {
|
||||
this.deleteSelectedModalOpen = false
|
||||
this.forceDeleteSelectedModalOpen = false
|
||||
this.restoreModalOpen = false
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Prepare the component to tbe destroyed.
|
||||
*/
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('keydown', this.handleEscape)
|
||||
|
||||
Nova.$off('close-dropdowns')
|
||||
},
|
||||
|
||||
methods: {
|
||||
confirmDeleteSelectedResources() {
|
||||
this.deleteSelectedModalOpen = true
|
||||
},
|
||||
|
||||
confirmForceDeleteSelectedResources() {
|
||||
this.forceDeleteSelectedModalOpen = true
|
||||
},
|
||||
|
||||
confirmRestore() {
|
||||
this.restoreModalOpen = true
|
||||
},
|
||||
|
||||
closeDeleteSelectedModal() {
|
||||
this.deleteSelectedModalOpen = false
|
||||
},
|
||||
|
||||
closeForceDeleteSelectedModal() {
|
||||
this.forceDeleteSelectedModalOpen = false
|
||||
},
|
||||
|
||||
closeRestoreModal() {
|
||||
this.restoreModalOpen = false
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete the selected resources.
|
||||
*/
|
||||
deleteSelectedResources() {
|
||||
this.$emit(
|
||||
this.allMatchingSelected ? 'deleteAllMatching' : 'deleteSelected'
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Force delete the selected resources.
|
||||
*/
|
||||
forceDeleteSelectedResources() {
|
||||
this.$emit(
|
||||
this.allMatchingSelected
|
||||
? 'forceDeleteAllMatching'
|
||||
: 'forceDeleteSelected'
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Restore the selected resources.
|
||||
*/
|
||||
restoreSelectedResources() {
|
||||
this.$emit(
|
||||
this.allMatchingSelected ? 'restoreAllMatching' : 'restoreSelected'
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle the escape key press event.
|
||||
*/
|
||||
handleEscape(e) {
|
||||
if (this.show && e.keyCode == 27) {
|
||||
this.close()
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the modal.
|
||||
*/
|
||||
close() {
|
||||
this.$emit('close')
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedResourcesCount() {
|
||||
return this.allMatchingSelected
|
||||
? this.allMatchingResourceCount
|
||||
: this.selectedResources.length
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine if any soft deleted resources are selected.
|
||||
*/
|
||||
softDeletedResourcesSelected() {
|
||||
return Boolean(
|
||||
_.find(this.selectedResources, resource => resource.softDeleted)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
23
nova/resources/js/components/Detail/BadgeField.vue
Executable file
23
nova/resources/js/components/Detail/BadgeField.vue
Executable file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<panel-item :field="field">
|
||||
<template slot="value">
|
||||
<badge
|
||||
class="mt-1"
|
||||
:label="field.label"
|
||||
:extra-classes="field.typeClass"
|
||||
/>
|
||||
</template>
|
||||
</panel-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Badge from '../Badge'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Badge,
|
||||
},
|
||||
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
}
|
||||
</script>
|
||||
27
nova/resources/js/components/Detail/BelongsToField.vue
Executable file
27
nova/resources/js/components/Detail/BelongsToField.vue
Executable file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<panel-item :field="field">
|
||||
<template slot="value">
|
||||
<router-link
|
||||
v-if="field.viewable && field.value"
|
||||
:to="{
|
||||
name: 'detail',
|
||||
params: {
|
||||
resourceName: field.resourceName,
|
||||
resourceId: field.belongsToId,
|
||||
},
|
||||
}"
|
||||
class="no-underline font-bold dim text-primary"
|
||||
>
|
||||
{{ field.value }}
|
||||
</router-link>
|
||||
<p v-else-if="field.value">{{ field.value }}</p>
|
||||
<p v-else>—</p>
|
||||
</template>
|
||||
</panel-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
}
|
||||
</script>
|
||||
29
nova/resources/js/components/Detail/BelongsToManyField.vue
Executable file
29
nova/resources/js/components/Detail/BelongsToManyField.vue
Executable file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<resource-index
|
||||
:field="field"
|
||||
:resource-name="field.resourceName"
|
||||
:via-resource="resourceName"
|
||||
:via-resource-id="resourceId"
|
||||
:via-relationship="field.belongsToManyRelationship"
|
||||
:relationship-type="'belongsToMany'"
|
||||
@actionExecuted="actionExecuted"
|
||||
:load-cards="false"
|
||||
:initialPerPage="field.perPage || 5"
|
||||
:should-override-meta="false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resourceName', 'resourceId', 'resource', 'field'],
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Handle the actionExecuted event and pass it up the chain.
|
||||
*/
|
||||
actionExecuted() {
|
||||
this.$emit('actionExecuted')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
34
nova/resources/js/components/Detail/BooleanField.vue
Executable file
34
nova/resources/js/components/Detail/BooleanField.vue
Executable file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<panel-item :field="field">
|
||||
<icon
|
||||
v-if="field.value"
|
||||
slot="value"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
type="check-circle"
|
||||
class="text-success"
|
||||
/>
|
||||
<icon
|
||||
v-else
|
||||
slot="value"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
type="x-circle"
|
||||
class="text-danger"
|
||||
/>
|
||||
</panel-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
|
||||
computed: {
|
||||
label() {
|
||||
return this.field.value == true ? this.__('Yes') : this.__('No')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
55
nova/resources/js/components/Detail/BooleanGroupField.vue
Executable file
55
nova/resources/js/components/Detail/BooleanGroupField.vue
Executable file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<panel-item :field="field">
|
||||
<template slot="value">
|
||||
<ul class="list-reset" v-if="value.length > 0">
|
||||
<li v-for="option in value" class="mb-1">
|
||||
<span
|
||||
:class="classes[option.checked]"
|
||||
class="inline-flex items-center py-1 pl-2 pr-3 rounded-full font-bold text-sm leading-tight"
|
||||
>
|
||||
<boolean-icon :value="option.checked" width="20" height="20" />
|
||||
<span class="ml-1">{{ option.label }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<span v-else>{{ this.field.noValueText }}</span>
|
||||
</template>
|
||||
</panel-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
|
||||
data: () => ({
|
||||
value: [],
|
||||
classes: {
|
||||
true: 'bg-success-light text-success-dark',
|
||||
false: 'bg-danger-light text-danger-dark',
|
||||
},
|
||||
}),
|
||||
|
||||
created() {
|
||||
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,
|
||||
}
|
||||
})
|
||||
.filter(o => {
|
||||
if (this.field.hideFalseValues === true && o.checked === false) {
|
||||
return false
|
||||
} else if (this.field.hideTrueValues === true && o.checked === true) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.value()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
121
nova/resources/js/components/Detail/CodeField.vue
Executable file
121
nova/resources/js/components/Detail/CodeField.vue
Executable file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<panel-item :field="field">
|
||||
<template slot="value">
|
||||
<div class="form-input form-input-bordered px-0 overflow-hidden">
|
||||
<textarea ref="theTextarea" />
|
||||
</div>
|
||||
</template>
|
||||
</panel-item>
|
||||
</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/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')
|
||||
)
|
||||
})
|
||||
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
|
||||
data: () => ({ codemirror: null }),
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*/
|
||||
mounted() {
|
||||
const config = {
|
||||
...{
|
||||
tabSize: 4,
|
||||
indentWithTabs: true,
|
||||
lineWrapping: true,
|
||||
lineNumbers: true,
|
||||
theme: 'dracula',
|
||||
},
|
||||
...this.field.options,
|
||||
...{ readOnly: true },
|
||||
}
|
||||
|
||||
this.codemirror = CodeMirror.fromTextArea(this.$refs.theTextarea, config)
|
||||
this.codemirror.getDoc().setValue(this.field.value)
|
||||
this.codemirror.setSize('100%', this.field.height)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
9
nova/resources/js/components/Detail/CurrencyField.vue
Executable file
9
nova/resources/js/components/Detail/CurrencyField.vue
Executable file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<panel-item :field="field" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
}
|
||||
</script>
|
||||
24
nova/resources/js/components/Detail/DateField.vue
Executable file
24
nova/resources/js/components/Detail/DateField.vue
Executable file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<panel-item :field="field">
|
||||
<template slot="value">
|
||||
<p v-if="field.value" class="text-90">{{ formattedDate }}</p>
|
||||
<p v-else>—</p>
|
||||
</template>
|
||||
</panel-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
|
||||
computed: {
|
||||
formattedDate() {
|
||||
if (this.field.format) {
|
||||
return moment(this.field.value).format(this.field.format)
|
||||
}
|
||||
|
||||
return this.field.value
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
27
nova/resources/js/components/Detail/DateTimeField.vue
Executable file
27
nova/resources/js/components/Detail/DateTimeField.vue
Executable file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<panel-item :field="field">
|
||||
<template slot="value">
|
||||
<p v-if="field.value" class="text-90">{{ localizedDateTime }}</p>
|
||||
<p v-else>—</p>
|
||||
</template>
|
||||
</panel-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { InteractsWithDates } from 'laravel-nova'
|
||||
|
||||
export default {
|
||||
mixins: [InteractsWithDates],
|
||||
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Get the localized date time.
|
||||
*/
|
||||
localizedDateTime() {
|
||||
return this.localizeDateTimeField(this.field)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
101
nova/resources/js/components/Detail/FileField.vue
Executable file
101
nova/resources/js/components/Detail/FileField.vue
Executable file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<panel-item :field="field">
|
||||
<div slot="value">
|
||||
<template v-if="shouldShowLoader">
|
||||
<ImageLoader
|
||||
:src="imageUrl"
|
||||
:maxWidth="maxWidth"
|
||||
:rounded="rounded"
|
||||
@missing="value => (missing = value)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="field.value && !imageUrl">
|
||||
<span class="break-words">{{ field.value }}</span>
|
||||
</template>
|
||||
|
||||
<span v-if="!field.value && !imageUrl">—</span>
|
||||
|
||||
<p v-if="shouldShowToolbar" class="flex items-center text-sm mt-3">
|
||||
<a
|
||||
v-if="field.downloadable"
|
||||
:dusk="field.attribute + '-download-link'"
|
||||
@keydown.enter.prevent="download"
|
||||
@click.prevent="download"
|
||||
tabindex="0"
|
||||
class="cursor-pointer dim btn btn-link text-primary inline-flex items-center"
|
||||
>
|
||||
<icon
|
||||
class="mr-2"
|
||||
type="download"
|
||||
view-box="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
<span class="class mt-1">{{ __('Download') }}</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</panel-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ImageLoader from '@/components/ImageLoader'
|
||||
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
|
||||
components: { ImageLoader },
|
||||
|
||||
data: () => ({ missing: false }),
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Download the linked file
|
||||
*/
|
||||
download() {
|
||||
const { resourceName, resourceId } = this
|
||||
const attribute = this.field.attribute
|
||||
|
||||
let link = document.createElement('a')
|
||||
link.href = `/nova-api/${resourceName}/${resourceId}/download/${attribute}`
|
||||
link.download = 'download'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasValue() {
|
||||
return (
|
||||
Boolean(this.field.value || this.imageUrl) && !Boolean(this.missing)
|
||||
)
|
||||
},
|
||||
|
||||
shouldShowLoader() {
|
||||
return Boolean(this.imageUrl)
|
||||
},
|
||||
|
||||
shouldShowToolbar() {
|
||||
return Boolean(this.field.downloadable && this.hasValue)
|
||||
},
|
||||
|
||||
imageUrl() {
|
||||
return this.field.previewUrl || this.field.thumbnailUrl
|
||||
},
|
||||
|
||||
rounded() {
|
||||
return this.field.rounded
|
||||
},
|
||||
|
||||
maxWidth() {
|
||||
return this.field.maxWidth || 320
|
||||
},
|
||||
|
||||
isVaporField() {
|
||||
return this.field.component == 'vapor-file-field'
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
29
nova/resources/js/components/Detail/HasManyField.vue
Executable file
29
nova/resources/js/components/Detail/HasManyField.vue
Executable file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<resource-index
|
||||
:field="field"
|
||||
:resource-name="field.resourceName"
|
||||
:via-resource="resourceName"
|
||||
:via-resource-id="resourceId"
|
||||
:via-relationship="field.hasManyRelationship"
|
||||
:relationship-type="'hasMany'"
|
||||
@actionExecuted="actionExecuted"
|
||||
:load-cards="false"
|
||||
:initialPerPage="field.perPage || 5"
|
||||
:should-override-meta="false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resourceName', 'resourceId', 'resource', 'field'],
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Handle the actionExecuted event and pass it up the chain.
|
||||
*/
|
||||
actionExecuted() {
|
||||
this.$emit('actionExecuted')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
29
nova/resources/js/components/Detail/HasManyThroughField.vue
Executable file
29
nova/resources/js/components/Detail/HasManyThroughField.vue
Executable file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<resource-index
|
||||
:field="field"
|
||||
:resource-name="field.resourceName"
|
||||
:via-resource="resourceName"
|
||||
:via-resource-id="resourceId"
|
||||
:via-relationship="field.hasManyThroughRelationship"
|
||||
:relationship-type="'hasManyThrough'"
|
||||
@actionExecuted="actionExecuted"
|
||||
:load-cards="false"
|
||||
:initialPerPage="field.perPage || 5"
|
||||
:should-override-meta="false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resourceName', 'resourceId', 'resource', 'field'],
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Handle the actionExecuted event and pass it up the chain.
|
||||
*/
|
||||
actionExecuted() {
|
||||
this.$emit('actionExecuted')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
29
nova/resources/js/components/Detail/HasOneField.vue
Executable file
29
nova/resources/js/components/Detail/HasOneField.vue
Executable file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<resource-index
|
||||
:field="field"
|
||||
:resource-name="field.resourceName"
|
||||
:via-resource="resourceName"
|
||||
:via-resource-id="resourceId"
|
||||
:via-relationship="field.hasOneRelationship"
|
||||
:relationship-type="'hasOne'"
|
||||
@actionExecuted="actionExecuted"
|
||||
:load-cards="false"
|
||||
:disable-pagination="true"
|
||||
:should-override-meta="false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resourceName', 'resourceId', 'resource', 'field'],
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Handle the actionExecuted event and pass it up the chain.
|
||||
*/
|
||||
actionExecuted() {
|
||||
this.$emit('actionExecuted')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
29
nova/resources/js/components/Detail/HasOneThroughField.vue
Executable file
29
nova/resources/js/components/Detail/HasOneThroughField.vue
Executable file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<resource-index
|
||||
:field="field"
|
||||
:resource-name="field.resourceName"
|
||||
:via-resource="resourceName"
|
||||
:via-resource-id="resourceId"
|
||||
:via-relationship="field.hasOneThroughRelationship"
|
||||
:relationship-type="'hasOneThrough'"
|
||||
@actionExecuted="actionExecuted"
|
||||
:load-cards="false"
|
||||
:disable-pagination="true"
|
||||
:should-override-meta="false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resourceName', 'resourceId', 'resource', 'field'],
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Handle the actionExecuted event and pass it up the chain.
|
||||
*/
|
||||
actionExecuted() {
|
||||
this.$emit('actionExecuted')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
40
nova/resources/js/components/Detail/HeadingField.vue
Executable file
40
nova/resources/js/components/Detail/HeadingField.vue
Executable file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="bg-20 flex border-b border-t border-40 -mx-6 -my-px px-2">
|
||||
<div class="w-full py-4 px-4">
|
||||
<slot name="value">
|
||||
<p v-if="fieldValue && !shouldDisplayAsHtml" class="text-90">
|
||||
{{ fieldValue }}
|
||||
</p>
|
||||
<div
|
||||
v-else-if="fieldValue && shouldDisplayAsHtml"
|
||||
v-html="field.value"
|
||||
></div>
|
||||
<p v-else>—</p>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
|
||||
computed: {
|
||||
fieldValue() {
|
||||
if (
|
||||
this.field.value === '' ||
|
||||
this.field.value === null ||
|
||||
this.field.value === undefined
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return String(this.field.value)
|
||||
},
|
||||
|
||||
shouldDisplayAsHtml() {
|
||||
return this.field.asHtml
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
9
nova/resources/js/components/Detail/HiddenField.vue
Executable file
9
nova/resources/js/components/Detail/HiddenField.vue
Executable file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="hidden" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
}
|
||||
</script>
|
||||
46
nova/resources/js/components/Detail/KeyValueField.vue
Executable file
46
nova/resources/js/components/Detail/KeyValueField.vue
Executable file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<panel-item :field="field">
|
||||
<template slot="value">
|
||||
<KeyValueTable
|
||||
v-if="theData.length > 0"
|
||||
:edit-mode="false"
|
||||
class="overflow-hidden"
|
||||
>
|
||||
<KeyValueHeader
|
||||
:key-label="field.keyLabel"
|
||||
:value-label="field.valueLabel"
|
||||
/>
|
||||
|
||||
<div class="bg-white overflow-hidden key-value-items">
|
||||
<KeyValueItem
|
||||
v-for="item in theData"
|
||||
:item="item"
|
||||
:disabled="true"
|
||||
:key="item.key"
|
||||
/>
|
||||
</div>
|
||||
</KeyValueTable>
|
||||
</template>
|
||||
</panel-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import KeyValueItem from '@/components/Form/KeyValueField/KeyValueItem'
|
||||
import KeyValueHeader from '@/components/Form/KeyValueField/KeyValueHeader'
|
||||
import KeyValueTable from '@/components/Form/KeyValueField/KeyValueTable'
|
||||
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
|
||||
components: { KeyValueTable, KeyValueHeader, KeyValueItem },
|
||||
|
||||
data: () => ({ theData: [] }),
|
||||
|
||||
created() {
|
||||
this.theData = _.map(this.field.value || {}, (value, key) => ({
|
||||
key,
|
||||
value,
|
||||
}))
|
||||
},
|
||||
}
|
||||
</script>
|
||||
21
nova/resources/js/components/Detail/MarkdownField.vue
Executable file
21
nova/resources/js/components/Detail/MarkdownField.vue
Executable file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<panel-item :field="field">
|
||||
<template slot="value">
|
||||
<excerpt :content="excerpt" :should-show="field.shouldShow" />
|
||||
</template>
|
||||
</panel-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const md = require('markdown-it')()
|
||||
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
|
||||
computed: {
|
||||
excerpt() {
|
||||
return md.render(this.field.value || '')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
32
nova/resources/js/components/Detail/MorphToActionTargetField.vue
Executable file
32
nova/resources/js/components/Detail/MorphToActionTargetField.vue
Executable file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<panel-item :field="field">
|
||||
<template slot="value">
|
||||
<router-link
|
||||
v-if="field.viewable && field.value"
|
||||
:to="{
|
||||
name: 'detail',
|
||||
params: {
|
||||
resourceName: field.resourceName,
|
||||
resourceId: field.morphToId,
|
||||
},
|
||||
}"
|
||||
class="no-underline font-bold dim text-primary"
|
||||
>
|
||||
{{ field.name }}: {{ field.value }} ({{ field.resourceLabel }})
|
||||
</router-link>
|
||||
<p v-else-if="field.value && field.resourceLabel === null">
|
||||
{{ field.morphToType }}: {{ field.value }}
|
||||
</p>
|
||||
<p v-else-if="field.value && field.resourceLabel !== null">
|
||||
{{ field.value }}
|
||||
</p>
|
||||
<p v-else>—</p>
|
||||
</template>
|
||||
</panel-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resourceName', 'resourceId', 'field'],
|
||||
}
|
||||
</script>
|
||||
32
nova/resources/js/components/Detail/MorphToField.vue
Executable file
32
nova/resources/js/components/Detail/MorphToField.vue
Executable file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<panel-item :field="field" :field-name="field.resourceLabel">
|
||||
<template slot="value">
|
||||
<router-link
|
||||
v-if="field.viewable && field.value"
|
||||
:to="{
|
||||
name: 'detail',
|
||||
params: {
|
||||
resourceName: field.resourceName,
|
||||
resourceId: field.morphToId,
|
||||
},
|
||||
}"
|
||||
class="no-underline font-bold dim text-primary"
|
||||
>
|
||||
{{ field.value }}
|
||||
</router-link>
|
||||
<p v-else-if="field.value && field.resourceLabel === null">
|
||||
{{ field.morphToType }}: {{ field.value }}
|
||||
</p>
|
||||
<p v-else-if="field.value && field.resourceLabel !== null">
|
||||
{{ field.value }}
|
||||
</p>
|
||||
<p v-else>—</p>
|
||||
</template>
|
||||
</panel-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resourceName', 'resourceId', 'field'],
|
||||
}
|
||||
</script>
|
||||
29
nova/resources/js/components/Detail/MorphToManyField.vue
Executable file
29
nova/resources/js/components/Detail/MorphToManyField.vue
Executable file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<resource-index
|
||||
:field="field"
|
||||
:resource-name="field.resourceName"
|
||||
:via-resource="resourceName"
|
||||
:via-resource-id="resourceId"
|
||||
:via-relationship="field.morphToManyRelationship"
|
||||
:relationship-type="'morphToMany'"
|
||||
@actionExecuted="actionExecuted"
|
||||
:load-cards="false"
|
||||
:initialPerPage="field.perPage || 5"
|
||||
:should-override-meta="false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resourceName', 'resourceId', 'resource', 'field'],
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Handle the actionExecuted event and pass it up the chain.
|
||||
*/
|
||||
actionExecuted() {
|
||||
this.$emit('actionExecuted')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
89
nova/resources/js/components/Detail/Panel.vue
Executable file
89
nova/resources/js/components/Detail/Panel.vue
Executable file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot>
|
||||
<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>
|
||||
</slot>
|
||||
|
||||
<card class="mb-6 py-3 px-6">
|
||||
<component
|
||||
:class="{
|
||||
'remove-bottom-border': index == panel.fields.length - 1,
|
||||
}"
|
||||
:key="index"
|
||||
v-for="(field, index) in fields"
|
||||
:is="resolveComponentName(field)"
|
||||
:resource-name="resourceName"
|
||||
:resource-id="resourceId"
|
||||
:resource="resource"
|
||||
:field="field"
|
||||
@actionExecuted="actionExecuted"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="shouldShowShowAllFieldsButton"
|
||||
class="bg-20 -mt-px -mx-6 -mb-6 border-t border-40 p-3 text-center rounded-b text-center"
|
||||
>
|
||||
<button
|
||||
class="block w-full dim text-sm text-80 font-bold"
|
||||
@click="showAllFields"
|
||||
>
|
||||
{{ __('Show All Fields') }}
|
||||
</button>
|
||||
</div>
|
||||
</card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { BehavesAsPanel } from 'laravel-nova'
|
||||
|
||||
export default {
|
||||
mixins: [BehavesAsPanel],
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Resolve the component name.
|
||||
*/
|
||||
resolveComponentName(field) {
|
||||
return field.prefixComponent
|
||||
? 'detail-' + field.component
|
||||
: field.component
|
||||
},
|
||||
|
||||
/**
|
||||
* Show all of the Panel's fields.
|
||||
*/
|
||||
showAllFields() {
|
||||
return (this.panel.limit = 0)
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Limits the visible fields.
|
||||
*/
|
||||
fields() {
|
||||
if (this.panel.limit > 0) {
|
||||
return this.panel.fields.slice(0, this.panel.limit)
|
||||
}
|
||||
|
||||
return this.panel.fields
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines if should display the 'Show all fields' button.
|
||||
*/
|
||||
shouldShowShowAllFieldsButton() {
|
||||
return this.panel.limit > 0
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
13
nova/resources/js/components/Detail/PasswordField.vue
Executable file
13
nova/resources/js/components/Detail/PasswordField.vue
Executable file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<panel-item :field="field">
|
||||
<p slot="value" class="text-90">
|
||||
·········
|
||||
</p>
|
||||
</panel-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
}
|
||||
</script>
|
||||
24
nova/resources/js/components/Detail/RelationshipPanel.vue
Executable file
24
nova/resources/js/components/Detail/RelationshipPanel.vue
Executable file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- <h4 class="text-90 font-normal text-2xl mb-3">{{ panel.name }}</h4> -->
|
||||
|
||||
<div :key="field + resourceId" v-for="field in panel.fields">
|
||||
<component
|
||||
:is="'detail-' + field.component"
|
||||
:resource-name="resourceName"
|
||||
:resource-id="resourceId"
|
||||
:resource="resource"
|
||||
:field="field"
|
||||
@actionExecuted="actionExecuted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { BehavesAsPanel } from 'laravel-nova'
|
||||
|
||||
export default {
|
||||
mixins: [BehavesAsPanel],
|
||||
}
|
||||
</script>
|
||||
91
nova/resources/js/components/Detail/SparklineField.vue
Executable file
91
nova/resources/js/components/Detail/SparklineField.vue
Executable file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<panel-item :field="field">
|
||||
<template slot="value">
|
||||
<div
|
||||
ref="chart"
|
||||
class="ct-chart"
|
||||
:style="{ width: chartWidth, height: chartHeight }"
|
||||
/>
|
||||
</template>
|
||||
</panel-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Chartist from 'chartist'
|
||||
import 'chartist/dist/chartist.min.css'
|
||||
|
||||
// Default chart diameters.
|
||||
const defaultHeight = 120
|
||||
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
|
||||
data: () => ({ chartist: null }),
|
||||
|
||||
watch: {
|
||||
'field.data': function (newData, oldData) {
|
||||
this.renderChart()
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
renderChart() {
|
||||
this.chartist.update(this.field.data)
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.chartist = new Chartist[this.chartStyle](
|
||||
this.$refs.chart,
|
||||
{ series: [this.field.data] },
|
||||
{
|
||||
height: this.chartHeight,
|
||||
width: this.chartWidth,
|
||||
showPoint: false,
|
||||
fullWidth: true,
|
||||
chartPadding: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
axisX: { showGrid: false, showLabel: false, offset: 0 },
|
||||
axisY: { showGrid: false, showLabel: false, offset: 0 },
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Determine if the field has a value other than null.
|
||||
*/
|
||||
hasData() {
|
||||
return this.field.data.length > 0
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine the chart style.
|
||||
*/
|
||||
chartStyle() {
|
||||
const validTypes = ['line', 'bar']
|
||||
let chartStyle = this.field.chartStyle.toLowerCase()
|
||||
|
||||
// Line and Bar are the only valid types.
|
||||
if (!validTypes.includes(chartStyle)) return 'Line'
|
||||
|
||||
return chartStyle.charAt(0).toUpperCase() + chartStyle.slice(1)
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine the chart height.
|
||||
*/
|
||||
chartHeight() {
|
||||
if (this.field.height) return `${this.field.height}px`
|
||||
|
||||
return `${defaultHeight}px`
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine the chart width.
|
||||
*/
|
||||
chartWidth() {
|
||||
if (this.field.width) return `${this.field.width}px`
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
34
nova/resources/js/components/Detail/StackField.vue
Executable file
34
nova/resources/js/components/Detail/StackField.vue
Executable file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<panel-item :field="field">
|
||||
<div slot="value" :class="`text-${field.textAlign}`">
|
||||
<template v-if="hasValue">
|
||||
<div class="leading-normal">
|
||||
<component
|
||||
v-for="line in field.lines"
|
||||
:key="line.value"
|
||||
:class="line.classes"
|
||||
:is="`index-${line.component}`"
|
||||
:field="line"
|
||||
:resourceName="resourceName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else>—</p>
|
||||
</div>
|
||||
</panel-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Determine if the field has a value other than null.
|
||||
*/
|
||||
hasValue() {
|
||||
return this.field.lines
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
38
nova/resources/js/components/Detail/StatusField.vue
Executable file
38
nova/resources/js/components/Detail/StatusField.vue
Executable file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="flex border-b border-40">
|
||||
<div class="w-1/4 py-4">
|
||||
<slot>
|
||||
<h4 class="font-normal text-80">{{ field.name }}</h4>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="w-3/4 py-4">
|
||||
<slot name="value">
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="mr-3 text-60"
|
||||
v-if="field.loadingWords.includes(field.value)"
|
||||
>
|
||||
<loader width="30" />
|
||||
</span>
|
||||
|
||||
<p
|
||||
v-if="field.value"
|
||||
class="text-90"
|
||||
:class="{
|
||||
'text-danger': field.failedWords.includes(field.value),
|
||||
}"
|
||||
>
|
||||
{{ field.value }}
|
||||
</p>
|
||||
<p v-else>—</p>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
}
|
||||
</script>
|
||||
9
nova/resources/js/components/Detail/TextField.vue
Executable file
9
nova/resources/js/components/Detail/TextField.vue
Executable file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<panel-item :field="field" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
}
|
||||
</script>
|
||||
17
nova/resources/js/components/Detail/TextareaField.vue
Executable file
17
nova/resources/js/components/Detail/TextareaField.vue
Executable file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<panel-item :field="field">
|
||||
<template slot="value">
|
||||
<excerpt
|
||||
:content="field.value"
|
||||
:plain-text="true"
|
||||
:should-show="field.shouldShow"
|
||||
/>
|
||||
</template>
|
||||
</panel-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
}
|
||||
</script>
|
||||
13
nova/resources/js/components/Detail/TrixField.vue
Executable file
13
nova/resources/js/components/Detail/TrixField.vue
Executable file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<panel-item :field="field">
|
||||
<template slot="value">
|
||||
<excerpt :content="field.value" :should-show="field.shouldShow" />
|
||||
</template>
|
||||
</panel-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['resource', 'resourceName', 'resourceId', 'field'],
|
||||
}
|
||||
</script>
|
||||
62
nova/resources/js/components/Dropdown.vue
Executable file
62
nova/resources/js/components/Dropdown.vue
Executable file
@@ -0,0 +1,62 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
offset: {
|
||||
type: [Number, String],
|
||||
default: 3,
|
||||
},
|
||||
|
||||
trigger: {
|
||||
default: 'click',
|
||||
validator: val => ['click', 'hover', 'manual'].includes(val),
|
||||
},
|
||||
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'bottom-start',
|
||||
},
|
||||
|
||||
boundary: {
|
||||
type: String,
|
||||
default: 'viewPort',
|
||||
},
|
||||
|
||||
autoHide: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
render(h) {
|
||||
return (
|
||||
<v-popover
|
||||
autoHide={this.autoHide}
|
||||
trigger={this.trigger}
|
||||
open={this.show}
|
||||
offset={this.offset}
|
||||
placement={this.placement}
|
||||
boundariesElement={this.boundary}
|
||||
popoverClass="z-50"
|
||||
popoverBaseClass=""
|
||||
popoverWrapperClass=""
|
||||
popoverArrowClass=""
|
||||
popoverInnerClass=""
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
staticClass="rounded active:outline-none active:shadow-outline focus:outline-none focus:shadow-outline"
|
||||
>
|
||||
{this.$slots.default}
|
||||
</button>
|
||||
|
||||
<template slot="popover">{this.$slots.menu}</template>
|
||||
</v-popover>
|
||||
)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
37
nova/resources/js/components/DropdownMenu.vue
Executable file
37
nova/resources/js/components/DropdownMenu.vue
Executable file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div
|
||||
ref="menu"
|
||||
:style="styles"
|
||||
class="select-none overflow-hidden bg-white border border-60 shadow rounded-lg"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
width: {
|
||||
default: 120,
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// If we recieve a click event from an anchor or button element, let's make sure
|
||||
// and close the dropdown's menu so it doesn't stay visible if we toggle a modal.
|
||||
this.$refs.menu.addEventListener('click', event => {
|
||||
if (event.target.tagName != 'A' && event.target.tagName != 'BUTTON') {
|
||||
Nova.$emit('close-dropdowns')
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
computed: {
|
||||
styles() {
|
||||
return {
|
||||
width: `${this.width}px`,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
42
nova/resources/js/components/DropdownTrigger.vue
Executable file
42
nova/resources/js/components/DropdownTrigger.vue
Executable file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div
|
||||
class="dropdown-trigger h-dropdown-trigger flex items-center cursor-pointer select-none"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<svg
|
||||
v-if="showArrow"
|
||||
class="ml-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="10"
|
||||
height="6"
|
||||
viewBox="0 0 10 6"
|
||||
>
|
||||
<path
|
||||
:fill="activeIconColor"
|
||||
d="M8.292893.292893c.390525-.390524 1.023689-.390524 1.414214 0 .390524.390525.390524 1.023689 0 1.414214l-4 4c-.390525.390524-1.023689.390524-1.414214 0l-4-4c-.390524-.390525-.390524-1.023689 0-1.414214.390525-.390524 1.023689-.390524 1.414214 0L5 3.585786 8.292893.292893z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showArrow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
activeIconColor() {
|
||||
return this.active ? 'var(--white)' : 'var(--90)'
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
64
nova/resources/js/components/Excerpt.vue
Executable file
64
nova/resources/js/components/Excerpt.vue
Executable file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div v-if="shouldShow && hasContent">
|
||||
<div
|
||||
class="markdown leading-normal"
|
||||
:class="{ 'whitespace-pre-wrap': plainText }"
|
||||
v-html="content"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="hasContent">
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="markdown leading-normal"
|
||||
:class="{ 'whitespace-pre-wrap': plainText }"
|
||||
v-html="content"
|
||||
/>
|
||||
|
||||
<a
|
||||
v-if="!shouldShow"
|
||||
@click="toggle"
|
||||
class="cursor-pointer dim inline-block text-primary font-bold"
|
||||
:class="{ 'mt-6': expanded }"
|
||||
aria-role="button"
|
||||
>
|
||||
{{ showHideLabel }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-else>—</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
plainText: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
shouldShow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
|
||||
data: () => ({ expanded: false }),
|
||||
|
||||
methods: {
|
||||
toggle() {
|
||||
this.expanded = !this.expanded
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasContent() {
|
||||
return this.content !== '' && this.content !== null
|
||||
},
|
||||
|
||||
showHideLabel() {
|
||||
return !this.expanded ? this.__('Show Content') : this.__('Hide Content')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
3
nova/resources/js/components/FadeTransition.vue
Executable file
3
nova/resources/js/components/FadeTransition.vue
Executable file
@@ -0,0 +1,3 @@
|
||||
<template functional>
|
||||
<transition name="fade" mode="out-in"> <slot /> </transition>
|
||||
</template>
|
||||
210
nova/resources/js/components/FilterMenu.vue
Executable file
210
nova/resources/js/components/FilterMenu.vue
Executable file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div v-on:click.prevent="toggleDropDown">
|
||||
<div class="filter-menu-dropdown">
|
||||
<dropdown
|
||||
v-if="filters.length > 0 || softDeletes || !viaResource"
|
||||
dusk="filter-selector"
|
||||
:autoHide="false"
|
||||
trigger="manual"
|
||||
:show="showDropDown"
|
||||
>
|
||||
<dropdown-trigger
|
||||
class="bg-30 px-3 border-2 border-30 rounded"
|
||||
:class="{ 'bg-primary border-primary': filtersAreApplied }"
|
||||
:active="filtersAreApplied"
|
||||
>
|
||||
<icon
|
||||
type="filter"
|
||||
:class="filtersAreApplied ? 'text-white' : 'text-80'"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="filtersAreApplied"
|
||||
class="ml-2 font-bold text-white text-80"
|
||||
>
|
||||
{{ activeFilterCount }}
|
||||
</span>
|
||||
</dropdown-trigger>
|
||||
|
||||
<dropdown-menu
|
||||
slot="menu"
|
||||
width="290"
|
||||
direction="rtl"
|
||||
:dark="true"
|
||||
v-on-clickaway="close"
|
||||
>
|
||||
<scroll-wrap :height="350">
|
||||
<div v-if="filtersAreApplied" class="bg-30 border-b border-60">
|
||||
<button
|
||||
@click="$emit('clear-selected-filters')"
|
||||
class="py-2 w-full block text-xs uppercase tracking-wide text-center text-80 dim font-bold focus:outline-none"
|
||||
>
|
||||
{{ __('Reset Filters') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Custom Filters -->
|
||||
<component
|
||||
v-for="filter in filters"
|
||||
:resource-name="resourceName"
|
||||
:key="filter.name"
|
||||
:filter-key="filter.class"
|
||||
:is="filter.component"
|
||||
:lens="lens"
|
||||
@input="$emit('filter-changed')"
|
||||
@change="$emit('filter-changed')"
|
||||
/>
|
||||
|
||||
<!-- Soft Deletes -->
|
||||
<div v-if="softDeletes" dusk="filter-soft-deletes">
|
||||
<h3
|
||||
slot="default"
|
||||
class="text-sm uppercase tracking-wide text-80 bg-30 p-3"
|
||||
>
|
||||
{{ __('Trashed') }}
|
||||
</h3>
|
||||
|
||||
<div class="p-2">
|
||||
<select
|
||||
slot="select"
|
||||
class="block w-full form-control-sm form-select"
|
||||
dusk="trashed-select"
|
||||
:value="trashed"
|
||||
@change="trashedChanged"
|
||||
>
|
||||
<option value="" selected>—</option>
|
||||
<option value="with">{{ __('With Trashed') }}</option>
|
||||
<option value="only">{{ __('Only Trashed') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per Page -->
|
||||
<div v-if="!viaResource" dusk="filter-per-page">
|
||||
<h3
|
||||
slot="default"
|
||||
class="text-sm uppercase tracking-wide text-80 bg-30 p-3"
|
||||
>
|
||||
{{ __('Per Page') }}
|
||||
</h3>
|
||||
|
||||
<div class="p-2">
|
||||
<select
|
||||
slot="select"
|
||||
dusk="per-page-select"
|
||||
class="block w-full form-control-sm form-select"
|
||||
:value="perPage"
|
||||
@change="perPageChanged"
|
||||
>
|
||||
<option v-for="option in perPageOptions" :key="option">
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</scroll-wrap>
|
||||
</dropdown-menu>
|
||||
</dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mixin as clickaway } from 'vue-clickaway'
|
||||
import composedPath from '@/polyfills/composedPath'
|
||||
|
||||
export default {
|
||||
mixins: [clickaway],
|
||||
|
||||
props: {
|
||||
resourceName: String,
|
||||
lens: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
viaResource: String,
|
||||
viaHasOne: Boolean,
|
||||
softDeletes: Boolean,
|
||||
trashed: {
|
||||
type: String,
|
||||
validator: value => ['', 'with', 'only'].indexOf(value) != -1,
|
||||
},
|
||||
perPage: [String, Number],
|
||||
perPageOptions: Array,
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
showDropDown: false,
|
||||
classWhitelist: [
|
||||
'filter-menu-dropdown',
|
||||
'flatpickr-current-month',
|
||||
'flatpickr-next-month',
|
||||
'flatpickr-prev-month',
|
||||
'flatpickr-weekday',
|
||||
'flatpickr-weekdays',
|
||||
'flatpickr-calendar',
|
||||
],
|
||||
}),
|
||||
|
||||
methods: {
|
||||
trashedChanged(event) {
|
||||
this.$emit('trashed-changed', event.target.value)
|
||||
},
|
||||
|
||||
perPageChanged(event) {
|
||||
this.$emit('per-page-changed', event.target.value)
|
||||
},
|
||||
|
||||
toggleDropDown() {
|
||||
this.showDropDown = !this.showDropDown
|
||||
},
|
||||
|
||||
close(e) {
|
||||
if (!e.isTrusted) return
|
||||
|
||||
let classArray = Array.isArray(this.classWhitelist)
|
||||
? this.classWhitelist
|
||||
: [this.classWhitelist]
|
||||
|
||||
if (
|
||||
_.filter(classArray, className => pathIncludesClass(e, className))
|
||||
.length > 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.showDropDown = false
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Return the filters from state
|
||||
*/
|
||||
filters() {
|
||||
return this.$store.getters[`${this.resourceName}/filters`]
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine via state whether filters are applied
|
||||
*/
|
||||
filtersAreApplied() {
|
||||
return this.$store.getters[`${this.resourceName}/filtersAreApplied`]
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the number of active filters
|
||||
*/
|
||||
activeFilterCount() {
|
||||
return this.$store.getters[`${this.resourceName}/activeFilterCount`]
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function pathIncludesClass(event, className) {
|
||||
return composedPath(event)
|
||||
.filter(el => el !== document && el !== window)
|
||||
.reduce((acc, e) => acc.concat([...e.classList]), [])
|
||||
.includes(className)
|
||||
}
|
||||
</script>
|
||||
56
nova/resources/js/components/Filters/BooleanFilter.vue
Executable file
56
nova/resources/js/components/Filters/BooleanFilter.vue
Executable file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="text-sm uppercase tracking-wide text-80 bg-30 p-3">
|
||||
{{ filter.name }}
|
||||
</h3>
|
||||
|
||||
<BooleanOption
|
||||
:resource-name="resourceName"
|
||||
:key="option.value"
|
||||
v-for="option in options"
|
||||
:filter="filter"
|
||||
:option="option"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BooleanOption from '@/components/BooleanOption.vue'
|
||||
|
||||
export default {
|
||||
components: { BooleanOption },
|
||||
|
||||
props: {
|
||||
resourceName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filterKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
lens: String,
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleChange() {
|
||||
this.$emit('change')
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
filter() {
|
||||
return this.$store.getters[`${this.resourceName}/getFilter`](
|
||||
this.filterKey
|
||||
)
|
||||
},
|
||||
|
||||
options() {
|
||||
return this.$store.getters[`${this.resourceName}/getOptionsForFilter`](
|
||||
this.filterKey
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
77
nova/resources/js/components/Filters/DateFilter.vue
Executable file
77
nova/resources/js/components/Filters/DateFilter.vue
Executable file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="text-sm uppercase tracking-wide text-80 bg-30 p-3">
|
||||
{{ filter.name }}
|
||||
</h3>
|
||||
|
||||
<div class="p-2">
|
||||
<date-time-picker
|
||||
class="w-full form-control form-input form-input-bordered"
|
||||
dusk="date-filter"
|
||||
name="date-filter"
|
||||
autocomplete="off"
|
||||
:value="value"
|
||||
alt-format="Y-m-d"
|
||||
date-format="Y-m-d"
|
||||
:placeholder="placeholder"
|
||||
:enable-time="false"
|
||||
:enable-seconds="false"
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
@input.prevent=""
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
resourceName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filterKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
lens: String,
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleChange(value) {
|
||||
this.$store.commit(`${this.resourceName}/updateFilterState`, {
|
||||
filterClass: this.filterKey,
|
||||
value,
|
||||
})
|
||||
this.$emit('change')
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
placeholder() {
|
||||
return this.filter.placeholder || this.__('Choose date')
|
||||
},
|
||||
|
||||
value() {
|
||||
return this.filter.currentValue
|
||||
},
|
||||
|
||||
filter() {
|
||||
return this.$store.getters[`${this.resourceName}/getFilter`](
|
||||
this.filterKey
|
||||
)
|
||||
},
|
||||
|
||||
options() {
|
||||
return this.$store.getters[`${this.resourceName}/getOptionsForFilter`](
|
||||
this.filterKey
|
||||
)
|
||||
},
|
||||
|
||||
firstDayOfWeek() {
|
||||
return this.filter.firstDayOfWeek || 0
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
59
nova/resources/js/components/Filters/SelectFilter.vue
Executable file
59
nova/resources/js/components/Filters/SelectFilter.vue
Executable file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="text-sm uppercase tracking-wide text-80 bg-30 p-3">
|
||||
{{ filter.name }}
|
||||
</h3>
|
||||
|
||||
<div class="p-2">
|
||||
<select-control
|
||||
:dusk="`${filter.name}-filter-select`"
|
||||
class="block w-full form-control-sm form-select"
|
||||
:value="value"
|
||||
@change="handleChange"
|
||||
:options="filter.options"
|
||||
label="name"
|
||||
>
|
||||
<option value="" selected>—</option>
|
||||
</select-control>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
resourceName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filterKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
lens: String,
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleChange(event) {
|
||||
this.$store.commit(`${this.resourceName}/updateFilterState`, {
|
||||
filterClass: this.filterKey,
|
||||
value: event.target.value,
|
||||
})
|
||||
|
||||
this.$emit('change')
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
filter() {
|
||||
return this.$store.getters[`${this.resourceName}/getFilter`](
|
||||
this.filterKey
|
||||
)
|
||||
},
|
||||
|
||||
value() {
|
||||
return this.filter.currentValue
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user