Initial commit

This commit is contained in:
Developer
2025-04-21 16:03:20 +02:00
commit 2832896157
22874 changed files with 3092801 additions and 0 deletions

226
nova/resources/js/Nova.js vendored Normal file
View 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
}
}

View 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()
})
})

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

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

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

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

View 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()
})
})

View File

@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Index.vue renders 1`] = `<loading-view-stub></loading-view-stub>`;

View File

@@ -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 Normal file
View 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 Normal file
View 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)

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

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

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

View File

@@ -0,0 +1,3 @@
<template>
<div class="card"><slot /></div>
</template>

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

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

View 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, weve 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. Well 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>

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

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

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

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

View File

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

View File

@@ -0,0 +1,9 @@
<template>
<div />
</template>
<script>
export default {
props: ['resource', 'resourceName'],
}
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div />
</template>
<script>
export default {
props: ['dashboardName'],
}
</script>

View File

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

View File

@@ -0,0 +1,9 @@
<template>
<div class="flex w-full justify-end items-center" />
</template>
<script>
export default {
props: ['resource', 'resourceName', 'resourceId'],
}
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div />
</template>
<script>
export default {
props: ['resourceName'],
}
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div class="flex w-full justify-end items-center mx-3" />
</template>
<script>
export default {
props: ['resourceName'],
}
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div />
</template>
<script>
export default {
props: ['resourceName'],
}
</script>

View File

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

View File

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

View 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">&mdash;</span>
<p v-if="shouldShowToolbar" class="flex items-center text-sm mt-3">
<a
v-if="field.downloadable"
:dusk="field.attribute + '-download-link'"
@keydown.enter.prevent="download"
@click.prevent="download"
tabindex="0"
class="cursor-pointer 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>

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

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

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

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

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

View File

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

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

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

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

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

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

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

View File

@@ -0,0 +1,13 @@
<template>
<panel-item :field="field">
<p slot="value" class="text-90">
&middot;&middot;&middot;&middot;&middot;&middot;&middot;&middot;&middot;
</p>
</panel-item>
</template>
<script>
export default {
props: ['resource', 'resourceName', 'resourceId', 'field'],
}
</script>

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

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

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

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

View File

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

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

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
<template functional>
<transition name="fade" mode="out-in"> <slot /> </transition>
</template>

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
<template>
<div v-if="isNotObject" class="flex items-center key-value-item">
<div class="flex flex-grow border-b border-50 key-value-fields">
<div
class="w-48 cursor-text"
:class="{ 'bg-30': readOnlyKeys || !isEditable }"
>
<textarea
:dusk="`key-value-key-${index}`"
v-model="item.key"
@focus="handleKeyFieldFocus"
ref="keyField"
type="text"
class="font-mono text-sm resize-none block min-h-input w-full form-control form-input form-input-row py-4 text-90"
:disabled="!isEditable || readOnlyKeys"
style="background-clip: border-box"
:class="{
'bg-white': !isEditable || readOnlyKeys,
'hover:bg-20 focus:bg-white': isEditable && !readOnlyKeys,
}"
/>
</div>
<div @click="handleValueFieldFocus" class="flex-grow border-l border-50">
<textarea
:dusk="`key-value-value-${index}`"
v-model="item.value"
@focus="handleValueFieldFocus"
ref="valueField"
type="text"
class="font-mono text-sm block min-h-input w-full form-control form-input form-input-row py-4 text-90"
:disabled="!isEditable"
:class="{
'bg-white': !isEditable,
'hover:bg-20 focus:bg-white': isEditable,
}"
/>
</div>
</div>
<div
v-if="isEditable && canDeleteRow"
class="flex justify-center h-11 w-11 absolute"
style="right: -50px"
>
<button
@click="$emit('remove-row', item.id)"
:dusk="`remove-key-value-${index}`"
type="button"
tabindex="-1"
class="flex appearance-none cursor-pointer text-70 hover:text-primary active:outline-none active:shadow-outline focus:outline-none focus:shadow-outline"
title="Delete"
>
<icon />
</button>
</div>
</div>
</template>
<script>
import autosize from 'autosize'
export default {
props: {
index: Number,
item: Object,
disabled: {
type: Boolean,
default: false,
},
readOnly: {
type: Boolean,
default: false,
},
readOnlyKeys: {
type: Boolean,
default: false,
},
canDeleteRow: {
type: Boolean,
default: true,
},
},
mounted() {
autosize(this.$refs.keyField)
autosize(this.$refs.valueField)
},
methods: {
handleKeyFieldFocus() {
this.$refs.keyField.select()
},
handleValueFieldFocus() {
this.$refs.valueField.select()
},
},
computed: {
isNotObject() {
return !(this.item.value instanceof Object)
},
isEditable() {
return !this.readOnly && !this.disabled
},
},
}
</script>

View File

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

View File

@@ -0,0 +1,15 @@
<template>
<label :for="labelFor" class="inline-block text-80 pt-2 leading-tight">
<slot />
</label>
</template>
<script>
export default {
props: {
labelFor: {
type: String,
},
},
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,146 @@
<template>
<default-field :field="field" :errors="errors" :show-help-text="showHelpText">
<template slot="field">
<!-- Search Input -->
<search-input
v-if="!isReadonly && isSearchable"
@input="performSearch"
@clear="clearSelection"
@selected="selectOption"
:error="hasError"
:value="selectedOption"
:data="filteredOptions"
:clearable="field.nullable"
trackBy="value"
class="w-full"
>
<!-- The Selected Option Slot -->
<div slot="default" v-if="selectedOption" class="flex items-center">
{{ selectedOption.label }}
</div>
<!-- Options List Slot -->
<div
slot="option"
slot-scope="{ option, selected }"
class="flex items-center text-sm font-semibold leading-5 text-90"
:class="{ 'text-white': selected }"
>
{{ option.label }}
</div>
</search-input>
<!-- Select Input Field -->
<select-control
v-else
:id="field.attribute"
:dusk="field.attribute"
@change="handleChange"
:value="this.value"
class="w-full form-control form-select"
:class="errorClasses"
:options="field.options"
:disabled="isReadonly"
>
<option value="" selected :disabled="!field.nullable">
{{ placeholder }}
</option>
</select-control>
</template>
</default-field>
</template>
<script>
import { FormField, HandlesValidationErrors } from 'laravel-nova'
export default {
mixins: [HandlesValidationErrors, FormField],
data: () => ({
selectedOption: null,
search: '',
}),
created() {
if (this.field.value && this.isSearchable) {
this.selectedOption = _(this.field.options).find(
v => v.value == this.field.value
)
}
},
methods: {
/**
* Provide a function that fills a passed FormData object with the
* field's internal value attribute. Here we are forcing there to be a
* value sent to the server instead of the default behavior of
* `this.value || ''` to avoid loose-comparison issues if the keys
* are truthy or falsey
*/
fill(formData) {
formData.append(this.field.attribute, this.value)
},
/**
* Set the search string to be used to filter the select field.
*/
performSearch(event) {
this.search = event
},
/**
* Clear the current selection for the field.
*/
clearSelection() {
this.selectedOption = ''
this.value = ''
},
/**
* Select the given option.
*/
selectOption(option) {
this.selectedOption = option
this.value = option.value
},
/**
* Handle the selection change event.
*/
handleChange(e) {
this.value = e.target.value
if (this.field) {
Nova.$emit(this.field.attribute + '-change', this.value)
}
},
},
computed: {
/**
* Determine if the related resources is searchable
*/
isSearchable() {
return this.field.searchable
},
/**
* Return the field options filtered by the search string.
*/
filteredOptions() {
return this.field.options.filter(option => {
return (
option.label.toLowerCase().indexOf(this.search.toLowerCase()) > -1
)
})
},
/**
* Return the placeholder text for the field.
*/
placeholder() {
return this.field.placeholder || this.__('Choose an option')
},
},
}
</script>

Some files were not shown because too many files have changed in this diff Show More