add nova
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import InlineFormData from '@/fields/Form/InlineFormData'
|
||||
|
||||
it('test it can generate proper nested attributes name', () => {
|
||||
global.FormData = class FormData {}
|
||||
|
||||
let inlineFormData = new InlineFormData('profile', new FormData())
|
||||
|
||||
expect(inlineFormData.name('email')).toEqual('profile[email]')
|
||||
expect(inlineFormData.name('email[]')).toEqual('profile[email][]')
|
||||
expect(inlineFormData.name('metadata[][filename]')).toEqual(
|
||||
'profile[metadata][][filename]'
|
||||
)
|
||||
expect(inlineFormData.name('metadata[][extension]')).toEqual(
|
||||
'profile[metadata][][extension]'
|
||||
)
|
||||
expect(inlineFormData.name('vaporFile[attribute][filename]')).toEqual(
|
||||
'profile[vaporFile][attribute][filename]'
|
||||
)
|
||||
expect(inlineFormData.name('vaporFile[attribute][extension]')).toEqual(
|
||||
'profile[vaporFile][attribute][extension]'
|
||||
)
|
||||
})
|
||||
|
||||
it('can generate proper nested attributes slug', () => {
|
||||
global.FormData = class FormData {}
|
||||
|
||||
let inlineFormData = new InlineFormData('profile', new FormData())
|
||||
|
||||
expect(inlineFormData.slug('email')).toEqual('profile.email')
|
||||
expect(inlineFormData.slug('email[]')).toEqual('profile.email[]')
|
||||
expect(inlineFormData.slug('metadata[][filename]')).toEqual(
|
||||
'profile.metadata[][filename]'
|
||||
)
|
||||
expect(inlineFormData.slug('metadata[][extension]')).toEqual(
|
||||
'profile.metadata[][extension]'
|
||||
)
|
||||
expect(inlineFormData.slug('vaporFile[attribute][filename]')).toEqual(
|
||||
'profile.vaporFile[attribute][filename]'
|
||||
)
|
||||
expect(inlineFormData.slug('vaporFile[attribute][extension]')).toEqual(
|
||||
'profile.vaporFile[attribute][extension]'
|
||||
)
|
||||
})
|
||||
75
nova/resources/js/__tests__/mixins/FieldValue.test.js
Normal file
75
nova/resources/js/__tests__/mixins/FieldValue.test.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import FieldValue from '@/mixins/FieldValue'
|
||||
|
||||
class DummyComponent {
|
||||
constructor(value) {
|
||||
this.field = {
|
||||
value: value,
|
||||
displayedAs: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('it can validate given value as integer', () => {
|
||||
let form = new DummyComponent(5)
|
||||
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 5)).toBe(true)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, '5')).toBe(true)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 0)).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, '0')).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, null)).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, '')).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 'laravel')).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 'nova')).toBe(false)
|
||||
})
|
||||
|
||||
test('it can validate given value as integer (string)', () => {
|
||||
let form = new DummyComponent('5')
|
||||
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 5)).toBe(true)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, '5')).toBe(true)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 0)).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, '0')).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, null)).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, '')).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 'laravel')).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 'nova')).toBe(false)
|
||||
})
|
||||
|
||||
test('it can validate given value as string', () => {
|
||||
let form = new DummyComponent('laravel')
|
||||
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 5)).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, '5')).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 0)).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, '0')).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, null)).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, '')).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 'laravel')).toBe(true)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 'nova')).toBe(false)
|
||||
})
|
||||
|
||||
test('it can validate given value as empty string', () => {
|
||||
let form = new DummyComponent('')
|
||||
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 5)).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, '5')).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 0)).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, '0')).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, null)).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, '')).toBe(true)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 'laravel')).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 'nova')).toBe(false)
|
||||
})
|
||||
|
||||
test('it can validate given value as null', () => {
|
||||
let form = new DummyComponent(null)
|
||||
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 5)).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, '5')).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 0)).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, '0')).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, null)).toBe(true)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, '')).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 'laravel')).toBe(false)
|
||||
expect(FieldValue.methods.isEqualsToValue.call(form, 'nova')).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import InteractsWithDates from '@/mixins/InteractsWithDates'
|
||||
|
||||
afterAll(() => {
|
||||
delete global.Nova
|
||||
})
|
||||
|
||||
test('it can get user timezone', () => {
|
||||
global.Nova = {
|
||||
config(key) {
|
||||
return this.appConfig[key] ?? null
|
||||
},
|
||||
appConfig: {
|
||||
timezone: 'UTC',
|
||||
userTimezone: 'Asia/Kuala_Lumpur',
|
||||
},
|
||||
}
|
||||
|
||||
expect(InteractsWithDates.computed.userTimezone()).toBe('Asia/Kuala_Lumpur')
|
||||
})
|
||||
|
||||
test('it can fallback to application timezone if user does not define timezone', () => {
|
||||
global.Nova = {
|
||||
config(key) {
|
||||
return this.appConfig[key] ?? null
|
||||
},
|
||||
appConfig: {
|
||||
timezone: 'UTC',
|
||||
},
|
||||
}
|
||||
|
||||
expect(InteractsWithDates.computed.userTimezone()).toBe('UTC')
|
||||
})
|
||||
27
nova/resources/js/__tests__/mixins/packages.test.js
Normal file
27
nova/resources/js/__tests__/mixins/packages.test.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useLocalization } from '@/mixins/packages'
|
||||
|
||||
afterAll(() => {
|
||||
delete global.Nova
|
||||
})
|
||||
|
||||
test('it can use localization', () => {
|
||||
const { __ } = useLocalization()
|
||||
|
||||
global.Nova = {
|
||||
config(key) {
|
||||
return this.appConfig[key] ?? null
|
||||
},
|
||||
appConfig: {
|
||||
translations: {
|
||||
taylorotwell: 'Taylor Otwell',
|
||||
'Laravel Nova :version': 'Laravel Nova v:version',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(__('taylorotwell')).toBe('Taylor Otwell')
|
||||
expect(__('Laravel Nova')).toBe('Laravel Nova')
|
||||
expect(__('Laravel Nova :version', { version: '4.0.0' })).toBe(
|
||||
'Laravel Nova v4.0.0'
|
||||
)
|
||||
})
|
||||
13
nova/resources/js/__tests__/util/hourCycle.test.js
Normal file
13
nova/resources/js/__tests__/util/hourCycle.test.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { default as hourCycle } from '@/util/hourCycle'
|
||||
|
||||
it('can uses 12 hour cycles', () => {
|
||||
expect(hourCycle('en-US')).toEqual(12)
|
||||
expect(hourCycle('ms-MY')).toEqual(12)
|
||||
expect(hourCycle('ko-KR')).toEqual(12)
|
||||
expect(hourCycle('ar-EG')).toEqual(12)
|
||||
})
|
||||
|
||||
it('can uses 24 hour cycles', () => {
|
||||
expect(hourCycle('en-GB')).toEqual(24)
|
||||
expect(hourCycle('ja-JP')).toEqual(24)
|
||||
})
|
||||
17
nova/resources/js/__tests__/util/increaseOrDecrease.test.js
Normal file
17
nova/resources/js/__tests__/util/increaseOrDecrease.test.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import increaseOrDecrease from '@/util/increaseOrDecrease'
|
||||
|
||||
test('it can calculate increase in percentage', () => {
|
||||
expect(increaseOrDecrease(50, 0)).toBe(null)
|
||||
expect(increaseOrDecrease(45, 10)).toBe(350)
|
||||
expect(increaseOrDecrease(45, 36)).toBe(25)
|
||||
expect(increaseOrDecrease(45, 40)).toBe(12.5)
|
||||
expect(increaseOrDecrease(50, -50)).toBe(200)
|
||||
})
|
||||
|
||||
test('it can calculate decrease in percentage', () => {
|
||||
expect(increaseOrDecrease(0, 50)).toBe(-100)
|
||||
expect(increaseOrDecrease(10, 45)).toBe(-77.77777777777779)
|
||||
expect(increaseOrDecrease(36, 45)).toBe(-20)
|
||||
expect(increaseOrDecrease(40, 45)).toBe(-11.11111111111111)
|
||||
expect(increaseOrDecrease(-50, 50)).toBe(-200)
|
||||
})
|
||||
14
nova/resources/js/__tests__/util/singularOrPlural.test.js
Normal file
14
nova/resources/js/__tests__/util/singularOrPlural.test.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import singularOrPlural from '@/util/singularOrPlural'
|
||||
|
||||
test('it can return correct inflector results', () => {
|
||||
expect(singularOrPlural(0, 'hour')).toBe('hours')
|
||||
expect(singularOrPlural(1, 'hour')).toBe('hour')
|
||||
expect(singularOrPlural(1.23, 'hour')).toBe('hours')
|
||||
expect(singularOrPlural(40, 'hour')).toBe('hours')
|
||||
expect(singularOrPlural(40, 'Bouqueté')).toBe('Bouquetés')
|
||||
})
|
||||
|
||||
test('it does ignore when suffix is a symbol', () => {
|
||||
expect(singularOrPlural(40, '%')).toBe('%')
|
||||
expect(singularOrPlural(40, '!')).toBe('!')
|
||||
})
|
||||
132
nova/resources/js/__tests__/util/tailwindConfig.test.js
Normal file
132
nova/resources/js/__tests__/util/tailwindConfig.test.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
generateRootCSSVars,
|
||||
generateTailwindColors,
|
||||
} from '../../../../generators'
|
||||
|
||||
it('generates Tailwind colors', () => {
|
||||
expect(generateTailwindColors()).toEqual(
|
||||
expect.objectContaining({
|
||||
current: 'currentColor',
|
||||
inherit: 'inherit',
|
||||
transparent: 'transparent',
|
||||
black: '#000',
|
||||
white: '#fff',
|
||||
primary: {
|
||||
100: 'rgba(var(--colors-primary-100))',
|
||||
200: 'rgba(var(--colors-primary-200))',
|
||||
300: 'rgba(var(--colors-primary-300))',
|
||||
400: 'rgba(var(--colors-primary-400))',
|
||||
50: 'rgba(var(--colors-primary-50))',
|
||||
500: 'rgba(var(--colors-primary-500))',
|
||||
600: 'rgba(var(--colors-primary-600))',
|
||||
700: 'rgba(var(--colors-primary-700))',
|
||||
800: 'rgba(var(--colors-primary-800))',
|
||||
900: 'rgba(var(--colors-primary-900))',
|
||||
950: 'rgba(var(--colors-primary-950))',
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const data = {
|
||||
lightBlue: {
|
||||
100: 'rgba(var(--colors-lightBlue-100))',
|
||||
200: 'rgba(var(--colors-lightBlue-200))',
|
||||
300: 'rgba(var(--colors-lightBlue-300))',
|
||||
400: 'rgba(var(--colors-lightBlue-400))',
|
||||
50: 'rgba(var(--colors-lightBlue-50))',
|
||||
500: 'rgba(var(--colors-lightBlue-500))',
|
||||
600: 'rgba(var(--colors-lightBlue-600))',
|
||||
700: 'rgba(var(--colors-lightBlue-700))',
|
||||
800: 'rgba(var(--colors-lightBlue-800))',
|
||||
900: 'rgba(var(--colors-lightBlue-900))',
|
||||
},
|
||||
|
||||
warmGray: {
|
||||
100: 'rgba(var(--colors-warmGray-100))',
|
||||
200: 'rgba(var(--colors-warmGray-200))',
|
||||
300: 'rgba(var(--colors-warmGray-300))',
|
||||
400: 'rgba(var(--colors-warmGray-400))',
|
||||
50: 'rgba(var(--colors-warmGray-50))',
|
||||
500: 'rgba(var(--colors-warmGray-500))',
|
||||
600: 'rgba(var(--colors-warmGray-600))',
|
||||
700: 'rgba(var(--colors-warmGray-700))',
|
||||
800: 'rgba(var(--colors-warmGray-800))',
|
||||
900: 'rgba(var(--colors-warmGray-900))',
|
||||
},
|
||||
|
||||
trueGray: {
|
||||
100: 'rgba(var(--colors-trueGray-100))',
|
||||
200: 'rgba(var(--colors-trueGray-200))',
|
||||
300: 'rgba(var(--colors-trueGray-300))',
|
||||
400: 'rgba(var(--colors-trueGray-400))',
|
||||
50: 'rgba(var(--colors-trueGray-50))',
|
||||
500: 'rgba(var(--colors-trueGray-500))',
|
||||
600: 'rgba(var(--colors-trueGray-600))',
|
||||
700: 'rgba(var(--colors-trueGray-700))',
|
||||
800: 'rgba(var(--colors-trueGray-800))',
|
||||
900: 'rgba(var(--colors-trueGray-900))',
|
||||
},
|
||||
|
||||
coolGray: {
|
||||
100: 'rgba(var(--colors-coolGray-100))',
|
||||
200: 'rgba(var(--colors-coolGray-200))',
|
||||
300: 'rgba(var(--colors-coolGray-300))',
|
||||
400: 'rgba(var(--colors-coolGray-400))',
|
||||
50: 'rgba(var(--colors-coolGray-50))',
|
||||
500: 'rgba(var(--colors-coolGray-500))',
|
||||
600: 'rgba(var(--colors-coolGray-600))',
|
||||
700: 'rgba(var(--colors-coolGray-700))',
|
||||
800: 'rgba(var(--colors-coolGray-800))',
|
||||
900: 'rgba(var(--colors-coolGray-900))',
|
||||
},
|
||||
|
||||
blueGray: {
|
||||
100: 'rgba(var(--colors-blueGray-100))',
|
||||
200: 'rgba(var(--colors-blueGray-200))',
|
||||
300: 'rgba(var(--colors-blueGray-300))',
|
||||
400: 'rgba(var(--colors-blueGray-400))',
|
||||
50: 'rgba(var(--colors-blueGray-50))',
|
||||
500: 'rgba(var(--colors-blueGray-500))',
|
||||
600: 'rgba(var(--colors-blueGray-600))',
|
||||
700: 'rgba(var(--colors-blueGray-700))',
|
||||
800: 'rgba(var(--colors-blueGray-800))',
|
||||
900: 'rgba(var(--colors-blueGray-900))',
|
||||
},
|
||||
}
|
||||
|
||||
describe.each(Object.keys(data))(
|
||||
`It does not generate the deprecated Tailwind colors`,
|
||||
key => {
|
||||
it(`does not generate "${key}" colors`, () => {
|
||||
expect(generateTailwindColors()).toEqual(
|
||||
expect.not.objectContaining({ [key]: data[key] })
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('generates root CSS variables', () => {
|
||||
expect(generateRootCSSVars()).toEqual(
|
||||
expect.objectContaining({
|
||||
'--colors-primary-50': '240, 249, 255',
|
||||
'--colors-primary-100': '224, 242, 254',
|
||||
'--colors-primary-200': '186, 230, 253',
|
||||
'--colors-primary-300': '125, 211, 252',
|
||||
'--colors-primary-400': '56, 189, 248',
|
||||
'--colors-primary-500': '14, 165, 233',
|
||||
'--colors-primary-600': '2, 132, 199',
|
||||
'--colors-primary-700': '3, 105, 161',
|
||||
'--colors-primary-800': '7, 89, 133',
|
||||
'--colors-primary-900': '12, 74, 110',
|
||||
})
|
||||
)
|
||||
|
||||
expect(generateRootCSSVars()).toEqual(
|
||||
expect.not.objectContaining({
|
||||
'--colors-inherit': 'inherit',
|
||||
'--colors-current': 'current',
|
||||
'--colors-transparent': 'transparent',
|
||||
})
|
||||
)
|
||||
})
|
||||
14
nova/resources/js/__tests__/util/url.test.js
Normal file
14
nova/resources/js/__tests__/util/url.test.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import url from '@/util/url'
|
||||
|
||||
it('it can generate proper urls', () => {
|
||||
expect(url('nova', '/resources/users')).toEqual('nova/resources/users')
|
||||
expect(url('nova', '/resources/users', { users_per_page: 15 })).toEqual(
|
||||
'nova/resources/users?users_per_page=15'
|
||||
)
|
||||
expect(
|
||||
url('nova', '/resources/users', { search: 'nova', users_per_page: 15 })
|
||||
).toEqual('nova/resources/users?search=nova&users_per_page=15')
|
||||
expect(url('nova', '/resources/users', { resources: [1, 2, 3] })).toEqual(
|
||||
'nova/resources/users?resources=1%2C2%2C3'
|
||||
)
|
||||
})
|
||||
75
nova/resources/js/__tests__/vendor/luxon.test.js
vendored
Normal file
75
nova/resources/js/__tests__/vendor/luxon.test.js
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
it('can handle UTC datetime', () => {
|
||||
expect(
|
||||
DateTime.fromISO('2021-10-14T02:48:15+00:00')
|
||||
.setZone('UTC')
|
||||
.toISO()
|
||||
).toEqual('2021-10-14T02:48:15.000Z')
|
||||
})
|
||||
|
||||
it('can convert datetime from UTC', () => {
|
||||
expect(
|
||||
DateTime.fromISO('2021-10-14T02:48:15+00:00')
|
||||
.setZone('America/Chicago')
|
||||
.toISO()
|
||||
).toEqual('2021-10-13T21:48:15.000-05:00')
|
||||
expect(
|
||||
DateTime.fromISO('2021-10-14T02:48:15+00:00')
|
||||
.setZone('America/Mexico_City')
|
||||
.toISO()
|
||||
).toEqual('2021-10-13T21:48:15.000-05:00')
|
||||
expect(
|
||||
DateTime.fromISO('2023-05-02T14:00:00+00:00')
|
||||
.setZone('America/Mexico_City')
|
||||
.toISO()
|
||||
).toEqual('2023-05-02T08:00:00.000-06:00')
|
||||
expect(
|
||||
DateTime.fromISO('2021-10-14T02:48:15+00:00')
|
||||
.setZone('Europe/Paris')
|
||||
.toISO()
|
||||
).toEqual('2021-10-14T04:48:15.000+02:00')
|
||||
expect(
|
||||
DateTime.fromISO('2022-05-10T10:00:00+00:00')
|
||||
.setZone('Europe/Paris')
|
||||
.toISO()
|
||||
).toEqual('2022-05-10T12:00:00.000+02:00')
|
||||
expect(
|
||||
DateTime.fromISO('2021-10-14T02:48:15+00:00')
|
||||
.setZone('Asia/Kuala_Lumpur')
|
||||
.toISO()
|
||||
).toEqual('2021-10-14T10:48:15.000+08:00')
|
||||
})
|
||||
|
||||
it('can convert datetime to UTC', () => {
|
||||
expect(
|
||||
DateTime.fromISO('2021-10-13T21:48:15.000-05:00', { zone: 'America/Chicago' })
|
||||
.setZone('UTC')
|
||||
.toISO()
|
||||
).toEqual('2021-10-14T02:48:15.000Z')
|
||||
expect(
|
||||
DateTime.fromISO('2021-10-13T21:48:15.000-05:00', { zone: 'America/Mexico_City' })
|
||||
.setZone('UTC')
|
||||
.toISO()
|
||||
).toEqual('2021-10-14T02:48:15.000Z')
|
||||
expect(
|
||||
DateTime.fromISO('2023-05-02T08:00:00.000-06:00', { zone: 'America/Mexico_City' })
|
||||
.setZone('UTC')
|
||||
.toISO()
|
||||
).toEqual('2023-05-02T14:00:00.000Z')
|
||||
expect(
|
||||
DateTime.fromISO('2021-10-14T04:48:15.000+02:00', { zone: 'Europe/Paris' })
|
||||
.setZone('UTC')
|
||||
.toISO()
|
||||
).toEqual('2021-10-14T02:48:15.000Z')
|
||||
expect(
|
||||
DateTime.fromISO('2022-05-10T12:00:00.000+02:00', { zone: 'Europe/Paris' })
|
||||
.setZone('UTC')
|
||||
.toISO()
|
||||
).toEqual('2022-05-10T10:00:00.000Z')
|
||||
expect(
|
||||
DateTime.fromISO('2021-10-14T10:48:15.000+08:00', { zone: 'Asia/Kuala_Lumpur' })
|
||||
.setZone('UTC')
|
||||
.toISO()
|
||||
).toEqual('2021-10-14T02:48:15.000Z')
|
||||
})
|
||||
515
nova/resources/js/app.js
Normal file
515
nova/resources/js/app.js
Normal file
@@ -0,0 +1,515 @@
|
||||
import Localization from '@/mixins/Localization'
|
||||
import { setupAxios } from '@/util/axios'
|
||||
import { setupNumbro } from '@/util/numbro'
|
||||
import { setupInertia } from '@/util/inertia'
|
||||
import url from '@/util/url'
|
||||
import { createInertiaApp, Head, Link } from '@inertiajs/inertia-vue3'
|
||||
import { Inertia } from '@inertiajs/inertia'
|
||||
import NProgress from 'nprogress'
|
||||
import { registerViews } from './components'
|
||||
import { registerFields } from './fields'
|
||||
import Mousetrap from 'mousetrap'
|
||||
import Form from 'form-backend-validation'
|
||||
import { createNovaStore } from './store'
|
||||
import resourceStore from './store/resources'
|
||||
import FloatingVue from 'floating-vue'
|
||||
import find from 'lodash/find'
|
||||
import isNil from 'lodash/isNil'
|
||||
import fromPairs from 'lodash/fromPairs'
|
||||
import isString from 'lodash/isString'
|
||||
import omit from 'lodash/omit'
|
||||
import Toasted from 'toastedjs'
|
||||
import Emitter from 'tiny-emitter'
|
||||
import Layout from '@/layouts/AppLayout'
|
||||
import CodeMirror from 'codemirror'
|
||||
import { Settings } from 'luxon'
|
||||
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'
|
||||
import { ColorTranslator } from 'colortranslator'
|
||||
|
||||
import 'floating-vue/dist/style.css'
|
||||
|
||||
const { parseColor } = require('tailwindcss/lib/util/color')
|
||||
|
||||
CodeMirror.defineMode('htmltwig', function (config, parserConfig) {
|
||||
return CodeMirror.overlayMode(
|
||||
CodeMirror.getMode(config, parserConfig.backdrop || 'text/html'),
|
||||
CodeMirror.getMode(config, 'twig')
|
||||
)
|
||||
})
|
||||
|
||||
const emitter = new Emitter()
|
||||
|
||||
window.createNovaApp = config => new Nova(config)
|
||||
window.Vue = require('vue')
|
||||
|
||||
const { createApp, h } = window.Vue
|
||||
|
||||
class Nova {
|
||||
constructor(config) {
|
||||
this.bootingCallbacks = []
|
||||
this.appConfig = config
|
||||
this.useShortcuts = true
|
||||
|
||||
this.pages = {
|
||||
'Nova.Attach': require('@/pages/Attach').default,
|
||||
'Nova.Create': require('@/pages/Create').default,
|
||||
'Nova.Dashboard': require('@/pages/Dashboard').default,
|
||||
'Nova.Detail': require('@/pages/Detail').default,
|
||||
'Nova.Error': require('@/pages/AppError').default,
|
||||
'Nova.Error403': require('@/pages/Error403').default,
|
||||
'Nova.Error404': require('@/pages/Error404').default,
|
||||
'Nova.ForgotPassword': require('@/pages/ForgotPassword').default,
|
||||
'Nova.Index': require('@/pages/Index').default,
|
||||
'Nova.Lens': require('@/pages/Lens').default,
|
||||
'Nova.Login': require('@/pages/Login').default,
|
||||
'Nova.Replicate': require('@/pages/Replicate').default,
|
||||
'Nova.ResetPassword': require('@/pages/ResetPassword').default,
|
||||
'Nova.Update': require('@/pages/Update').default,
|
||||
'Nova.UpdateAttached': require('@/pages/UpdateAttached').default,
|
||||
}
|
||||
|
||||
this.$toasted = new Toasted({
|
||||
theme: 'nova',
|
||||
position: config.rtlEnabled ? 'bottom-left' : 'bottom-right',
|
||||
duration: 6000,
|
||||
})
|
||||
this.$progress = NProgress
|
||||
this.$router = Inertia
|
||||
|
||||
if (config.debug === true) {
|
||||
this.$testing = {
|
||||
timezone: timezone => {
|
||||
Settings.defaultZoneName = timezone
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.store = createNovaStore()
|
||||
|
||||
this.bootingCallbacks.forEach(callback => callback(this.app, this.store))
|
||||
this.bootingCallbacks = []
|
||||
}
|
||||
|
||||
booted(callback) {
|
||||
callback(this.app, this.store)
|
||||
}
|
||||
|
||||
async countdown() {
|
||||
this.log('Initiating Nova countdown...')
|
||||
|
||||
const appName = this.config('appName')
|
||||
|
||||
await createInertiaApp({
|
||||
title: title => (!title ? appName : `${title} - ${appName}`),
|
||||
resolve: name => {
|
||||
const page = !isNil(this.pages[name])
|
||||
? this.pages[name]
|
||||
: require('@/pages/Error404').default
|
||||
|
||||
page.layout = page.layout || Layout
|
||||
|
||||
return page
|
||||
},
|
||||
setup: ({ el, App, props, plugin }) => {
|
||||
this.mountTo = el
|
||||
this.app = createApp({ render: () => h(App, props) })
|
||||
|
||||
this.app.use(plugin)
|
||||
this.app.use(FloatingVue, {
|
||||
preventOverflow: true,
|
||||
flip: true,
|
||||
themes: {
|
||||
Nova: {
|
||||
$extend: 'tooltip',
|
||||
triggers: ['click'],
|
||||
autoHide: true,
|
||||
placement: 'bottom',
|
||||
html: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Nova app by calling each of the tool's callbacks and then creating
|
||||
* the underlying Vue instance.
|
||||
*/
|
||||
liftOff() {
|
||||
this.log('We have lift off!')
|
||||
|
||||
this.boot()
|
||||
|
||||
if (this.config('notificationCenterEnabled')) {
|
||||
this.notificationPollingInterval = setInterval(() => {
|
||||
if (document.hasFocus()) {
|
||||
this.$emit('refresh-notifications')
|
||||
}
|
||||
}, this.config('notificationPollingInterval'))
|
||||
}
|
||||
|
||||
this.registerStoreModules()
|
||||
|
||||
this.app.mixin(Localization)
|
||||
|
||||
setupInertia()
|
||||
|
||||
document.addEventListener('inertia:before', () => {
|
||||
;(async () => {
|
||||
this.log('Syncing Inertia props to the store...')
|
||||
await this.store.dispatch('assignPropsFromInertia')
|
||||
})()
|
||||
})
|
||||
|
||||
document.addEventListener('inertia:navigate', () => {
|
||||
;(async () => {
|
||||
this.log('Syncing Inertia props to the store...')
|
||||
await this.store.dispatch('assignPropsFromInertia')
|
||||
})()
|
||||
})
|
||||
|
||||
this.app.mixin({
|
||||
methods: {
|
||||
$url: (path, parameters) => this.url(path, parameters),
|
||||
},
|
||||
})
|
||||
|
||||
this.component('Link', Link)
|
||||
this.component('InertiaLink', Link)
|
||||
this.component('Head', Head)
|
||||
|
||||
registerViews(this)
|
||||
registerFields(this)
|
||||
|
||||
this.app.mount(this.mountTo)
|
||||
|
||||
let mousetrapDefaultStopCallback = Mousetrap.prototype.stopCallback
|
||||
|
||||
Mousetrap.prototype.stopCallback = (e, element, combo) => {
|
||||
if (!this.useShortcuts) {
|
||||
return true
|
||||
}
|
||||
|
||||
return mousetrapDefaultStopCallback.call(this, e, element, combo)
|
||||
}
|
||||
|
||||
Mousetrap.init()
|
||||
|
||||
this.applyTheme()
|
||||
|
||||
this.log('All systems go...')
|
||||
}
|
||||
|
||||
config(key) {
|
||||
return this.appConfig[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a form object configured with Nova's preconfigured axios instance.
|
||||
*
|
||||
* @param {object} data
|
||||
*/
|
||||
form(data) {
|
||||
return new Form(data, {
|
||||
http: this.request(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an axios instance configured to make requests to Nova's API
|
||||
* and handle certain response codes.
|
||||
*/
|
||||
request(options) {
|
||||
let axios = setupAxios()
|
||||
|
||||
if (options !== undefined) {
|
||||
return axios(options)
|
||||
}
|
||||
|
||||
return axios
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL from base Nova prefix.
|
||||
*/
|
||||
url(path, parameters) {
|
||||
if (path === '/') {
|
||||
path = this.config('initialPath')
|
||||
}
|
||||
|
||||
return url(this.config('base'), path, parameters)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a listener on Nova's built-in event bus
|
||||
*/
|
||||
$on(...args) {
|
||||
emitter.on(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a one-time listener on the event bus
|
||||
*/
|
||||
$once(...args) {
|
||||
emitter.once(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister an listener on the event bus
|
||||
*/
|
||||
$off(...args) {
|
||||
emitter.off(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event on the event bus
|
||||
*/
|
||||
$emit(...args) {
|
||||
emitter.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
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the built-in Vuex modules for each resource
|
||||
*/
|
||||
registerStoreModules() {
|
||||
this.app.use(this.store)
|
||||
|
||||
this.config('resources').forEach(resource => {
|
||||
this.store.registerModule(resource.uriKey, resourceStore)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Inertia component.
|
||||
*/
|
||||
inertia(name, component) {
|
||||
this.pages[name] = component
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom Vue component.
|
||||
*/
|
||||
component(name, component) {
|
||||
if (isNil(this.app._context.components[name])) {
|
||||
this.app.component(name, component)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an error message to the user.
|
||||
*
|
||||
* @param {string} message
|
||||
*/
|
||||
info(message) {
|
||||
this.$toasted.show(message, { type: 'info' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an error message to the user.
|
||||
*
|
||||
* @param {string} message
|
||||
*/
|
||||
error(message) {
|
||||
this.$toasted.show(message, { type: 'error' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a success message to the user.
|
||||
*
|
||||
* @param {string} message
|
||||
*/
|
||||
success(message) {
|
||||
this.$toasted.show(message, { type: 'success' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a warning message to the user.
|
||||
*
|
||||
* @param {string} message
|
||||
*/
|
||||
warning(message) {
|
||||
this.$toasted.show(message, { type: 'warning' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number using numbro.js for consistent number formatting.
|
||||
*/
|
||||
formatNumber(number, format) {
|
||||
const numbro = setupNumbro(
|
||||
document.querySelector('meta[name="locale"]').content
|
||||
)
|
||||
const num = numbro(number)
|
||||
|
||||
if (format !== undefined) {
|
||||
return num.format(format)
|
||||
}
|
||||
|
||||
return num.format()
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message to the console with the NOVA prefix
|
||||
*
|
||||
* @param message
|
||||
* @param type
|
||||
*/
|
||||
log(message, type = 'log') {
|
||||
console[type](`[NOVA]`, message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to login path.
|
||||
*/
|
||||
redirectToLogin() {
|
||||
const url =
|
||||
!this.config('withAuthentication') && this.config('customLoginPath')
|
||||
? this.config('customLoginPath')
|
||||
: this.url('/login')
|
||||
|
||||
this.visit({
|
||||
remote: true,
|
||||
url,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit page using Inertia visit or window.location for remote.
|
||||
*/
|
||||
visit(path, options) {
|
||||
options = options || {}
|
||||
const openInNewTab = options?.openInNewTab || null
|
||||
|
||||
if (isString(path)) {
|
||||
Inertia.visit(this.url(path), omit(options, ['openInNewTab']))
|
||||
return
|
||||
}
|
||||
|
||||
if (isString(path.url) && path.hasOwnProperty('remote')) {
|
||||
if (path.remote === true) {
|
||||
if (openInNewTab === true) {
|
||||
window.open(path.url, '_blank')
|
||||
} else {
|
||||
window.location = path.url
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
Inertia.visit(path.url, omit(options, ['openInNewTab']))
|
||||
}
|
||||
}
|
||||
|
||||
applyTheme() {
|
||||
const brandColors = this.config('brandColors')
|
||||
|
||||
if (Object.keys(brandColors).length > 0) {
|
||||
const style = document.createElement('style')
|
||||
|
||||
// Handle converting any non-RGB user strings into valid RGB strings.
|
||||
// This allows the user to specify any color in HSL, RGB, and RGBA
|
||||
// format, and we'll convert it to the proper format for them.
|
||||
let css = Object.keys(brandColors).reduce((carry, v) => {
|
||||
let colorValue = brandColors[v]
|
||||
let validColor = parseColor(colorValue)
|
||||
|
||||
if (validColor) {
|
||||
let parsedColor = parseColor(
|
||||
ColorTranslator.toRGBA(convertColor(validColor))
|
||||
)
|
||||
|
||||
let rgbaString = `${parsedColor.color.join(' ')} / ${
|
||||
parsedColor.alpha
|
||||
}`
|
||||
|
||||
return carry + `\n --colors-primary-${v}: ${rgbaString};`
|
||||
}
|
||||
|
||||
return carry + `\n --colors-primary-${v}: ${colorValue};`
|
||||
}, '')
|
||||
|
||||
style.innerHTML = `:root {${css}\n}`
|
||||
|
||||
document.head.append(style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function convertColor(parsedColor) {
|
||||
let color = fromPairs(
|
||||
Array.from(parsedColor.mode).map((v, i) => {
|
||||
return [v, parsedColor.color[i]]
|
||||
})
|
||||
)
|
||||
|
||||
if (parsedColor.alpha !== undefined) {
|
||||
color.a = parsedColor.alpha
|
||||
}
|
||||
|
||||
return color
|
||||
}
|
||||
43
nova/resources/js/components.js
Normal file
43
nova/resources/js/components.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import camelCase from 'lodash/camelCase'
|
||||
import upperFirst from 'lodash/upperFirst'
|
||||
import CustomError404 from '@/views/CustomError404'
|
||||
import CustomError403 from '@/views/CustomError403'
|
||||
import CustomAppError from '@/views/CustomAppError'
|
||||
import ResourceIndex from '@/views/Index'
|
||||
import ResourceDetail from '@/views/Detail'
|
||||
import Attach from '@/views/Attach'
|
||||
import UpdateAttached from '@/views/UpdateAttached'
|
||||
// import Lens from '@/views/Lens'
|
||||
|
||||
export function registerViews(app) {
|
||||
// Manually register some views...
|
||||
app.component('CustomError403', CustomError403)
|
||||
app.component('CustomError404', CustomError404)
|
||||
app.component('CustomAppError', CustomAppError)
|
||||
app.component('ResourceIndex', ResourceIndex)
|
||||
app.component('ResourceDetail', ResourceDetail)
|
||||
app.component('AttachResource', Attach)
|
||||
app.component('UpdateAttachedResource', UpdateAttached)
|
||||
// app.component('Lens', Lens)
|
||||
|
||||
const requireComponent = require.context(
|
||||
'./components',
|
||||
true,
|
||||
/[A-Z]\w+\.(vue)$/
|
||||
)
|
||||
|
||||
requireComponent.keys().forEach(fileName => {
|
||||
const componentConfig = requireComponent(fileName)
|
||||
|
||||
const componentName = upperFirst(
|
||||
camelCase(
|
||||
fileName
|
||||
.split('/')
|
||||
.pop()
|
||||
.replace(/\.\w+$/, '')
|
||||
)
|
||||
)
|
||||
|
||||
app.component(componentName, componentConfig.default || componentConfig)
|
||||
})
|
||||
}
|
||||
111
nova/resources/js/components/ActionSelector.vue
Normal file
111
nova/resources/js/components/ActionSelector.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<SelectControl
|
||||
v-bind="$attrs"
|
||||
v-if="actionsForSelect.length > 0"
|
||||
ref="actionSelectControl"
|
||||
size="xs"
|
||||
@change="handleSelectionChange"
|
||||
:options="actionsForSelect"
|
||||
dusk="action-select"
|
||||
selected=""
|
||||
:class="{ 'max-w-[6rem]': width === 'auto', 'w-full': width === 'full' }"
|
||||
:aria-label="__('Select Action')"
|
||||
>
|
||||
<option value="" disabled selected>{{ __('Actions') }}</option>
|
||||
</SelectControl>
|
||||
|
||||
<!-- Confirm Action Modal -->
|
||||
<component
|
||||
class="text-left"
|
||||
v-if="actionModalVisible"
|
||||
:show="actionModalVisible"
|
||||
:is="selectedAction?.component"
|
||||
:working="working"
|
||||
:selected-resources="selectedResources"
|
||||
:resource-name="resourceName"
|
||||
:action="selectedAction"
|
||||
:errors="errors"
|
||||
@confirm="executeAction"
|
||||
@close="closeConfirmationModal"
|
||||
/>
|
||||
|
||||
<component
|
||||
v-if="responseModalVisible"
|
||||
:show="responseModalVisible"
|
||||
:is="actionResponseData?.modal"
|
||||
@confirm="closeResponseModal"
|
||||
@close="closeResponseModal"
|
||||
:data="actionResponseData"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useActions } from '@/composables/useActions'
|
||||
import { useStore } from 'vuex'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
// Elements
|
||||
const actionSelectControl = ref(null)
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const emitter = defineEmits(['actionExecuted'])
|
||||
|
||||
const props = defineProps({
|
||||
width: { type: String, default: 'auto' },
|
||||
pivotName: { type: String, default: null },
|
||||
|
||||
resourceName: {},
|
||||
viaResource: {},
|
||||
viaResourceId: {},
|
||||
viaRelationship: {},
|
||||
relationshipType: {},
|
||||
pivotActions: {
|
||||
type: Object,
|
||||
default: () => ({ name: 'Pivot', actions: [] }),
|
||||
},
|
||||
actions: { type: Array, default: [] },
|
||||
selectedResources: { type: [Array, String], default: () => [] },
|
||||
endpoint: { type: String, default: null },
|
||||
triggerDuskAttribute: { type: String, default: null },
|
||||
})
|
||||
|
||||
const {
|
||||
errors,
|
||||
actionModalVisible,
|
||||
responseModalVisible,
|
||||
openConfirmationModal,
|
||||
closeConfirmationModal,
|
||||
closeResponseModal,
|
||||
handleActionClick,
|
||||
selectedAction,
|
||||
setSelectedActionKey,
|
||||
determineActionStrategy,
|
||||
working,
|
||||
executeAction,
|
||||
availableActions,
|
||||
availablePivotActions,
|
||||
actionResponseData,
|
||||
} = useActions(props, emitter, store)
|
||||
|
||||
const handleSelectionChange = event => {
|
||||
setSelectedActionKey(event)
|
||||
determineActionStrategy()
|
||||
|
||||
actionSelectControl.value.resetSelection()
|
||||
}
|
||||
|
||||
const actionsForSelect = computed(() => [
|
||||
...availableActions.value.map(a => ({
|
||||
value: a.uriKey,
|
||||
label: a.name,
|
||||
disabled: a.authorizedToRun === false,
|
||||
})),
|
||||
...availablePivotActions.value.map(a => ({
|
||||
group: props.pivotName,
|
||||
value: a.uriKey,
|
||||
label: a.name,
|
||||
disabled: a.authorizedToRun === false,
|
||||
})),
|
||||
])
|
||||
</script>
|
||||
47
nova/resources/js/components/AppLogo.vue
Normal file
47
nova/resources/js/components/AppLogo.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<PassthroughLogo v-if="logo" :logo="logo" :class="$attrs.class" />
|
||||
<svg
|
||||
v-else
|
||||
:class="$attrs.class"
|
||||
class="h-6"
|
||||
viewBox="0 0 204 37"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient
|
||||
cx="-4.619%"
|
||||
cy="6.646%"
|
||||
fx="-4.619%"
|
||||
fy="6.646%"
|
||||
r="101.342%"
|
||||
gradientTransform="matrix(.8299 .53351 -.5579 .79363 .03 .038)"
|
||||
id="a"
|
||||
>
|
||||
<stop stop-color="#00FFC4" offset="0%" />
|
||||
<stop stop-color="#00E1FF" offset="100%" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<g fill-rule="nonzero" fill="none">
|
||||
<path
|
||||
d="M30.343 9.99a14.757 14.757 0 0 1 .046 20.972 18.383 18.383 0 0 1-13.019 5.365A18.382 18.382 0 0 1 3.272 29.79c7.209 5.955 17.945 5.581 24.713-1.118a11.477 11.477 0 0 0 0-16.345c-4.56-4.514-11.953-4.514-16.513 0a4.918 4.918 0 0 0 0 7.006 5.04 5.04 0 0 0 7.077 0 1.68 1.68 0 0 1 2.359 0 1.639 1.639 0 0 1 0 2.333 8.4 8.4 0 0 1-11.794 0 8.198 8.198 0 0 1 0-11.674c5.861-5.805 15.366-5.805 21.229 0ZM17.37 0a18.38 18.38 0 0 1 14.097 6.538C24.257.583 13.52.958 6.756 7.653v.002a11.477 11.477 0 0 0 0 16.346c4.558 4.515 11.95 4.515 16.51 0a4.918 4.918 0 0 0 0-7.005 5.04 5.04 0 0 0-7.077 0 1.68 1.68 0 0 1-2.358 0 1.639 1.639 0 0 1 0-2.334 8.4 8.4 0 0 1 11.794 0 8.198 8.198 0 0 1 0 11.674c-5.862 5.805-15.367 5.805-21.23 0a14.756 14.756 0 0 1-.02-20.994A18.383 18.383 0 0 1 17.37 0Z"
|
||||
fill="url(#a)"
|
||||
/>
|
||||
<path
|
||||
d="M59.211 27.49a1.68 1.68 0 0 0 1.69-1.69 1.68 1.68 0 0 0-1.69-1.69h-6.88V12.306c0-1.039-.82-1.86-1.86-1.86-1.037 0-1.858.821-1.858 1.86v13.325c0 1.039.82 1.858 1.859 1.858h8.74Zm9.318-13.084c2.004 0 3.453.531 4.37 1.448.965.967 1.4 2.39 1.4 4.13v5.888c0 .99-.798 1.763-1.787 1.763-1.062 0-1.763-.749-1.763-1.52v-.026c-.893.99-2.123 1.642-3.91 1.642-2.438 0-4.441-1.4-4.441-3.959v-.048c0-2.824 2.148-4.128 5.214-4.128a9.195 9.195 0 0 1 3.163.532v-.218c0-1.521-.944-2.366-2.777-2.366a8.416 8.416 0 0 0-2.535.361 1.525 1.525 0 0 1-.53.098c-.846 0-1.521-.652-1.521-1.496 0-.635.394-1.203.989-1.425 1.16-.435 2.414-.676 4.128-.676Zm-.05 7.387c-1.567 0-2.533.628-2.533 1.786v.047c0 .99.821 1.57 2.005 1.57h-.001l.195-.004c1.541-.066 2.59-.915 2.672-2.113l.005-.151v-.653c-.628-.289-1.448-.482-2.342-.482Zm10.817 5.842c1.014 0 1.833-.82 1.833-1.835v-3.428c0-2.607 1.04-4.03 2.898-4.465.748-.17 1.375-.75 1.375-1.714 0-1.04-.652-1.787-1.785-1.787-1.088 0-1.956 1.159-2.486 2.415v-.58a1.835 1.835 0 1 0-3.67 0v9.56c0 1.013.82 1.833 1.833 1.833l.002.001Zm13.01-13.229c2.005 0 3.453.531 4.37 1.448.965.967 1.4 2.39 1.4 4.13v5.888c0 .99-.797 1.763-1.786 1.763-1.063 0-1.763-.749-1.763-1.52v-.026c-.893.99-2.123 1.643-3.911 1.643-2.438-.001-4.44-1.401-4.44-3.96v-.048c0-2.824 2.148-4.128 5.214-4.128a9.195 9.195 0 0 1 3.162.532v-.218c0-1.521-.943-2.366-2.776-2.366a8.416 8.416 0 0 0-2.535.361 1.525 1.525 0 0 1-.53.098c-.847 0-1.522-.652-1.522-1.496 0-.635.395-1.203.99-1.425 1.16-.435 2.413-.676 4.127-.676Zm-.048 7.387c-1.568 0-2.534.628-2.534 1.786v.047c0 .99.821 1.57 2.003 1.57 1.714 0 2.872-.94 2.872-2.268v-.653c-.627-.289-1.447-.482-2.341-.482Zm14.17 5.963c.99 0 1.667-.653 2.076-1.593l3.959-9.15c.072-.169.194-.555.194-.869a1.736 1.736 0 0 0-1.764-1.738c-.965 0-1.472.628-1.712 1.255l-2.825 7.556-2.775-7.508c-.267-.748-.798-1.303-1.788-1.303-.989 0-1.786.845-1.786 1.714 0 .338.097.652.194.894l3.959 9.149c.41.965 1.086 1.593 2.075 1.593h.194-.001Zm13.977-13.447c4.321 0 6.228 3.55 6.228 6.228 0 1.063-.748 1.763-1.714 1.763h-7.265c.362 1.665 1.52 2.535 3.162 2.535a4.237 4.237 0 0 0 2.607-.87 1.37 1.37 0 0 1 .894-.29c.82 0 1.423.63 1.423 1.449 0 .483-.216.846-.483 1.086-1.134.967-2.607 1.57-4.49 1.57-3.886 0-6.758-2.728-6.758-6.687v-.047c0-3.695 2.63-6.737 6.396-6.737Zm0 2.945c-1.52 0-2.51 1.086-2.8 2.753h5.528c-.217-1.642-1.183-2.753-2.728-2.753Zm11.033 10.381c1.014 0 1.833-.82 1.833-1.835V11.556a1.834 1.834 0 0 0-3.668 0V25.8c0 1.014.82 1.833 1.833 1.833l.002.003Zm14.75 0c1.013 0 1.833-.82 1.833-1.835v-9.053l7.435 9.753c.507.653 1.039 1.086 1.93 1.086h.123c1.037 0 1.858-.82 1.858-1.858V12.283a1.835 1.835 0 0 0-3.67 0v8.713l-7.17-9.415c-.505-.651-1.037-1.086-1.93-1.086h-.386c-1.038 0-1.859.821-1.859 1.859v13.445c0 1.014.82 1.836 1.834 1.836h.001Zm23.244-13.326c4.007 0 6.976 2.97 6.976 6.687v.048c0 3.719-2.993 6.735-7.024 6.735-4.007 0-6.976-2.97-6.976-6.686v-.047c0-3.719 2.993-6.737 7.024-6.737Zm-.048 3.163c-2.1 0-3.355 1.617-3.355 3.524v.048c0 1.907 1.375 3.573 3.403 3.573 2.1 0 3.355-1.617 3.355-3.524v-.049c0-1.905-1.375-3.572-3.403-3.572Zm14.798 10.284c.99 0 1.664-.653 2.076-1.593l3.958-9.15c.072-.169.195-.555.195-.869a1.736 1.736 0 0 0-1.764-1.738c-.966 0-1.473.628-1.713 1.255l-2.825 7.556-2.777-7.508c-.264-.748-.796-1.303-1.786-1.303-.989 0-1.786.845-1.786 1.714 0 .338.097.652.194.894l3.959 9.149c.41.965 1.086 1.593 2.075 1.593h.194Zm13.76-13.35c2.003 0 3.451.531 4.368 1.448.967.967 1.4 2.39 1.4 4.13v5.888c0 .99-.796 1.763-1.786 1.763-1.061 0-1.761-.749-1.761-1.52v-.026c-.894.99-2.126 1.642-3.91 1.642-2.44 0-4.444-1.4-4.444-3.959v-.048c0-2.824 2.149-4.128 5.215-4.128a9.195 9.195 0 0 1 3.162.532v-.218c0-1.521-.942-2.366-2.776-2.366a8.416 8.416 0 0 0-2.535.361 1.52 1.52 0 0 1-.53.098c-.845 0-1.522-.652-1.522-1.496 0-.636.395-1.204.99-1.425 1.159-.435 2.415-.676 4.129-.676Zm-.049 7.387c-1.57 0-2.535.628-2.535 1.786v.047c0 .99.821 1.57 2.004 1.57 1.714 0 2.873-.94 2.873-2.268v-.653c-.628-.289-1.449-.482-2.342-.482Z"
|
||||
class="fill-current text-gray-600 dark:text-white"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
|
||||
computed: {
|
||||
logo() {
|
||||
return window.Nova.config('logo')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
22
nova/resources/js/components/Avatar.vue
Normal file
22
nova/resources/js/components/Avatar.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<img :src="src" :class="avatarClasses" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
src: { type: String },
|
||||
rounded: { type: Boolean, default: true },
|
||||
small: { type: Boolean },
|
||||
medium: { type: Boolean },
|
||||
large: { type: Boolean },
|
||||
})
|
||||
|
||||
const avatarClasses = computed(() => [
|
||||
props.small && 'w-6 h-6',
|
||||
props.medium && !props.small && !props.large && 'w-8 h-8',
|
||||
props.large && 'w-12 h-12',
|
||||
props.rounded && 'rounded-full',
|
||||
])
|
||||
</script>
|
||||
40
nova/resources/js/components/Backdrop.vue
Normal file
40
nova/resources/js/components/Backdrop.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div
|
||||
v-bind="$attrs"
|
||||
v-show="props.show"
|
||||
class="absolute inset-0 h-full"
|
||||
:style="{ top: `${scrollY}px` }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const scrollY = ref()
|
||||
const scrollEvent = () => {
|
||||
scrollY.value = window.scrollY
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scrollEvent()
|
||||
|
||||
document.addEventListener('scroll', scrollEvent)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('scroll', scrollEvent)
|
||||
})
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
}
|
||||
</script>
|
||||
27
nova/resources/js/components/Badges/Badge.vue
Normal file
27
nova/resources/js/components/Badges/Badge.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center whitespace-nowrap min-h-6 px-2 rounded-full uppercase text-xs font-bold"
|
||||
:class="extraClasses"
|
||||
>
|
||||
<slot name="icon" />
|
||||
<slot>
|
||||
{{ label }}
|
||||
</slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
label: {
|
||||
type: [Boolean, String],
|
||||
required: false,
|
||||
},
|
||||
|
||||
extraClasses: {
|
||||
type: [Array, String],
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
13
nova/resources/js/components/Badges/CircleBadge.vue
Normal file
13
nova/resources/js/components/Badges/CircleBadge.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<span
|
||||
class="h-4 inline-flex items-center justify-center font-bold rounded-full px-2 text-mono text-xs ml-1 bg-primary-100 text-primary-800 dark:bg-primary-500 dark:text-gray-800"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
//
|
||||
}
|
||||
</script>
|
||||
54
nova/resources/js/components/BooleanOption.vue
Normal file
54
nova/resources/js/components/BooleanOption.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<CheckboxWithLabel
|
||||
:dusk="`${option.value}-checkbox`"
|
||||
:checked="isChecked"
|
||||
@input="updateCheckedState(option.value, $event.target.checked)"
|
||||
>
|
||||
<span>{{ labelFor(option) }}</span>
|
||||
</CheckboxWithLabel>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
emits: ['change'],
|
||||
|
||||
props: {
|
||||
resourceName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filter: Object,
|
||||
option: Object,
|
||||
label: { default: 'name' },
|
||||
},
|
||||
|
||||
methods: {
|
||||
labelFor(option) {
|
||||
return option[this.label] || ''
|
||||
},
|
||||
|
||||
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>
|
||||
45
nova/resources/js/components/Buttons/BasicButton.vue
Normal file
45
nova/resources/js/components/Buttons/BasicButton.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<component
|
||||
v-bind="{ ...$props, ...$attrs }"
|
||||
:is="component"
|
||||
ref="button"
|
||||
class="cursor-pointer rounded text-sm font-bold focus:outline-none focus:ring ring-primary-200 dark:ring-gray-600"
|
||||
:class="{
|
||||
'inline-flex items-center justify-center': align == 'center',
|
||||
'inline-flex items-center justify-start': align == 'left',
|
||||
'h-9 px-3': size == 'lg',
|
||||
'h-8 px-3': size == 'sm',
|
||||
'h-7 px-1 md:px-3': size == 'xs',
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
size: {
|
||||
type: String,
|
||||
default: 'lg',
|
||||
},
|
||||
|
||||
align: {
|
||||
type: String,
|
||||
default: 'center',
|
||||
validator: v => ['left', 'center'].includes(v),
|
||||
},
|
||||
|
||||
component: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
},
|
||||
})
|
||||
|
||||
const button = ref(null)
|
||||
|
||||
const focus = () => button.value.focus()
|
||||
|
||||
defineExpose({ focus })
|
||||
</script>
|
||||
24
nova/resources/js/components/Buttons/ButtonInertiaLink.vue
Normal file
24
nova/resources/js/components/Buttons/ButtonInertiaLink.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<Link
|
||||
v-bind="{ ...$props, ...$attrs }"
|
||||
class="shadow rounded focus:outline-none ring-primary-200 dark:ring-gray-600 focus:ring bg-primary-500 hover:bg-primary-400 active:bg-primary-600 text-white dark:text-gray-800 inline-flex items-center font-bold"
|
||||
:class="{
|
||||
'px-4 h-9 text-sm': size === 'md',
|
||||
'px-3 h-7 text-xs': size === 'sm',
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</Link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: val => ['sm', 'md'].includes(val),
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
38
nova/resources/js/components/Buttons/CopyButton.vue
Normal file
38
nova/resources/js/components/Buttons/CopyButton.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleClick"
|
||||
class="inline-flex items-center px-2 space-x-1 -mx-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 hover:text-gray-500 active:text-gray-600 dark:hover:bg-gray-900"
|
||||
:class="{
|
||||
'rounded-lg': !rounded,
|
||||
'rounded-full': rounded,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<CopyIcon v-if="withIcon" :copied="copied" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
rounded: { type: Boolean, default: true },
|
||||
withIcon: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const denouncedHandleClick = debounce(
|
||||
() => {
|
||||
copied.value = !copied.value
|
||||
setTimeout(() => (copied.value = !copied.value), 2000)
|
||||
},
|
||||
2000,
|
||||
{ leading: true, trailing: false }
|
||||
)
|
||||
|
||||
const handleClick = () => denouncedHandleClick()
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<Button variant="link" size="small" leading-icon="plus-circle" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button } from 'laravel-nova-ui'
|
||||
</script>
|
||||
38
nova/resources/js/components/Buttons/DefaultButton.vue
Normal file
38
nova/resources/js/components/Buttons/DefaultButton.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<BasicButton
|
||||
v-bind="{ ...$props, ...$attrs }"
|
||||
:component="component"
|
||||
ref="button"
|
||||
class="shadow relative bg-primary-500 hover:bg-primary-400 text-white dark:text-gray-900"
|
||||
>
|
||||
<slot />
|
||||
</BasicButton>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
size: {
|
||||
type: String,
|
||||
default: 'lg',
|
||||
},
|
||||
|
||||
align: {
|
||||
type: String,
|
||||
default: 'center',
|
||||
validator: v => ['left', 'center'].includes(v),
|
||||
},
|
||||
|
||||
component: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
focus() {
|
||||
this.$refs.button.focus()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
27
nova/resources/js/components/Buttons/IconButton.vue
Normal file
27
nova/resources/js/components/Buttons/IconButton.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center focus:ring focus:ring-primary-200 focus:outline-none rounded"
|
||||
:class="buttonClasses"
|
||||
>
|
||||
<Icon :type="iconType" class="hover:opacity-50" v-bind="{ solid: solid }" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
iconType: { type: String, default: 'dots-horizontal' },
|
||||
small: { type: Boolean },
|
||||
medium: { type: Boolean },
|
||||
large: { type: Boolean },
|
||||
solid: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const buttonClasses = computed(() => [
|
||||
props.small && 'w-6 h-6',
|
||||
props.medium && 'w-8 h-8',
|
||||
props.large && 'w-9 h-9',
|
||||
])
|
||||
</script>
|
||||
19
nova/resources/js/components/Buttons/InertiaButton.vue
Normal file
19
nova/resources/js/components/Buttons/InertiaButton.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import { Button } from 'laravel-nova-ui'
|
||||
|
||||
const props = defineProps({
|
||||
href: { type: String, required: true },
|
||||
variant: { type: String, default: 'primary' },
|
||||
icon: { type: String, default: 'primary' },
|
||||
dusk: { type: String, default: null },
|
||||
label: { type: String, default: null },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Link :href="href" :dusk="dusk">
|
||||
<Button as="div" :variant="variant" :icon="icon" :label="label">
|
||||
<slot />
|
||||
</Button>
|
||||
</Link>
|
||||
</template>
|
||||
17
nova/resources/js/components/Buttons/InvertedButton.vue
Normal file
17
nova/resources/js/components/Buttons/InvertedButton.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="space-x-1 cursor-pointer focus:outline-none focus:ring ring-primary-200 dark:ring-gray-600 focus:ring-offset-4 dark:focus:ring-offset-gray-800 rounded-lg mx-auto text-primary-500 font-bold link-default px-3 rounded-b-lg flex items-center"
|
||||
>
|
||||
<Icon :type="iconType" />
|
||||
<span>
|
||||
<slot />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
iconType: { type: String, default: 'plus-circle' },
|
||||
})
|
||||
</script>
|
||||
29
nova/resources/js/components/Buttons/LinkButton.vue
Normal file
29
nova/resources/js/components/Buttons/LinkButton.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<BasicButton
|
||||
v-bind="{ ...$props, ...$attrs }"
|
||||
:component="component"
|
||||
class="appearance-none bg-transparent font-bold text-gray-400 hover:text-gray-300 active:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400 dark:active:text-gray-600 dark:hover:bg-gray-800"
|
||||
>
|
||||
<slot />
|
||||
</BasicButton>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
size: {
|
||||
type: String,
|
||||
default: 'lg',
|
||||
},
|
||||
|
||||
align: {
|
||||
type: String,
|
||||
default: 'center',
|
||||
validator: v => ['left', 'center'].includes(v),
|
||||
},
|
||||
|
||||
component: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
15
nova/resources/js/components/Buttons/OutlineButton.vue
Normal file
15
nova/resources/js/components/Buttons/OutlineButton.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<BasicButton
|
||||
v-bind="$attrs"
|
||||
component="button"
|
||||
class="focus:outline-none focus:ring rounded border-2 border-primary-300 dark:border-gray-500 hover:border-primary-500 active:border-primary-400 dark:hover:border-gray-400 dark:active:border-gray-300 bg-white dark:bg-transparent text-primary-500 dark:text-gray-400 px-3 h-9 inline-flex items-center font-bold"
|
||||
>
|
||||
<slot />
|
||||
</BasicButton>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
//
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<Link
|
||||
v-bind="{ ...$props, ...$attrs }"
|
||||
class="focus:outline-none ring-primary-200 dark:ring-gray-600 focus:ring-2 rounded border-2 border-gray-200 dark:border-gray-500 hover:border-primary-500 active:border-primary-400 dark:hover:border-gray-400 dark:active:border-gray-300 bg-white dark:bg-transparent text-primary-500 dark:text-gray-400 px-3 h-9 inline-flex items-center font-bold"
|
||||
>
|
||||
<slot />
|
||||
</Link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
//
|
||||
}
|
||||
</script>
|
||||
16
nova/resources/js/components/Buttons/RemoveButton.vue
Normal file
16
nova/resources/js/components/Buttons/RemoveButton.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full shadow bg-white dark:bg-gray-800 text-center flex items-center justify-center h-[20px] w-[21px]"
|
||||
>
|
||||
<Icon
|
||||
type="x-circle"
|
||||
:solid="true"
|
||||
class="text-gray-800 dark:text-gray-200"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
//
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<button class="px-2" @click="togglePolling" v-tooltip.click="buttonLabel">
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
:class="{
|
||||
'text-green-500': currentlyPolling,
|
||||
'text-gray-300 dark:text-gray-500': !currentlyPolling,
|
||||
}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
emits: ['start-polling', 'stop-polling'],
|
||||
|
||||
props: {
|
||||
currentlyPolling: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
togglePolling() {
|
||||
return this.currentlyPolling
|
||||
? this.$emit('stop-polling')
|
||||
: this.$emit('start-polling')
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
buttonLabel() {
|
||||
return this.currentlyPolling
|
||||
? this.__('Stop Polling')
|
||||
: this.__('Start Polling')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
20
nova/resources/js/components/Buttons/ToolbarButton.vue
Normal file
20
nova/resources/js/components/Buttons/ToolbarButton.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center w-8 h-8 focus:outline-none focus:ring ring-primary-200 dark:ring-gray-600 rounded-lg"
|
||||
>
|
||||
<slot />
|
||||
<Icon v-if="type" solid :type="type" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
33
nova/resources/js/components/CancelButton.vue
Normal file
33
nova/resources/js/components/CancelButton.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<LinkButton
|
||||
v-bind="{ size, align, ...$props, ...$attrs }"
|
||||
type="button"
|
||||
:component="component"
|
||||
>
|
||||
<slot>
|
||||
{{ __('Cancel') }}
|
||||
</slot>
|
||||
</LinkButton>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
size: {
|
||||
type: String,
|
||||
default: 'lg',
|
||||
},
|
||||
|
||||
align: {
|
||||
type: String,
|
||||
default: 'center',
|
||||
validator: v => ['left', 'center'].includes(v),
|
||||
},
|
||||
|
||||
component: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
13
nova/resources/js/components/Card.vue
Normal file
13
nova/resources/js/components/Card.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative overflow-hidden bg-white dark:bg-gray-800 rounded-lg shadow"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
//
|
||||
}
|
||||
</script>
|
||||
64
nova/resources/js/components/CardWrapper.vue
Normal file
64
nova/resources/js/components/CardWrapper.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<component
|
||||
:class="[widthClass, heightClass]"
|
||||
:key="`${card.component}.${card.uriKey}`"
|
||||
class="h-full"
|
||||
:is="card.component"
|
||||
:card="card"
|
||||
:resource="resource"
|
||||
:resourceName="resourceName"
|
||||
:resourceId="resourceId"
|
||||
:lens="lens"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
card: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
resource: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
|
||||
resourceName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
resourceId: {
|
||||
type: [Number, String],
|
||||
default: '',
|
||||
},
|
||||
|
||||
lens: {
|
||||
lens: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* The class given to the card wrappers based on its width
|
||||
*/
|
||||
widthClass() {
|
||||
return {
|
||||
full: 'md:col-span-12',
|
||||
'1/3': 'md:col-span-4',
|
||||
'1/2': 'md:col-span-6',
|
||||
'1/4': 'md:col-span-3',
|
||||
'2/3': 'md:col-span-8',
|
||||
'3/4': 'md:col-span-9',
|
||||
}[this.card.width]
|
||||
},
|
||||
|
||||
heightClass() {
|
||||
return this.card.height == 'fixed' ? 'min-h-40' : ''
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
91
nova/resources/js/components/Cards.vue
Normal file
91
nova/resources/js/components/Cards.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div>
|
||||
<button
|
||||
v-if="filteredCards.length > 1"
|
||||
@click="toggleCollapse"
|
||||
class="md:hidden h-8 py-3 mb-3 uppercase tracking-widest font-bold text-xs inline-flex items-center justify-center focus:outline-none focus:ring-primary-200 border-1 border-primary-500 focus:ring focus:ring-offset-4 focus:ring-offset-gray-100 dark:ring-gray-600 dark:focus:ring-offset-gray-900 rounded"
|
||||
>
|
||||
<span>{{ collapsed ? __('Show Cards') : __('Hide Cards') }}</span>
|
||||
<CollapseButton class="ml-1" :collapsed="collapsed" />
|
||||
</button>
|
||||
|
||||
<div v-if="filteredCards.length > 0" class="grid md:grid-cols-12 gap-6">
|
||||
<CardWrapper
|
||||
v-show="!collapsed"
|
||||
v-for="card in filteredCards"
|
||||
:card="card"
|
||||
:resource="resource"
|
||||
:resource-name="resourceName"
|
||||
:resource-id="resourceId"
|
||||
:key="`${card.component}.${card.uriKey}`"
|
||||
:lens="lens"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import filter from 'lodash/filter'
|
||||
import { Collapsable } from '@/mixins'
|
||||
import filled from '@/util/filled'
|
||||
|
||||
export default {
|
||||
mixins: [Collapsable],
|
||||
|
||||
props: {
|
||||
cards: Array,
|
||||
|
||||
resource: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
|
||||
resourceName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
resourceId: {
|
||||
type: [Number, String],
|
||||
default: '',
|
||||
},
|
||||
|
||||
onlyOnDetail: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
lens: {
|
||||
lens: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
data: () => ({ collapsed: false }),
|
||||
|
||||
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)
|
||||
},
|
||||
|
||||
localStorageKey() {
|
||||
let name = this.resourceName
|
||||
|
||||
if (filled(this.lens)) {
|
||||
name = `${name}.${this.lens}`
|
||||
} else if (filled(this.resourceId)) {
|
||||
name = `${name}.${this.resourceId}`
|
||||
}
|
||||
|
||||
return `nova.cards.${name}.collapsed`
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
229
nova/resources/js/components/Cards/HelpCard.vue
Normal file
229
nova/resources/js/components/Cards/HelpCard.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="w-full">
|
||||
<Heading>Get Started</Heading>
|
||||
<p class="leading-tight mt-3">
|
||||
Welcome to Nova! Get familiar with Nova and explore its features in the
|
||||
documentation:
|
||||
</p>
|
||||
|
||||
<Card class="mt-8">
|
||||
<div class="md:grid md:grid-cols-2">
|
||||
<div class="border-r border-b border-gray-200 dark:border-gray-700">
|
||||
<a :href="resources" class="no-underline flex p-6">
|
||||
<div class="flex justify-center w-11 shrink-0 mr-6">
|
||||
<svg
|
||||
class="text-primary-500 dark:text-primary-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
>
|
||||
<path
|
||||
class="fill-current"
|
||||
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">Resources</Heading>
|
||||
<p class="leading-normal mt-3">
|
||||
Nova's resource manager allows you to quickly view and manage
|
||||
your Eloquent model records directly from Nova's intuitive
|
||||
interface.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<a :href="actions" class="no-underline flex p-6">
|
||||
<div class="flex justify-center w-11 shrink-0 mr-6">
|
||||
<svg
|
||||
class="text-primary-500 dark:text-primary-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="44"
|
||||
height="44"
|
||||
viewBox="0 0 44 44"
|
||||
>
|
||||
<path
|
||||
class="fill-current"
|
||||
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">Actions</Heading>
|
||||
<p class="leading-normal mt-3">
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div class="border-r border-b border-gray-200 dark:border-gray-700">
|
||||
<a :href="filters" class="no-underline flex p-6">
|
||||
<div class="flex justify-center w-11 shrink-0 mr-6">
|
||||
<svg
|
||||
class="text-primary-500 dark:text-primary-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="38"
|
||||
height="38"
|
||||
viewBox="0 0 38 38"
|
||||
>
|
||||
<path
|
||||
class="fill-current"
|
||||
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">Filters</Heading>
|
||||
<p class="leading-normal mt-3">
|
||||
Write custom filters for your resource indexes to offer your
|
||||
users quick glances at different segments of your data.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<a :href="lenses" class="no-underline flex p-6">
|
||||
<div class="flex justify-center w-11 shrink-0 mr-6">
|
||||
<svg
|
||||
class="text-primary-500 dark:text-primary-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="36"
|
||||
height="36"
|
||||
viewBox="0 0 36 36"
|
||||
>
|
||||
<path
|
||||
class="fill-current"
|
||||
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">Lenses</Heading>
|
||||
<p class="leading-normal mt-3">
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border-r md:border-b-0 border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<a :href="metrics" class="no-underline flex p-6">
|
||||
<div class="flex justify-center w-11 shrink-0 mr-6">
|
||||
<svg
|
||||
class="text-primary-500 dark:text-primary-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="37"
|
||||
height="36"
|
||||
viewBox="0 0 37 36"
|
||||
>
|
||||
<path
|
||||
class="fill-current"
|
||||
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">Metrics</Heading>
|
||||
<p class="leading-normal mt-3">
|
||||
Nova makes it painless to quickly display custom metrics for
|
||||
your application. To put the cherry on top, we’ve included
|
||||
query helpers to make it all easy as pie.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="md:border-b-0 border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<a :href="cards" class="no-underline flex p-6">
|
||||
<div class="flex justify-center w-11 shrink-0 mr-6">
|
||||
<svg
|
||||
class="text-primary-500 dark:text-primary-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="36"
|
||||
height="36"
|
||||
viewBox="0 0 36 36"
|
||||
>
|
||||
<path
|
||||
class="fill-current"
|
||||
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">Cards</Heading>
|
||||
<p class="leading-normal mt-3">
|
||||
Nova offers CLI generators for scaffolding your own custom
|
||||
cards. We’ll give you a Vue component and infinite
|
||||
possibilities.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</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 = Nova.config('version').split('.')
|
||||
parts.splice(-2)
|
||||
|
||||
return `${parts}.0`
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
21
nova/resources/js/components/Checkbox.vue
Normal file
21
nova/resources/js/components/Checkbox.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
:disabled="disabled"
|
||||
:checked="checked"
|
||||
@change="handleChange"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
checked: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['input'])
|
||||
|
||||
const handleChange = e => emit('input', e)
|
||||
</script>
|
||||
26
nova/resources/js/components/CheckboxWithLabel.vue
Normal file
26
nova/resources/js/components/CheckboxWithLabel.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<label class="flex items-center select-none space-x-2">
|
||||
<Checkbox
|
||||
@input="$emit('input', $event)"
|
||||
:checked="checked"
|
||||
:name="name"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<slot />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
emits: ['input'],
|
||||
|
||||
props: {
|
||||
checked: Boolean,
|
||||
name: { type: String, required: false },
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
17
nova/resources/js/components/CollapseButton.vue
Normal file
17
nova/resources/js/components/CollapseButton.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<IconArrow
|
||||
class="transform"
|
||||
:class="{ 'ltr:-rotate-90 rtl:rotate-90': collapsed }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
111
nova/resources/js/components/Controls/MultiSelectControl.vue
Normal file
111
nova/resources/js/components/Controls/MultiSelectControl.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="flex relative" :class="$attrs.class">
|
||||
<select
|
||||
v-bind="defaultAttributes"
|
||||
@change="handleChange"
|
||||
class="w-full block form-control form-control-bordered form-input min-h-[10rem]"
|
||||
:multiple="true"
|
||||
ref="selectControl"
|
||||
:class="{
|
||||
'h-8 text-xs': size === 'sm',
|
||||
'h-7 text-xs': size === 'xs',
|
||||
'h-6 text-xs': size === 'xxs',
|
||||
'form-control-bordered-error': hasError,
|
||||
'form-input-disabled': disabled,
|
||||
}"
|
||||
:data-disabled="disabled ? 'true' : null"
|
||||
>
|
||||
<slot />
|
||||
<template v-for="(options, group) in groupedOptions">
|
||||
<optgroup :label="group" v-if="group" :key="group">
|
||||
<option
|
||||
v-bind="attrsFor(option)"
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:selected="isSelected(option)"
|
||||
>
|
||||
{{ labelFor(option) }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<template v-else>
|
||||
<option
|
||||
v-bind="attrsFor(option)"
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:selected="isSelected(option)"
|
||||
>
|
||||
{{ labelFor(option) }}
|
||||
</option>
|
||||
</template>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import filter from 'lodash/filter'
|
||||
import groupBy from 'lodash/groupBy'
|
||||
import map from 'lodash/map'
|
||||
import omit from 'lodash/omit'
|
||||
|
||||
export default {
|
||||
emits: ['change'],
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
hasError: { type: Boolean, default: false },
|
||||
label: { default: 'label' },
|
||||
options: { type: Array, default: [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
selected: {},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: val => ['xxs', 'xs', 'sm', 'md'].includes(val),
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
labelFor(option) {
|
||||
return this.label instanceof Function
|
||||
? this.label(option)
|
||||
: option[this.label]
|
||||
},
|
||||
|
||||
attrsFor(option) {
|
||||
return {
|
||||
...(option.attrs || {}),
|
||||
...{ value: option.value },
|
||||
}
|
||||
},
|
||||
|
||||
isSelected(option) {
|
||||
return this.selected.indexOf(option.value) > -1
|
||||
},
|
||||
|
||||
handleChange(event) {
|
||||
let selected = map(
|
||||
filter(event.target.options, option => option.selected),
|
||||
option => option.value
|
||||
)
|
||||
|
||||
this.$emit('change', selected)
|
||||
},
|
||||
|
||||
resetSelection() {
|
||||
this.$refs.selectControl.selectedIndex = 0
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
defaultAttributes() {
|
||||
return omit(this.$attrs, ['class'])
|
||||
},
|
||||
|
||||
groupedOptions() {
|
||||
return groupBy(this.options, option => option.group || '')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
122
nova/resources/js/components/Controls/SelectControl.vue
Normal file
122
nova/resources/js/components/Controls/SelectControl.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="flex relative" :class="$attrs.class">
|
||||
<select
|
||||
v-bind="defaultAttributes"
|
||||
:value="selected"
|
||||
@change="handleChange"
|
||||
class="w-full block form-control form-control-bordered form-input"
|
||||
ref="selectControl"
|
||||
:disabled="disabled"
|
||||
:class="{
|
||||
'h-8 text-xs': size === 'sm',
|
||||
'h-7 text-xs': size === 'xs',
|
||||
'h-6 text-xs': size === 'xxs',
|
||||
'form-control-bordered-error': hasError,
|
||||
'form-input-disabled': disabled,
|
||||
}"
|
||||
:data-disabled="disabled ? 'true' : null"
|
||||
>
|
||||
<slot />
|
||||
<template v-for="(options, group) in groupedOptions">
|
||||
<optgroup :label="group" v-if="group" :key="group">
|
||||
<option
|
||||
v-bind="attrsFor(option)"
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:selected="isSelected(option)"
|
||||
:disabled="isDisabled(option)"
|
||||
>
|
||||
{{ labelFor(option) }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<template v-else>
|
||||
<option
|
||||
v-bind="attrsFor(option)"
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:selected="isSelected(option)"
|
||||
:disabled="isDisabled(option)"
|
||||
>
|
||||
{{ labelFor(option) }}
|
||||
</option>
|
||||
</template>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<IconArrow
|
||||
class="pointer-events-none absolute text-gray-700 right-[11px]"
|
||||
:class="{
|
||||
'top-[15px]': size === 'md',
|
||||
'top-[13px]': size === 'sm',
|
||||
'top-[11px]': size === 'xs',
|
||||
'top-[9px]': size === 'xxs',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import groupBy from 'lodash/groupBy'
|
||||
import map from 'lodash/map'
|
||||
import omit from 'lodash/omit'
|
||||
|
||||
export default {
|
||||
emits: ['change'],
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
hasError: { type: Boolean, default: false },
|
||||
label: { default: 'label' },
|
||||
options: { type: Array, default: [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
selected: {},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: val => ['xxs', 'xs', 'sm', 'md'].includes(val),
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
labelFor(option) {
|
||||
return this.label instanceof Function
|
||||
? this.label(option)
|
||||
: option[this.label]
|
||||
},
|
||||
|
||||
attrsFor(option) {
|
||||
return {
|
||||
...(option.attrs || {}),
|
||||
...{ value: option.value },
|
||||
}
|
||||
},
|
||||
|
||||
isSelected(option) {
|
||||
return option.value == this.selected
|
||||
},
|
||||
|
||||
isDisabled(option) {
|
||||
return option.disabled === true
|
||||
},
|
||||
|
||||
handleChange(event) {
|
||||
this.$emit('change', event.target.value)
|
||||
},
|
||||
|
||||
resetSelection() {
|
||||
this.$refs.selectControl.selectedIndex = 0
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
defaultAttributes() {
|
||||
return omit(this.$attrs, ['class'])
|
||||
},
|
||||
|
||||
groupedOptions() {
|
||||
return groupBy(this.options, option => option.group || '')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
411
nova/resources/js/components/CreateForm.vue
Normal file
411
nova/resources/js/components/CreateForm.vue
Normal file
@@ -0,0 +1,411 @@
|
||||
<template>
|
||||
<LoadingView :loading="loading">
|
||||
<template v-if="shouldOverrideMeta && resourceInformation">
|
||||
<Head
|
||||
:title="
|
||||
__('Create :resource', {
|
||||
resource: resourceInformation.singularLabel,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<form
|
||||
class="space-y-8"
|
||||
v-if="panels"
|
||||
@submit="submitViaCreateResource"
|
||||
@change="onUpdateFormStatus"
|
||||
:data-form-unique-id="formUniqueId"
|
||||
autocomplete="off"
|
||||
ref="form"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<component
|
||||
v-for="panel in panels"
|
||||
:key="panel.id"
|
||||
:is="'form-' + panel.component"
|
||||
@field-changed="onUpdateFormStatus"
|
||||
@file-upload-started="handleFileUploadStarted"
|
||||
@file-upload-finished="handleFileUploadFinished"
|
||||
:shown-via-new-relation-modal="shownViaNewRelationModal"
|
||||
:panel="panel"
|
||||
:name="panel.name"
|
||||
:dusk="`${panel.attribute}-panel`"
|
||||
:resource-name="resourceName"
|
||||
:fields="panel.fields"
|
||||
:form-unique-id="formUniqueId"
|
||||
:mode="mode"
|
||||
:validation-errors="validationErrors"
|
||||
:via-resource="viaResource"
|
||||
:via-resource-id="viaResourceId"
|
||||
:via-relationship="viaRelationship"
|
||||
:show-help-text="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Create Button -->
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center justify-center md:justify-end space-y-2 md:space-y-0 md:space-x-3"
|
||||
>
|
||||
<Button
|
||||
@click="$emit('create-cancelled')"
|
||||
variant="ghost"
|
||||
:label="__('Cancel')"
|
||||
:disabled="isWorking"
|
||||
dusk="cancel-create-button"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="shouldShowAddAnotherButton"
|
||||
@click="submitViaCreateResourceAndAddAnother"
|
||||
:label="__('Create & Add Another')"
|
||||
:loading="wasSubmittedViaCreateResourceAndAddAnother"
|
||||
dusk="create-and-add-another-button"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
dusk="create-button"
|
||||
@click="submitViaCreateResource"
|
||||
:label="createButtonLabel"
|
||||
:disabled="isWorking"
|
||||
:loading="wasSubmittedViaCreateResource"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</LoadingView>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import each from 'lodash/each'
|
||||
import isNil from 'lodash/isNil'
|
||||
import tap from 'lodash/tap'
|
||||
import {
|
||||
HandlesFormRequest,
|
||||
HandlesUploads,
|
||||
InteractsWithResourceInformation,
|
||||
mapProps,
|
||||
} from '@/mixins'
|
||||
import { mapActions, mapMutations } from 'vuex'
|
||||
import { Button } from 'laravel-nova-ui'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Button,
|
||||
},
|
||||
|
||||
emits: [
|
||||
'resource-created',
|
||||
'resource-created-and-adding-another',
|
||||
'create-cancelled',
|
||||
'update-form-status',
|
||||
'finished-loading',
|
||||
],
|
||||
|
||||
mixins: [
|
||||
HandlesFormRequest,
|
||||
HandlesUploads,
|
||||
InteractsWithResourceInformation,
|
||||
],
|
||||
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'form',
|
||||
validator: val => ['modal', 'form'].includes(val),
|
||||
},
|
||||
|
||||
fromResourceId: {
|
||||
default: null,
|
||||
},
|
||||
|
||||
...mapProps([
|
||||
'resourceName',
|
||||
'viaResource',
|
||||
'viaResourceId',
|
||||
'viaRelationship',
|
||||
'shouldOverrideMeta',
|
||||
]),
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
relationResponse: null,
|
||||
loading: true,
|
||||
submittedViaCreateResourceAndAddAnother: false,
|
||||
submittedViaCreateResource: false,
|
||||
fields: [],
|
||||
panels: [],
|
||||
}),
|
||||
|
||||
async created() {
|
||||
if (Nova.missingResource(this.resourceName)) return Nova.visit('/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.'))
|
||||
|
||||
Nova.visit(`/resources/${this.viaResource}/${this.viaResourceId}`)
|
||||
}
|
||||
|
||||
if (this.isHasOneThroughRelationship && this.alreadyFilled) {
|
||||
Nova.error(
|
||||
this.__('The HasOneThrough relationship has already been filled.')
|
||||
)
|
||||
|
||||
Nova.visit(`/resources/${this.viaResource}/${this.viaResourceId}`)
|
||||
}
|
||||
}
|
||||
|
||||
this.getFields()
|
||||
|
||||
this.mode === 'form' ? this.allowLeavingForm() : this.allowLeavingModal()
|
||||
},
|
||||
|
||||
methods: {
|
||||
...mapMutations([
|
||||
'allowLeavingForm',
|
||||
'preventLeavingForm',
|
||||
'allowLeavingModal',
|
||||
'preventLeavingModal',
|
||||
]),
|
||||
...mapActions(['fetchPolicies']),
|
||||
|
||||
/**
|
||||
* Handle resource loaded event.
|
||||
*/
|
||||
handleResourceLoaded() {
|
||||
this.loading = false
|
||||
|
||||
this.$emit('finished-loading')
|
||||
|
||||
Nova.$emit('resource-loaded', {
|
||||
resourceName: this.resourceName,
|
||||
resourceId: null,
|
||||
mode: 'create',
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 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',
|
||||
inline: this.shownViaNewRelationModal,
|
||||
fromResourceId: this.fromResourceId,
|
||||
viaResource: this.viaResource,
|
||||
viaResourceId: this.viaResourceId,
|
||||
viaRelationship: this.viaRelationship,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
this.panels = panels
|
||||
this.fields = fields
|
||||
|
||||
this.handleResourceLoaded()
|
||||
},
|
||||
|
||||
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.mode === 'form'
|
||||
? this.allowLeavingForm()
|
||||
: this.allowLeavingModal()
|
||||
|
||||
// Reload the policies for Nova in case the user has new permissions
|
||||
await this.fetchPolicies()
|
||||
|
||||
Nova.success(
|
||||
this.__('The :resource was created!', {
|
||||
resource: this.resourceInformation.singularLabel.toLowerCase(),
|
||||
})
|
||||
)
|
||||
|
||||
if (this.submittedViaCreateResource) {
|
||||
this.$emit('resource-created', { id, redirect })
|
||||
} else {
|
||||
window.scrollTo(0, 0)
|
||||
|
||||
this.$emit('resource-created-and-adding-another', { id })
|
||||
|
||||
// Reset the form by refetching the fields
|
||||
this.getFields()
|
||||
this.resetErrors()
|
||||
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
|
||||
|
||||
this.mode === 'form'
|
||||
? this.preventLeavingForm()
|
||||
: this.preventLeavingModal()
|
||||
|
||||
this.handleOnCreateResponseError(error)
|
||||
}
|
||||
}
|
||||
|
||||
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.panels, panel => {
|
||||
each(panel.fields, field => {
|
||||
field.fill(formData)
|
||||
})
|
||||
})
|
||||
|
||||
if (!isNil(this.fromResourceId)) {
|
||||
formData.append('fromResourceId', this.fromResourceId)
|
||||
}
|
||||
|
||||
formData.append('viaResource', this.viaResource)
|
||||
formData.append('viaResourceId', this.viaResourceId)
|
||||
formData.append('viaRelationship', this.viaRelationship)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Prevent accidental abandonment only if form was changed.
|
||||
*/
|
||||
onUpdateFormStatus() {
|
||||
this.$emit('update-form-status')
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
wasSubmittedViaCreateResource() {
|
||||
return this.isWorking && this.submittedViaCreateResource
|
||||
},
|
||||
|
||||
wasSubmittedViaCreateResourceAndAddAnother() {
|
||||
return this.isWorking && this.submittedViaCreateResourceAndAddAnother
|
||||
},
|
||||
|
||||
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>
|
||||
93
nova/resources/js/components/CreateResourceButton.vue
Normal file
93
nova/resources/js/components/CreateResourceButton.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div v-if="shouldShowButtons">
|
||||
<!-- Attach Related Models -->
|
||||
<ButtonInertiaLink
|
||||
class="shrink-0"
|
||||
v-if="shouldShowAttachButton"
|
||||
dusk="attach-button"
|
||||
:href="
|
||||
$url(
|
||||
`/resources/${viaResource}/${viaResourceId}/attach/${resourceName}`,
|
||||
{
|
||||
viaRelationship,
|
||||
polymorphic: relationshipType === 'morphToMany' ? '1' : '0',
|
||||
}
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot>
|
||||
<span class="hidden md:inline-block">
|
||||
{{ __('Attach :resource', { resource: singularName }) }}
|
||||
</span>
|
||||
<span class="inline-block md:hidden">
|
||||
{{ __('Attach') }}
|
||||
</span>
|
||||
</slot>
|
||||
</ButtonInertiaLink>
|
||||
|
||||
<!-- Create Related Models -->
|
||||
<ButtonInertiaLink
|
||||
v-else-if="shouldShowCreateButton"
|
||||
class="shrink-0 h-9 px-4 focus:outline-none ring-primary-200 dark:ring-gray-600 focus:ring text-white dark:text-gray-800 inline-flex items-center font-bold"
|
||||
dusk="create-button"
|
||||
:href="
|
||||
$url(`/resources/${resourceName}/new`, {
|
||||
viaResource: viaResource,
|
||||
viaResourceId: viaResourceId,
|
||||
viaRelationship: viaRelationship,
|
||||
relationshipType: relationshipType,
|
||||
})
|
||||
"
|
||||
>
|
||||
<span class="hidden md:inline-block">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span class="inline-block md:hidden">
|
||||
{{ __('Create') }}
|
||||
</span>
|
||||
</ButtonInertiaLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useLocalization } from '@/composables/useLocalization'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { __ } = useLocalization()
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
validator: val => ['button', 'outline-button'].includes(val),
|
||||
},
|
||||
label: {},
|
||||
singularName: {},
|
||||
resourceName: {},
|
||||
viaResource: {},
|
||||
viaResourceId: {},
|
||||
viaRelationship: {},
|
||||
relationshipType: {},
|
||||
authorizedToCreate: {},
|
||||
authorizedToRelate: {},
|
||||
alreadyFilled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const shouldShowAttachButton = computed(() => {
|
||||
return (
|
||||
(props.relationshipType === 'belongsToMany' ||
|
||||
props.relationshipType === 'morphToMany') &&
|
||||
props.authorizedToRelate
|
||||
)
|
||||
})
|
||||
|
||||
const shouldShowCreateButton = computed(() => {
|
||||
return (
|
||||
props.authorizedToCreate && props.authorizedToRelate && !props.alreadyFilled
|
||||
)
|
||||
})
|
||||
|
||||
const shouldShowButtons = computed(() => {
|
||||
return shouldShowAttachButton || shouldShowCreateButton
|
||||
})
|
||||
</script>
|
||||
111
nova/resources/js/components/DefaultField.vue
Normal file
111
nova/resources/js/components/DefaultField.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div v-if="field.visible" :class="fieldWrapperClasses">
|
||||
<div v-if="field.withLabel" :class="labelClasses">
|
||||
<slot>
|
||||
<FormLabel
|
||||
:label-for="labelFor || field.uniqueKey"
|
||||
class="space-x-1"
|
||||
:class="{ 'mb-2': shouldShowHelpText }"
|
||||
>
|
||||
<span>
|
||||
{{ fieldLabel }}
|
||||
</span>
|
||||
<span v-if="field.required" class="text-red-500 text-sm">
|
||||
{{ __('*') }}
|
||||
</span>
|
||||
</FormLabel>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div :class="controlWrapperClasses">
|
||||
<slot name="field" />
|
||||
|
||||
<HelpText class="help-text-error" v-if="showErrors && hasError">
|
||||
{{ firstError }}
|
||||
</HelpText>
|
||||
|
||||
<HelpText
|
||||
class="help-text"
|
||||
v-if="shouldShowHelpText"
|
||||
v-html="field.helpText"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { HandlesValidationErrors, mapProps } from '@/mixins'
|
||||
|
||||
export default {
|
||||
mixins: [HandlesValidationErrors],
|
||||
|
||||
props: {
|
||||
field: { type: Object, required: true },
|
||||
fieldName: { type: String },
|
||||
showErrors: { type: Boolean, default: true },
|
||||
fullWidthContent: { type: Boolean, default: false },
|
||||
labelFor: { default: null },
|
||||
...mapProps(['showHelpText']),
|
||||
},
|
||||
|
||||
computed: {
|
||||
fieldWrapperClasses() {
|
||||
// prettier-ignore
|
||||
return [
|
||||
'space-y-2',
|
||||
'md:flex @md/modal:flex',
|
||||
'md:flex-row @md/modal:flex-row',
|
||||
'md:space-y-0 @md/modal:space-y-0',
|
||||
this.field.withLabel && !this.field.inline && (this.field.compact ? 'py-3' : 'py-5'),
|
||||
this.field.stacked && 'md:flex-col @md/modal:flex-col md:space-y-2 @md/modal:space-y-2',
|
||||
]
|
||||
},
|
||||
|
||||
labelClasses() {
|
||||
// prettier-ignore
|
||||
return [
|
||||
'w-full',
|
||||
this.field.compact ? '!px-3' : 'px-6',
|
||||
!this.field.stacked && 'md:mt-2 @md/modal:mt-2',
|
||||
this.field.stacked && !this.field.inline && 'md:px-8 @md/modal:px-8',
|
||||
!this.field.stacked && !this.field.inline && 'md:px-8 @md/modal:px-8',
|
||||
this.field.compact && 'md:!px-6 @md/modal:!px-6',
|
||||
!this.field.stacked && !this.field.inline && 'md:w-1/5 @md/modal:w-1/5',
|
||||
]
|
||||
},
|
||||
|
||||
controlWrapperClasses() {
|
||||
// prettier-ignore
|
||||
return [
|
||||
'w-full space-y-2',
|
||||
this.field.compact ? '!px-3' : 'px-6',
|
||||
this.field.compact && 'md:!px-4 @md/modal:!px-4',
|
||||
this.field.stacked && !this.field.inline && 'md:px-8 @md/modal:px-8',
|
||||
!this.field.stacked && !this.field.inline && 'md:px-8 @md/modal:px-8',
|
||||
!this.field.stacked && !this.field.inline && !this.field.fullWidth && 'md:w-3/5 @md/modal:w-3/5',
|
||||
this.field.stacked && !this.field.inline && !this.field.fullWidth && 'md:w-3/5 @md/modal:w-3/5',
|
||||
!this.field.stacked && !this.field.inline && this.field.fullWidth && 'md:w-4/5 @md/modal:w-4/5',
|
||||
]
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine help text should be shown.
|
||||
*/
|
||||
shouldShowHelpText() {
|
||||
return this.showHelpText && this.field.helpText?.length > 0
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
18
nova/resources/js/components/DeleteButton.vue
Normal file
18
nova/resources/js/components/DeleteButton.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
@keydown.enter.prevent="$emit('click')"
|
||||
@click.prevent="$emit('click')"
|
||||
tabindex="0"
|
||||
class="cursor-pointer text-gray-500 inline-flex items-center"
|
||||
>
|
||||
<Icon type="trash" :solid="true" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
emits: ['click'],
|
||||
}
|
||||
</script>
|
||||
284
nova/resources/js/components/DeleteMenu.vue
Normal file
284
nova/resources/js/components/DeleteMenu.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="h-9" v-if="hasDropDownMenuItems">
|
||||
<Dropdown>
|
||||
<Button
|
||||
variant="ghost"
|
||||
padding="tight"
|
||||
icon="trash"
|
||||
trailing-icon="chevron-down"
|
||||
:aria-label="__('Trash Dropdown')"
|
||||
/>
|
||||
|
||||
<template #menu>
|
||||
<DropdownMenu class="px-1" width="250">
|
||||
<nav class="py-1">
|
||||
<!-- Delete Menu -->
|
||||
<DropdownMenuItem
|
||||
v-if="shouldShowDeleteItem"
|
||||
as="button"
|
||||
class="border-none"
|
||||
dusk="delete-selected-button"
|
||||
@click.prevent="confirmDeleteSelectedResources"
|
||||
>
|
||||
{{ __(viaManyToMany ? 'Detach Selected' : 'Delete Selected') }}
|
||||
<CircleBadge>{{ selectedResourcesCount }}</CircleBadge>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Restore Resources -->
|
||||
<DropdownMenuItem
|
||||
v-if="shouldShowRestoreItem"
|
||||
as="button"
|
||||
dusk="restore-selected-button"
|
||||
@click.prevent="confirmRestore"
|
||||
>
|
||||
{{ __('Restore Selected') }}
|
||||
<CircleBadge>{{ selectedResourcesCount }}</CircleBadge>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Force Delete Resources -->
|
||||
<DropdownMenuItem
|
||||
v-if="shouldShowForceDeleteItem"
|
||||
as="button"
|
||||
dusk="force-delete-selected-button"
|
||||
@click.prevent="confirmForceDeleteSelectedResources"
|
||||
>
|
||||
{{ __('Force Delete Selected') }}
|
||||
<CircleBadge>{{ selectedResourcesCount }}</CircleBadge>
|
||||
</DropdownMenuItem>
|
||||
</nav>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
|
||||
<DeleteResourceModal
|
||||
:mode="viaManyToMany ? 'detach' : 'delete'"
|
||||
:show="selectedResources.length > 0 && deleteSelectedModalOpen"
|
||||
@close="closeDeleteSelectedModal"
|
||||
@confirm="deleteSelectedResources"
|
||||
/>
|
||||
|
||||
<DeleteResourceModal
|
||||
:show="selectedResources.length > 0 && forceDeleteSelectedModalOpen"
|
||||
mode="delete"
|
||||
@close="closeForceDeleteSelectedModal"
|
||||
@confirm="forceDeleteSelectedResources"
|
||||
>
|
||||
<ModalHeader v-text="__('Force Delete Resource')" />
|
||||
<ModalContent>
|
||||
<p
|
||||
class="leading-normal"
|
||||
v-text="
|
||||
__('Are you sure you want to force delete the selected resources?')
|
||||
"
|
||||
/>
|
||||
</ModalContent>
|
||||
</DeleteResourceModal>
|
||||
|
||||
<RestoreResourceModal
|
||||
:show="selectedResources.length > 0 && restoreModalOpen"
|
||||
@close="closeRestoreModal"
|
||||
@confirm="restoreSelectedResources"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import find from 'lodash/find'
|
||||
import { Button } from 'laravel-nova-ui'
|
||||
import { InteractsWithQueryString } from '@/mixins'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Button,
|
||||
},
|
||||
|
||||
emits: [
|
||||
'close',
|
||||
'deleteAllMatching',
|
||||
'deleteSelected',
|
||||
'forceDeleteAllMatching',
|
||||
'forceDeleteSelected',
|
||||
'restoreAllMatching',
|
||||
'restoreSelected',
|
||||
],
|
||||
|
||||
mixins: [InteractsWithQueryString],
|
||||
|
||||
props: [
|
||||
'allMatchingResourceCount',
|
||||
'allMatchingSelected',
|
||||
'authorizedToDeleteAnyResources',
|
||||
'authorizedToDeleteSelectedResources',
|
||||
'authorizedToForceDeleteAnyResources',
|
||||
'authorizedToForceDeleteSelectedResources',
|
||||
'authorizedToRestoreAnyResources',
|
||||
'authorizedToRestoreSelectedResources',
|
||||
'resources',
|
||||
'selectedResources',
|
||||
'show',
|
||||
'softDeletes',
|
||||
'trashedParameter',
|
||||
'viaManyToMany',
|
||||
],
|
||||
|
||||
data: () => ({
|
||||
deleteSelectedModalOpen: false,
|
||||
forceDeleteSelectedModalOpen: false,
|
||||
restoreModalOpen: false,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*/
|
||||
mounted() {
|
||||
document.addEventListener('keydown', this.handleEscape)
|
||||
|
||||
Nova.$on('close-dropdowns', this.handleClosingDropdown)
|
||||
},
|
||||
|
||||
/**
|
||||
* Prepare the component to be unmounted.
|
||||
*/
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('keydown', this.handleEscape)
|
||||
|
||||
Nova.$off('close-dropdowns', this.handleClosingDropdown)
|
||||
},
|
||||
|
||||
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')
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle closing the dropdown.
|
||||
*/
|
||||
handleClosingDropdown() {
|
||||
this.deleteSelectedModalOpen = false
|
||||
this.forceDeleteSelectedModalOpen = false
|
||||
this.restoreModalOpen = false
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
trashedOnlyMode() {
|
||||
return this.queryStringParams[this.trashedParameter] == 'only'
|
||||
},
|
||||
|
||||
hasDropDownMenuItems() {
|
||||
return (
|
||||
this.shouldShowDeleteItem ||
|
||||
this.shouldShowRestoreItem ||
|
||||
this.shouldShowForceDeleteItem
|
||||
)
|
||||
},
|
||||
|
||||
shouldShowDeleteItem() {
|
||||
return (
|
||||
!this.trashedOnlyMode &&
|
||||
Boolean(
|
||||
this.authorizedToDeleteSelectedResources || this.allMatchingSelected
|
||||
)
|
||||
)
|
||||
},
|
||||
|
||||
shouldShowRestoreItem() {
|
||||
return (
|
||||
this.softDeletes &&
|
||||
!this.viaManyToMany &&
|
||||
(this.softDeletedResourcesSelected || this.allMatchingSelected) &&
|
||||
(this.authorizedToRestoreSelectedResources || this.allMatchingSelected)
|
||||
)
|
||||
},
|
||||
|
||||
shouldShowForceDeleteItem() {
|
||||
return (
|
||||
this.softDeletes &&
|
||||
!this.viaManyToMany &&
|
||||
(this.authorizedToForceDeleteSelectedResources ||
|
||||
this.allMatchingSelected)
|
||||
)
|
||||
},
|
||||
|
||||
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>
|
||||
11
nova/resources/js/components/DividerLine.vue
Normal file
11
nova/resources/js/components/DividerLine.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<svg
|
||||
class="block mx-auto mb-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="100"
|
||||
height="2"
|
||||
viewBox="0 0 100 2"
|
||||
>
|
||||
<path fill="#D8E3EC" d="M0 0h100v2H0z"></path>
|
||||
</svg>
|
||||
</template>
|
||||
113
nova/resources/js/components/DropZone/DropZone.vue
Normal file
113
nova/resources/js/components/DropZone/DropZone.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div>
|
||||
<input
|
||||
class="visually-hidden"
|
||||
:dusk="$attrs['input-dusk']"
|
||||
@change.prevent="handleChange"
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
:multiple="multiple"
|
||||
:accept="acceptedTypes"
|
||||
:disabled="disabled"
|
||||
tabindex="-1"
|
||||
/>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div v-if="files.length > 0" class="grid grid-cols-4 gap-x-6 gap-y-2">
|
||||
<FilePreviewBlock
|
||||
v-for="(file, index) in files"
|
||||
:file="file"
|
||||
@removed="() => handleRemove(index)"
|
||||
:rounded="rounded"
|
||||
:dusk="$attrs.dusk"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
@click="handleClick"
|
||||
@keydown.space.prevent="handleClick"
|
||||
@keydown.enter.prevent="handleClick"
|
||||
class="focus:outline-none focus:!border-primary-500 block cursor-pointer p-4 bg-gray-50 dark:bg-gray-900 dark:hover:bg-gray-900 border-4 border-dashed hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600 rounded-lg"
|
||||
:class="{ 'border-gray-300 dark:border-gray-600': startedDrag }"
|
||||
@dragenter.prevent="handleOnDragEnter"
|
||||
@dragleave.prevent="handleOnDragLeave"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleOnDrop"
|
||||
>
|
||||
<div class="flex items-center space-x-4 pointer-events-none">
|
||||
<p class="text-center pointer-events-none">
|
||||
<Button as="div">
|
||||
{{ multiple ? __('Choose Files') : __('Choose File') }}
|
||||
</Button>
|
||||
</p>
|
||||
|
||||
<p
|
||||
class="pointer-events-none text-center text-sm text-gray-500 dark:text-gray-400 font-semibold"
|
||||
>
|
||||
{{
|
||||
multiple
|
||||
? __('Drop files or click to choose')
|
||||
: __('Drop file or click to choose')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useLocalization } from '@/composables/useLocalization'
|
||||
import { useDragAndDrop } from '@/composables/useDragAndDrop'
|
||||
import { Button } from 'laravel-nova-ui'
|
||||
|
||||
const emit = defineEmits(['fileChanged', 'fileRemoved'])
|
||||
const { __ } = useLocalization()
|
||||
|
||||
const props = defineProps({
|
||||
files: { type: Array, default: [] },
|
||||
multiple: { type: Boolean, default: false },
|
||||
rounded: { type: Boolean, default: false },
|
||||
acceptedTypes: { type: String, default: null },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const { startedDrag, handleOnDragEnter, handleOnDragLeave } =
|
||||
useDragAndDrop(emit)
|
||||
|
||||
const demFiles = ref([])
|
||||
const fileInput = ref()
|
||||
|
||||
const handleClick = () => fileInput.value.click()
|
||||
|
||||
const handleOnDrop = e => {
|
||||
demFiles.value = props.multiple
|
||||
? e.dataTransfer.files
|
||||
: [e.dataTransfer.files[0]]
|
||||
|
||||
emit('fileChanged', demFiles.value)
|
||||
}
|
||||
|
||||
const handleChange = () => {
|
||||
demFiles.value = props.multiple
|
||||
? fileInput.value.files
|
||||
: [fileInput.value.files[0]]
|
||||
emit('fileChanged', demFiles.value)
|
||||
fileInput.value.files = null
|
||||
}
|
||||
|
||||
const handleRemove = index => {
|
||||
emit('fileRemoved', index)
|
||||
fileInput.value.files = null
|
||||
fileInput.value.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
}
|
||||
</script>
|
||||
86
nova/resources/js/components/DropZone/FilePreviewBlock.vue
Normal file
86
nova/resources/js/components/DropZone/FilePreviewBlock.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="h-full flex items-start justify-center">
|
||||
<div class="relative w-full">
|
||||
<!-- Remove Button -->
|
||||
<RemoveButton
|
||||
v-if="removable"
|
||||
class="absolute z-20 top-[-10px] right-[-9px]"
|
||||
@click.stop="handleRemoveClick"
|
||||
v-tooltip="__('Remove')"
|
||||
:dusk="$attrs.dusk"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-700 relative aspect-square flex items-center justify-center border-2 border-gray-200 dark:border-gray-700 overflow-hidden rounded-lg"
|
||||
>
|
||||
<!-- Upload Overlay -->
|
||||
<div
|
||||
v-if="file.processing"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<ProgressBar
|
||||
:title="uploadingLabel"
|
||||
class="mx-4"
|
||||
color="bg-green-500"
|
||||
:value="uploadingPercentage"
|
||||
/>
|
||||
<div class="bg-primary-900 opacity-5 absolute inset-0" />
|
||||
</div>
|
||||
|
||||
<!-- Image Preview -->
|
||||
<img
|
||||
v-if="isImage"
|
||||
:src="previewUrl"
|
||||
class="aspect-square object-scale-down"
|
||||
/>
|
||||
<div v-else>
|
||||
<div class="rounded bg-gray-200 border-2 border-gray-200 p-4">
|
||||
<Icon type="document-text" width="50" height="50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Information -->
|
||||
<p class="font-semibold text-xs mt-1">{{ file.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useFilePreviews } from '@/composables/useFilePreviews'
|
||||
import { useLocalization } from '@/composables/useLocalization'
|
||||
import { computed, toRef } from 'vue'
|
||||
|
||||
const { __ } = useLocalization()
|
||||
const emit = defineEmits(['removed'])
|
||||
const props = defineProps({
|
||||
file: { type: Object },
|
||||
removable: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const uploadingLabel = computed(() => {
|
||||
if (props.file.processing) {
|
||||
return __('Uploading') + ' (' + props.file.progress + '%)'
|
||||
}
|
||||
|
||||
return props.file.name
|
||||
})
|
||||
|
||||
const uploadingPercentage = computed(() => {
|
||||
if (props.file.processing) {
|
||||
return props.file.progress
|
||||
}
|
||||
|
||||
return 100
|
||||
})
|
||||
|
||||
const { previewUrl, isImage } = useFilePreviews(toRef(props, 'file'))
|
||||
|
||||
const handleRemoveClick = () => emit('removed')
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
}
|
||||
</script>
|
||||
65
nova/resources/js/components/DropZone/SingleDropZone.vue
Normal file
65
nova/resources/js/components/DropZone/SingleDropZone.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div v-if="files.length > 0" class="grid grid-cols-4 gap-x-6">
|
||||
<FilePreviewBlock
|
||||
v-for="(file, index) in files"
|
||||
:file="file"
|
||||
@removed="() => handleRemoveClick(index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@click="handleClick"
|
||||
class="cursor-pointer p-4 bg-gray-50 dark:bg-gray-900 dark:hover:bg-gray-900 border-4 border-dashed hover:border-gray-300 dark:hover:border-gray-600 rounded-lg"
|
||||
:class="
|
||||
startedDrag
|
||||
? 'border-gray-300 dark:border-gray-600'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
"
|
||||
@dragenter.prevent="handleOnDragEnter"
|
||||
@dragleave.prevent="handleOnDragLeave"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleOnDrop"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<p class="text-center pointer-events-none">
|
||||
<Button as="div">
|
||||
{{ __('Choose a file') }}
|
||||
</Button>
|
||||
</p>
|
||||
|
||||
<p
|
||||
class="pointer-events-none text-center text-sm text-gray-500 dark:text-gray-400 font-semibold"
|
||||
>
|
||||
{{
|
||||
multiple
|
||||
? __('Drop files or click to choose')
|
||||
: __('Drop file or click to choose')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useLocalization } from '@/composables/useLocalization'
|
||||
import { useDragAndDrop } from '@/composables/useDragAndDrop'
|
||||
import { Button } from 'laravel-nova-ui'
|
||||
|
||||
const { __ } = useLocalization()
|
||||
|
||||
const emit = defineEmits(['fileChanged', 'fileRemoved'])
|
||||
|
||||
const { startedDrag, handleOnDragEnter, handleOnDragLeave, handleOnDrop } =
|
||||
useDragAndDrop(emit)
|
||||
|
||||
defineProps({
|
||||
files: Array,
|
||||
handleClick: Function,
|
||||
})
|
||||
|
||||
function handleRemoveClick(index) {
|
||||
emit('fileRemoved', index)
|
||||
}
|
||||
</script>
|
||||
129
nova/resources/js/components/Dropdowns/ActionDropdown.vue
Normal file
129
nova/resources/js/components/Dropdowns/ActionDropdown.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Confirm Action Modal -->
|
||||
<component
|
||||
v-if="actionModalVisible"
|
||||
:show="actionModalVisible"
|
||||
class="text-left"
|
||||
:is="selectedAction?.component"
|
||||
:working="working"
|
||||
:selected-resources="selectedResources"
|
||||
:resource-name="resourceName"
|
||||
:action="selectedAction"
|
||||
:errors="errors"
|
||||
@confirm="runAction"
|
||||
@close="closeConfirmationModal"
|
||||
/>
|
||||
|
||||
<component
|
||||
v-if="responseModalVisible"
|
||||
:show="responseModalVisible"
|
||||
:is="actionResponseData?.modal"
|
||||
@confirm="handleResponseModalConfirm"
|
||||
@close="handleResponseModalClose"
|
||||
:data="actionResponseData"
|
||||
/>
|
||||
|
||||
<Dropdown>
|
||||
<template #default>
|
||||
<slot name="trigger">
|
||||
<Button
|
||||
@click.stop
|
||||
:dusk="triggerDuskAttribute"
|
||||
variant="ghost"
|
||||
icon="ellipsis-horizontal"
|
||||
v-tooltip="__('Actions')"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<template #menu>
|
||||
<DropdownMenu width="auto">
|
||||
<ScrollWrap :height="250">
|
||||
<nav
|
||||
class="px-1 divide-y divide-gray-100 dark:divide-gray-800 divide-solid"
|
||||
>
|
||||
<slot name="menu" />
|
||||
|
||||
<div v-if="actions.length > 0">
|
||||
<DropdownMenuHeading v-if="showHeadings">{{
|
||||
__('User Actions')
|
||||
}}</DropdownMenuHeading>
|
||||
|
||||
<div class="py-1">
|
||||
<DropdownMenuItem
|
||||
v-for="action in actions"
|
||||
:key="action.uriKey"
|
||||
:data-action-id="action.uriKey"
|
||||
as="button"
|
||||
class="border-none"
|
||||
@click="() => handleClick(action)"
|
||||
:title="action.name"
|
||||
:disabled="action.authorizedToRun === false"
|
||||
>
|
||||
{{ action.name }}
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</ScrollWrap>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useActions } from '@/composables/useActions'
|
||||
import { useStore } from 'vuex'
|
||||
const store = useStore()
|
||||
import { Button } from 'laravel-nova-ui'
|
||||
import DropdownMenuHeading from './DropdownMenuHeading.vue'
|
||||
|
||||
const emitter = defineEmits(['actionExecuted'])
|
||||
|
||||
const props = defineProps({
|
||||
resourceName: {},
|
||||
viaResource: {},
|
||||
viaResourceId: {},
|
||||
viaRelationship: {},
|
||||
relationshipType: {},
|
||||
actions: { type: Array, default: [] },
|
||||
selectedResources: { type: [Array, String], default: () => [] },
|
||||
endpoint: { type: String, default: null },
|
||||
triggerDuskAttribute: { type: String, default: null },
|
||||
showHeadings: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const {
|
||||
errors,
|
||||
actionModalVisible,
|
||||
responseModalVisible,
|
||||
openConfirmationModal,
|
||||
closeConfirmationModal,
|
||||
closeResponseModal,
|
||||
handleActionClick,
|
||||
selectedAction,
|
||||
working,
|
||||
executeAction,
|
||||
actionResponseData,
|
||||
} = useActions(props, emitter, store)
|
||||
|
||||
const runAction = () => executeAction(() => emitter('actionExecuted'))
|
||||
|
||||
const handleClick = action => {
|
||||
if (action.authorizedToRun !== false) {
|
||||
handleActionClick(action.uriKey)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResponseModalConfirm = () => {
|
||||
closeResponseModal()
|
||||
emitter('actionExecuted')
|
||||
}
|
||||
|
||||
const handleResponseModalClose = () => {
|
||||
closeResponseModal()
|
||||
emitter('actionExecuted')
|
||||
}
|
||||
</script>
|
||||
255
nova/resources/js/components/Dropdowns/DetailActionDropdown.vue
Normal file
255
nova/resources/js/components/Dropdowns/DetailActionDropdown.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<ActionDropdown
|
||||
v-if="resource"
|
||||
:resource="resource"
|
||||
:actions="actions"
|
||||
:via-resource="viaResource"
|
||||
:via-resource-id="viaResourceId"
|
||||
:via-relationship="viaRelationship"
|
||||
:resource-name="resourceName"
|
||||
@actionExecuted="$emit('actionExecuted')"
|
||||
:selected-resources="[resource.id.value]"
|
||||
:trigger-dusk-attribute="`${resource.id.value}-control-selector`"
|
||||
:show-headings="true"
|
||||
>
|
||||
<template #menu>
|
||||
<div
|
||||
v-if="
|
||||
resource.authorizedToReplicate ||
|
||||
(currentUser.canImpersonate && resource.authorizedToImpersonate) ||
|
||||
(resource.authorizedToDelete && !resource.softDeleted) ||
|
||||
(resource.authorizedToRestore && resource.softDeleted) ||
|
||||
resource.authorizedToForceDelete
|
||||
"
|
||||
>
|
||||
<DropdownMenuHeading>{{ __('Actions') }}</DropdownMenuHeading>
|
||||
<div class="py-1">
|
||||
<!-- Replicate Resource Link -->
|
||||
<DropdownMenuItem
|
||||
v-if="resource.authorizedToReplicate"
|
||||
:dusk="`${resource.id.value}-replicate-button`"
|
||||
:href="
|
||||
$url(
|
||||
`/resources/${resourceName}/${resource.id.value}/replicate`,
|
||||
{
|
||||
viaResource,
|
||||
viaResourceId,
|
||||
viaRelationship,
|
||||
}
|
||||
)
|
||||
"
|
||||
:title="__('Replicate')"
|
||||
>
|
||||
{{ __('Replicate') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Impersonate Resource Button -->
|
||||
<DropdownMenuItem
|
||||
as="button"
|
||||
v-if="
|
||||
currentUser.canImpersonate && resource.authorizedToImpersonate
|
||||
"
|
||||
:dusk="`${resource.id.value}-impersonate-button`"
|
||||
@click.prevent="
|
||||
startImpersonating({
|
||||
resource: resourceName,
|
||||
resourceId: resource.id.value,
|
||||
})
|
||||
"
|
||||
:title="__('Impersonate')"
|
||||
>
|
||||
{{ __('Impersonate') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
v-if="resource.authorizedToDelete && !resource.softDeleted"
|
||||
dusk="open-delete-modal-button"
|
||||
@click.prevent="openDeleteModal"
|
||||
>
|
||||
{{ __('Delete Resource') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
as="button"
|
||||
v-if="resource.authorizedToRestore && resource.softDeleted"
|
||||
dusk="open-restore-modal-button"
|
||||
@click.prevent="openRestoreModal"
|
||||
>
|
||||
{{ __('Restore Resource') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
as="button"
|
||||
v-if="resource.authorizedToForceDelete"
|
||||
dusk="open-force-delete-modal-button"
|
||||
@click.prevent="openForceDeleteModal"
|
||||
>
|
||||
{{ __('Force Delete Resource') }}
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ActionDropdown>
|
||||
|
||||
<DeleteResourceModal
|
||||
:show="deleteModalOpen"
|
||||
mode="delete"
|
||||
@close="closeDeleteModal"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
|
||||
<RestoreResourceModal
|
||||
:show="restoreModalOpen"
|
||||
@close="closeRestoreModal"
|
||||
@confirm="confirmRestore"
|
||||
/>
|
||||
|
||||
<DeleteResourceModal
|
||||
:show="forceDeleteModalOpen"
|
||||
mode="force delete"
|
||||
@close="closeForceDeleteModal"
|
||||
@confirm="confirmForceDelete"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Deletable, InteractsWithResourceInformation, mapProps } from '@/mixins'
|
||||
import { mapGetters, mapActions } from 'vuex'
|
||||
|
||||
export default {
|
||||
emits: ['actionExecuted', 'resource-deleted', 'resource-restored'],
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
mixins: [Deletable, InteractsWithResourceInformation],
|
||||
|
||||
props: {
|
||||
resource: { type: Object },
|
||||
actions: { type: Array },
|
||||
viaManyToMany: { type: Boolean },
|
||||
|
||||
...mapProps([
|
||||
'resourceName',
|
||||
'viaResource',
|
||||
'viaResourceId',
|
||||
'viaRelationship',
|
||||
]),
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
deleteModalOpen: false,
|
||||
restoreModalOpen: false,
|
||||
forceDeleteModalOpen: false,
|
||||
}),
|
||||
|
||||
methods: {
|
||||
...mapActions(['startImpersonating']),
|
||||
|
||||
/**
|
||||
* Show the confirmation modal for deleting or detaching a resource
|
||||
*/
|
||||
async confirmDelete() {
|
||||
this.deleteResources([this.resource], response => {
|
||||
Nova.success(
|
||||
this.__('The :resource was deleted!', {
|
||||
resource: this.resourceInformation.singularLabel.toLowerCase(),
|
||||
})
|
||||
)
|
||||
|
||||
if (response && response.data && response.data.redirect) {
|
||||
Nova.visit(response.data.redirect)
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.resource.softDeletes) {
|
||||
Nova.visit(`/resources/${this.resourceName}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.closeDeleteModal()
|
||||
this.$emit('resource-deleted')
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the delete modal
|
||||
*/
|
||||
openDeleteModal() {
|
||||
this.deleteModalOpen = true
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the delete modal
|
||||
*/
|
||||
closeDeleteModal() {
|
||||
this.deleteModalOpen = false
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the confirmation modal for restoring a resource
|
||||
*/
|
||||
async confirmRestore() {
|
||||
this.restoreResources([this.resource], () => {
|
||||
Nova.success(
|
||||
this.__('The :resource was restored!', {
|
||||
resource: this.resourceInformation.singularLabel.toLowerCase(),
|
||||
})
|
||||
)
|
||||
|
||||
this.closeRestoreModal()
|
||||
this.$emit('resource-restored')
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the restore modal
|
||||
*/
|
||||
openRestoreModal() {
|
||||
this.restoreModalOpen = true
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the restore modal
|
||||
*/
|
||||
closeRestoreModal() {
|
||||
this.restoreModalOpen = false
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the confirmation modal for force deleting
|
||||
*/
|
||||
async confirmForceDelete() {
|
||||
this.forceDeleteResources([this.resource], response => {
|
||||
Nova.success(
|
||||
this.__('The :resource was deleted!', {
|
||||
resource: this.resourceInformation.singularLabel.toLowerCase(),
|
||||
})
|
||||
)
|
||||
|
||||
if (response && response.data && response.data.redirect) {
|
||||
Nova.visit(response.data.redirect)
|
||||
return
|
||||
}
|
||||
|
||||
Nova.visit(`/resources/${this.resourceName}`)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the force delete modal
|
||||
*/
|
||||
openForceDeleteModal() {
|
||||
this.forceDeleteModalOpen = true
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the force delete modal
|
||||
*/
|
||||
closeForceDeleteModal() {
|
||||
this.forceDeleteModalOpen = false
|
||||
},
|
||||
},
|
||||
|
||||
computed: mapGetters(['currentUser']),
|
||||
}
|
||||
</script>
|
||||
205
nova/resources/js/components/Dropdowns/Dropdown.vue
Normal file
205
nova/resources/js/components/Dropdowns/Dropdown.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<script>
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
size,
|
||||
useFloating,
|
||||
} from '@floating-ui/vue'
|
||||
import {
|
||||
cloneVNode,
|
||||
computed,
|
||||
h,
|
||||
mergeProps,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
Teleport,
|
||||
Transition,
|
||||
watch,
|
||||
withModifiers,
|
||||
} from 'vue'
|
||||
import { useId } from '../../composables/useId'
|
||||
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
||||
import { renderSlotFragments } from '../../util/renderSlotFragments'
|
||||
import { useCloseOnEsc } from '../../composables/useCloseOnEsc'
|
||||
|
||||
export default {
|
||||
emits: ['menu-opened', 'menu-closed'],
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
offset: { type: [Number, String], default: 5 },
|
||||
placement: { type: String, default: 'bottom-start' },
|
||||
boundary: { type: String, default: 'viewPort' },
|
||||
dusk: { type: String, default: null },
|
||||
shouldCloseOnBlur: { type: Boolean, default: true },
|
||||
},
|
||||
|
||||
setup(props, { slots }) {
|
||||
const menuShown = ref(false)
|
||||
const triggerRef = ref(null)
|
||||
const teleportedRef = ref(null)
|
||||
const menuRef = ref(null)
|
||||
|
||||
const { activate, deactivate } = useFocusTrap(menuRef, {
|
||||
initialFocus: false,
|
||||
allowOutsideClick: true,
|
||||
})
|
||||
|
||||
const usesFocusTrap = ref(true)
|
||||
|
||||
const hasTrapFocus = computed(() => {
|
||||
return menuShown.value === true && usesFocusTrap.value === true
|
||||
})
|
||||
|
||||
const disableModalFocusTrap = () => {
|
||||
usesFocusTrap.value = false
|
||||
}
|
||||
|
||||
const enableModalFocusTrap = () => {
|
||||
usesFocusTrap.value = true
|
||||
}
|
||||
|
||||
useCloseOnEsc(() => (menuShown.value = false))
|
||||
|
||||
const dropdownButtonLabel = computed(
|
||||
() => `nova-ui-dropdown-button-${useId()}`
|
||||
)
|
||||
const menuLabel = computed(() => `nova-ui-dropdown-menu-${useId()}`)
|
||||
|
||||
const resolvedPlacement = computed(() => {
|
||||
if (!Nova.config('rtlEnabled')) {
|
||||
return props.placement
|
||||
}
|
||||
|
||||
return {
|
||||
'auto-start': 'auto-end',
|
||||
'auto-end': 'auto-start',
|
||||
'top-start': 'top-end',
|
||||
'top-end': 'top-start',
|
||||
'bottom-start': 'bottom-end',
|
||||
'bottom-end': 'bottom-start',
|
||||
'right-start': 'right-end',
|
||||
'right-end': 'right-start',
|
||||
'left-start': 'left-end',
|
||||
'left-end': 'left-start',
|
||||
}[props.placement]
|
||||
})
|
||||
|
||||
const { floatingStyles } = useFloating(triggerRef, menuRef, {
|
||||
whileElementsMounted: autoUpdate,
|
||||
placement: resolvedPlacement.value,
|
||||
middleware: [offset(props.offset), flip(), shift({ padding: 5 }), size()],
|
||||
})
|
||||
|
||||
watch(
|
||||
() => hasTrapFocus,
|
||||
async v => {
|
||||
await nextTick()
|
||||
v ? activate() : deactivate()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
Nova.$on('disable-focus-trap', disableModalFocusTrap)
|
||||
Nova.$on('enable-focus-trap', enableModalFocusTrap)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
Nova.$off('disable-focus-trap', disableModalFocusTrap)
|
||||
Nova.$off('enable-focus-trap', enableModalFocusTrap)
|
||||
|
||||
usesFocusTrap.value = false
|
||||
})
|
||||
|
||||
return () => {
|
||||
const children = renderSlotFragments(slots.default())
|
||||
const [trigger, ...otherChildren] = children
|
||||
|
||||
const mergedProps = mergeProps({
|
||||
...trigger.props,
|
||||
...{
|
||||
id: dropdownButtonLabel.value,
|
||||
'aria-expanded': menuShown.value === true ? 'true' : 'false',
|
||||
'aria-haspopup': 'true',
|
||||
'aria-controls': menuLabel.value,
|
||||
onClick: withModifiers(() => {
|
||||
menuShown.value = !menuShown.value
|
||||
}, ['stop']),
|
||||
},
|
||||
})
|
||||
|
||||
const cloned = cloneVNode(trigger, mergedProps)
|
||||
|
||||
// Explicitly override props starting with `on`.
|
||||
// It seems cloneVNode from Vue doesn't like overriding `onXXX` props. So
|
||||
// we have to do it manually.
|
||||
for (const prop in mergedProps) {
|
||||
if (prop.startsWith('on')) {
|
||||
cloned.props ||= {}
|
||||
cloned.props[prop] = mergedProps[prop]
|
||||
}
|
||||
}
|
||||
|
||||
return h('div', { dusk: props.dusk }, [
|
||||
h('span', { ref: triggerRef }, cloned),
|
||||
h(
|
||||
Teleport,
|
||||
{ to: 'body' },
|
||||
h(
|
||||
Transition,
|
||||
{
|
||||
enterActiveClass: 'transition duration-0 ease-out',
|
||||
enterFromClass: 'opacity-0',
|
||||
enterToClass: 'opacity-100',
|
||||
leaveActiveClass: 'transition duration-300 ease-in',
|
||||
leaveFromClass: 'opacity-100',
|
||||
leaveToClass: 'opacity-0',
|
||||
},
|
||||
() => [
|
||||
menuShown.value
|
||||
? h(
|
||||
'div',
|
||||
{
|
||||
ref: teleportedRef,
|
||||
dusk: 'dropdown-teleported',
|
||||
},
|
||||
[
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
ref: menuRef,
|
||||
id: menuLabel.value,
|
||||
'aria-labelledby': dropdownButtonLabel.value,
|
||||
tabindex: '0',
|
||||
class: 'relative z-[70]',
|
||||
style: floatingStyles.value,
|
||||
'data-menu-open': menuShown.value,
|
||||
dusk: 'dropdown-menu',
|
||||
onClick: () =>
|
||||
props.shouldCloseOnBlur
|
||||
? (menuShown.value = false)
|
||||
: null,
|
||||
},
|
||||
slots.menu()
|
||||
),
|
||||
h('div', {
|
||||
class: 'z-[69] fixed inset-0',
|
||||
dusk: 'dropdown-overlay',
|
||||
onClick: () => (menuShown.value = false),
|
||||
}),
|
||||
]
|
||||
)
|
||||
: null,
|
||||
]
|
||||
)
|
||||
),
|
||||
])
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
27
nova/resources/js/components/Dropdowns/DropdownMenu.vue
Normal file
27
nova/resources/js/components/Dropdowns/DropdownMenu.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div
|
||||
:style="styles"
|
||||
class="select-none overflow-hidden bg-white dark:bg-gray-900 shadow-lg rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
:class="{ 'max-w-sm lg:max-w-lg': width === 'auto' }"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
width: {
|
||||
default: 120,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
styles() {
|
||||
return {
|
||||
width: this.width === 'auto' ? 'auto' : `${this.width}px`,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<h3 class="mt-3 px-3 text-xs font-bold">
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
58
nova/resources/js/components/Dropdowns/DropdownMenuItem.vue
Normal file
58
nova/resources/js/components/Dropdowns/DropdownMenuItem.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<component
|
||||
:is="component"
|
||||
v-bind="defaultAttributes"
|
||||
class="block w-full text-left px-3 focus:outline-none rounded truncate whitespace-nowrap"
|
||||
:class="{
|
||||
'text-sm py-1.5': size === 'small',
|
||||
'text-sm py-2': size === 'large',
|
||||
'hover:bg-gray-50 dark:hover:bg-gray-800 focus:ring cursor-pointer':
|
||||
!disabled,
|
||||
'text-gray-400 dark:text-gray-700 cursor-default': disabled,
|
||||
'text-gray-500 active:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400 dark:active:text-gray-600':
|
||||
!disabled,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
as: {
|
||||
type: String,
|
||||
default: 'external',
|
||||
validator: v => ['button', 'external', 'form-button', 'link'].includes(v),
|
||||
},
|
||||
disabled: { type: Boolean, default: false },
|
||||
size: {
|
||||
type: String,
|
||||
default: 'small',
|
||||
validator: v => ['small', 'large'].includes(v),
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
component() {
|
||||
return {
|
||||
button: 'button',
|
||||
external: 'a',
|
||||
link: 'Link',
|
||||
'form-button': 'FormButton',
|
||||
}[this.as]
|
||||
},
|
||||
|
||||
defaultAttributes() {
|
||||
return {
|
||||
...this.$attrs,
|
||||
...{
|
||||
disabled:
|
||||
this.as === 'button' && this.disabled === true ? true : null,
|
||||
type: this.as === 'button' ? 'button' : null,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
113
nova/resources/js/components/Dropdowns/InlineActionDropdown.vue
Normal file
113
nova/resources/js/components/Dropdowns/InlineActionDropdown.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<ActionDropdown
|
||||
:resource="resource"
|
||||
:actions="actions"
|
||||
:via-resource="viaResource"
|
||||
:via-resource-id="viaResourceId"
|
||||
:via-relationship="viaRelationship"
|
||||
:resource-name="resourceName"
|
||||
@actionExecuted="$emit('actionExecuted')"
|
||||
:selected-resources="[resource.id.value]"
|
||||
:show-headings="true"
|
||||
>
|
||||
<template #trigger>
|
||||
<Button
|
||||
variant="action"
|
||||
icon="ellipsis-horizontal"
|
||||
:dusk="`${resource.id.value}-control-selector`"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #menu>
|
||||
<div
|
||||
v-if="
|
||||
(resource.authorizedToView && resource.previewHasFields) ||
|
||||
resource.authorizedToReplicate ||
|
||||
(currentUser.canImpersonate && resource.authorizedToImpersonate)
|
||||
"
|
||||
>
|
||||
<DropdownMenuHeading>{{ __('Actions') }}</DropdownMenuHeading>
|
||||
<div class="py-1">
|
||||
<!-- Preview Resource Link -->
|
||||
<DropdownMenuItem
|
||||
v-if="resource.authorizedToView && resource.previewHasFields"
|
||||
:dusk="`${resource.id.value}-preview-button`"
|
||||
as="button"
|
||||
@click.prevent="$emit('show-preview')"
|
||||
:title="__('Preview')"
|
||||
>
|
||||
{{ __('Preview') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Replicate Resource Link -->
|
||||
<DropdownMenuItem
|
||||
v-if="resource.authorizedToReplicate"
|
||||
:dusk="`${resource.id.value}-replicate-button`"
|
||||
:href="
|
||||
$url(
|
||||
`/resources/${resourceName}/${resource.id.value}/replicate`,
|
||||
{
|
||||
viaResource,
|
||||
viaResourceId,
|
||||
viaRelationship,
|
||||
}
|
||||
)
|
||||
"
|
||||
:title="__('Replicate')"
|
||||
>
|
||||
{{ __('Replicate') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Impersonate Resource Button -->
|
||||
<DropdownMenuItem
|
||||
as="button"
|
||||
v-if="
|
||||
currentUser.canImpersonate && resource.authorizedToImpersonate
|
||||
"
|
||||
:dusk="`${resource.id.value}-impersonate-button`"
|
||||
@click.prevent="
|
||||
startImpersonating({
|
||||
resource: resourceName,
|
||||
resourceId: resource.id.value,
|
||||
})
|
||||
"
|
||||
:title="__('Impersonate')"
|
||||
>
|
||||
{{ __('Impersonate') }}
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ActionDropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapProps } from '@/mixins'
|
||||
import { mapGetters, mapActions } from 'vuex'
|
||||
import { Button } from 'laravel-nova-ui'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Button,
|
||||
},
|
||||
|
||||
emits: ['actionExecuted', 'show-preview'],
|
||||
|
||||
props: {
|
||||
resource: { type: Object },
|
||||
actions: { type: Array },
|
||||
viaManyToMany: { type: Boolean },
|
||||
|
||||
...mapProps([
|
||||
'resourceName',
|
||||
'viaResource',
|
||||
'viaResourceId',
|
||||
'viaRelationship',
|
||||
]),
|
||||
},
|
||||
|
||||
methods: mapActions(['startImpersonating']),
|
||||
|
||||
computed: mapGetters(['currentUser']),
|
||||
}
|
||||
</script>
|
||||
110
nova/resources/js/components/Dropdowns/SelectAllDropdown.vue
Normal file
110
nova/resources/js/components/Dropdowns/SelectAllDropdown.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<Dropdown placement="bottom-start" dusk="select-all-dropdown">
|
||||
<Button
|
||||
variant="ghost"
|
||||
trailing-icon="chevron-down"
|
||||
class="-ml-1"
|
||||
:class="{
|
||||
'enabled:bg-gray-700/5 dark:enabled:bg-gray-950':
|
||||
selectAllOrSelectAllMatchingChecked || selectedResourcesCount > 0,
|
||||
}"
|
||||
dusk="select-all-dropdown-trigger"
|
||||
>
|
||||
<Checkbox
|
||||
:aria-label="__('Select this page')"
|
||||
:indeterminate="selectAllIndeterminate"
|
||||
:model-value="selectAllAndSelectAllMatchingChecked"
|
||||
class="pointer-events-none"
|
||||
dusk="select-all-indicator"
|
||||
tabindex="-1"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref="selectedStatus"
|
||||
v-if="selectedResourcesCount > 0"
|
||||
class="rounded-lg h-9 inline-flex items-center text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1 pl-1">
|
||||
<span class="font-bold">{{
|
||||
__(':amount selected', {
|
||||
amount: selectAllMatchingChecked
|
||||
? allMatchingResourceCount
|
||||
: selectedResourcesCount,
|
||||
label: singularOrPlural(selectedResourcesCount, 'resources'),
|
||||
})
|
||||
}}</span>
|
||||
</span>
|
||||
<Button
|
||||
@click.stop="$emit('deselect')"
|
||||
variant="link"
|
||||
icon="x-circle"
|
||||
size="small"
|
||||
state="mellow"
|
||||
class="-mr-2"
|
||||
:aria-label="__('Deselect All')"
|
||||
dusk="deselect-all-button"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<template #menu>
|
||||
<DropdownMenu direction="ltr" width="250">
|
||||
<div class="p-4 flex flex-col items-start gap-4">
|
||||
<!-- @click="$emit('toggle-select-all')"-->
|
||||
<!-- @keydown.space.stop="$emit('toggle-select-all')"-->
|
||||
<Checkbox
|
||||
@change="$emit('toggle-select-all')"
|
||||
:model-value="selectAllChecked"
|
||||
dusk="select-all-button"
|
||||
>
|
||||
<span>
|
||||
{{ __('Select this page') }}
|
||||
</span>
|
||||
<CircleBadge>
|
||||
{{ currentPageCount }}
|
||||
</CircleBadge>
|
||||
</Checkbox>
|
||||
|
||||
<Checkbox
|
||||
@change="$emit('toggle-select-all-matching')"
|
||||
:model-value="selectAllMatchingChecked"
|
||||
dusk="select-all-matching-button"
|
||||
>
|
||||
<span>
|
||||
<span>
|
||||
{{ __('Select all') }}
|
||||
</span>
|
||||
<CircleBadge dusk="select-all-matching-count">
|
||||
{{ allMatchingResourceCount }}
|
||||
</CircleBadge>
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import { singularOrPlural } from '@/util'
|
||||
import { Checkbox, Button } from 'laravel-nova-ui'
|
||||
|
||||
defineEmits(['toggle-select-all', 'toggle-select-all-matching', 'deselect'])
|
||||
|
||||
const selectedResourcesCount = inject('selectedResourcesCount')
|
||||
const selectAllChecked = inject('selectAllChecked')
|
||||
const selectAllMatchingChecked = inject('selectAllMatchingChecked')
|
||||
const selectAllAndSelectAllMatchingChecked = inject(
|
||||
'selectAllAndSelectAllMatchingChecked'
|
||||
)
|
||||
const selectAllOrSelectAllMatchingChecked = inject(
|
||||
'selectAllOrSelectAllMatchingChecked'
|
||||
)
|
||||
const selectAllIndeterminate = inject('selectAllIndeterminate')
|
||||
|
||||
defineProps({
|
||||
currentPageCount: { type: Number, default: 0 },
|
||||
allMatchingResourceCount: { type: Number, default: 0 },
|
||||
})
|
||||
</script>
|
||||
151
nova/resources/js/components/Dropdowns/ThemeDropdown.vue
Normal file
151
nova/resources/js/components/Dropdowns/ThemeDropdown.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<Dropdown v-if="themeSwitcherEnabled" placement="bottom-end">
|
||||
<Button variant="action" :icon="themeIcon" :class="themeColor" />
|
||||
|
||||
<template #menu>
|
||||
<DropdownMenu width="auto">
|
||||
<nav class="flex flex-col py-1 px-1">
|
||||
<DropdownMenuItem
|
||||
as="button"
|
||||
size="small"
|
||||
class="flex items-center gap-2"
|
||||
@click="toggleLightTheme"
|
||||
>
|
||||
<Icon name="sun" type="micro" />
|
||||
<span>{{ __('Light') }}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
as="button"
|
||||
class="flex items-center gap-2"
|
||||
@click="toggleDarkTheme"
|
||||
>
|
||||
<Icon name="moon" type="micro" />
|
||||
<span>{{ __('Dark') }}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
as="button"
|
||||
class="flex items-center gap-2"
|
||||
@click="toggleSystemTheme"
|
||||
>
|
||||
<Icon name="computer-desktop" type="micro" />
|
||||
<span>{{ __('System') }}</span>
|
||||
</DropdownMenuItem>
|
||||
</nav>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Button, Icon } from 'laravel-nova-ui'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Button,
|
||||
Icon,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
theme: 'system',
|
||||
listener: null,
|
||||
matcher: window.matchMedia('(prefers-color-scheme: dark)'),
|
||||
themes: ['light', 'dark'],
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (Nova.config('themeSwitcherEnabled')) {
|
||||
if (this.themes.includes(localStorage.novaTheme)) {
|
||||
this.theme = localStorage.novaTheme
|
||||
}
|
||||
|
||||
this.listener = () => {
|
||||
if (this.theme === 'system') {
|
||||
this.applyColorScheme()
|
||||
}
|
||||
}
|
||||
this.matcher.addEventListener('change', this.listener)
|
||||
} else {
|
||||
localStorage.removeItem('novaTheme')
|
||||
}
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
if (Nova.config('themeSwitcherEnabled')) {
|
||||
this.matcher.removeEventListener('change', this.listener)
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
theme(theme) {
|
||||
if (theme === 'light') {
|
||||
localStorage.novaTheme = 'light'
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
|
||||
if (theme === 'dark') {
|
||||
localStorage.novaTheme = 'dark'
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
|
||||
if (theme === 'system') {
|
||||
localStorage.removeItem('novaTheme')
|
||||
this.applyColorScheme()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
applyColorScheme() {
|
||||
if (Nova.config('themeSwitcherEnabled')) {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
toggleLightTheme() {
|
||||
this.theme = 'light'
|
||||
},
|
||||
|
||||
toggleDarkTheme() {
|
||||
this.theme = 'dark'
|
||||
},
|
||||
|
||||
toggleSystemTheme() {
|
||||
this.theme = 'system'
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
themeSwitcherEnabled() {
|
||||
return Nova.config('themeSwitcherEnabled')
|
||||
},
|
||||
|
||||
themeIcon() {
|
||||
// if (this.theme === 'system') {
|
||||
// return 'desktop-computer'
|
||||
// }
|
||||
|
||||
return {
|
||||
light: 'sun',
|
||||
dark: 'moon',
|
||||
system: 'computer-desktop',
|
||||
}[this.theme]
|
||||
},
|
||||
|
||||
themeColor() {
|
||||
return {
|
||||
light: 'text-primary-500',
|
||||
dark: 'dark:text-primary-500',
|
||||
system: '',
|
||||
}[this.theme]
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
66
nova/resources/js/components/Excerpt.vue
Normal file
66
nova/resources/js/components/Excerpt.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div v-if="shouldShow && hasContent" class="break-normal">
|
||||
<div
|
||||
class="prose prose-sm dark:prose-invert text-gray-500 dark:text-gray-400"
|
||||
:class="{ 'whitespace-pre-wrap': plainText }"
|
||||
v-html="content"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="hasContent" class="break-normal">
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="prose prose-sm dark:prose-invert max-w-none text-gray-500 dark:text-gray-400"
|
||||
:class="{ 'whitespace-pre-wrap': plainText }"
|
||||
v-html="content"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
v-if="!shouldShow"
|
||||
@click="toggle"
|
||||
class="link-default"
|
||||
:class="{ 'mt-6': expanded }"
|
||||
aria-role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{{ showHideLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>—</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
plainText: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
shouldShow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
|
||||
data: () => ({ expanded: false }),
|
||||
|
||||
methods: {
|
||||
toggle() {
|
||||
this.expanded = !this.expanded
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasContent() {
|
||||
return this.content !== '' && this.content !== null
|
||||
},
|
||||
|
||||
showHideLabel() {
|
||||
return !this.expanded ? this.__('Show Content') : this.__('Hide Content')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
19
nova/resources/js/components/FadeTransition.vue
Normal file
19
nova/resources/js/components/FadeTransition.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform opacity-0"
|
||||
enter-to-class="transform opacity-100"
|
||||
leave-active-class="transition duration-200 ease-out"
|
||||
leave-from-class="transform opacity-100"
|
||||
leave-to-class="transform opacity-0"
|
||||
mode="out-in"
|
||||
>
|
||||
<slot />
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
//
|
||||
}
|
||||
</script>
|
||||
12
nova/resources/js/components/FieldWrapper.vue
Normal file
12
nova/resources/js/components/FieldWrapper.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="flex flex-col" :class="{ 'md:flex-row': !stacked }">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
stacked: { type: Boolean, default: false },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
171
nova/resources/js/components/FilterMenu.vue
Normal file
171
nova/resources/js/components/FilterMenu.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<Dropdown dusk="filter-selector" :should-close-on-blur="false">
|
||||
<Button
|
||||
:variant="filtersAreApplied ? 'solid' : 'ghost'"
|
||||
dusk="filter-selector-button"
|
||||
icon="funnel"
|
||||
trailing-icon="chevron-down"
|
||||
padding="tight"
|
||||
:label="activeFilterCount > 0 ? activeFilterCount : ''"
|
||||
:aria-label="__('Filter Dropdown')"
|
||||
/>
|
||||
|
||||
<template #menu>
|
||||
<DropdownMenu width="260" dusk="filter-menu">
|
||||
<ScrollWrap :height="350" class="bg-white dark:bg-gray-900">
|
||||
<div
|
||||
class="divide-y divide-gray-200 dark:divide-gray-800 divide-solid"
|
||||
>
|
||||
<div v-if="filtersAreApplied" class="bg-gray-100">
|
||||
<button
|
||||
class="py-2 w-full block text-xs uppercase tracking-wide text-center text-gray-500 dark:bg-gray-800 dark:hover:bg-gray-700 font-bold focus:outline-none focus:text-primary-500"
|
||||
@click="handleClearSelectedFiltersClick"
|
||||
>
|
||||
{{ __('Reset Filters') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Custom Filters -->
|
||||
<div
|
||||
v-for="(filter, index) in filters"
|
||||
:key="`${filter.class}-${index}`"
|
||||
>
|
||||
<component
|
||||
:is="filter.component"
|
||||
:filter-key="filter.class"
|
||||
:lens="lens"
|
||||
:resource-name="resourceName"
|
||||
@change="handleFilterChanged"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Soft Deletes -->
|
||||
<FilterContainer v-if="softDeletes" dusk="filter-soft-deletes">
|
||||
<span>{{ __('Trashed') }}</span>
|
||||
|
||||
<template #filter>
|
||||
<SelectControl
|
||||
v-model:selected="trashedValue"
|
||||
:options="[
|
||||
{ value: '', label: '—' },
|
||||
{ value: 'with', label: __('With Trashed') },
|
||||
{ value: 'only', label: __('Only Trashed') },
|
||||
]"
|
||||
dusk="trashed-select"
|
||||
size="sm"
|
||||
@change="trashedValue = $event"
|
||||
/>
|
||||
</template>
|
||||
</FilterContainer>
|
||||
|
||||
<!-- Per Page -->
|
||||
<FilterContainer v-if="!viaResource" dusk="filter-per-page">
|
||||
<span>{{ __('Per Page') }}</span>
|
||||
|
||||
<template #filter>
|
||||
<SelectControl
|
||||
v-model:selected="perPageValue"
|
||||
:options="perPageOptionsForFilter"
|
||||
dusk="per-page-select"
|
||||
size="sm"
|
||||
@change="perPageValue = $event"
|
||||
/>
|
||||
</template>
|
||||
</FilterContainer>
|
||||
</div>
|
||||
</ScrollWrap>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import map from 'lodash/map'
|
||||
import { Button } from 'laravel-nova-ui'
|
||||
|
||||
export default {
|
||||
components: { Button },
|
||||
|
||||
emits: [
|
||||
'filter-changed',
|
||||
'clear-selected-filters',
|
||||
'trashed-changed',
|
||||
'per-page-changed',
|
||||
],
|
||||
|
||||
props: {
|
||||
activeFilterCount: Number,
|
||||
filters: Array,
|
||||
filtersAreApplied: Boolean,
|
||||
lens: { type: String, default: '' },
|
||||
perPage: [String, Number],
|
||||
perPageOptions: Array,
|
||||
resourceName: String,
|
||||
softDeletes: Boolean,
|
||||
trashed: { type: String, validator: v => ['', 'with', 'only'].includes(v) },
|
||||
viaResource: String,
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleFilterChanged(v) {
|
||||
// Older filters generated with our stubs will not have a value, since they committed to the store directly
|
||||
// instead of emitting a change event with the `filterKey` and `value`. We need to handle both cases.
|
||||
if (v) {
|
||||
const { filterClass, value } = v
|
||||
|
||||
if (filterClass) {
|
||||
Nova.log(`Updating filter state ${filterClass}: ${value}`)
|
||||
|
||||
this.$store.commit(`${this.resourceName}/updateFilterState`, {
|
||||
filterClass,
|
||||
value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.$emit('filter-changed')
|
||||
},
|
||||
|
||||
handleClearSelectedFiltersClick() {
|
||||
Nova.$emit('clear-filter-values')
|
||||
|
||||
setTimeout(() => {
|
||||
this.$emit('clear-selected-filters')
|
||||
}, 500)
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
trashedValue: {
|
||||
set(event) {
|
||||
let value = event?.target?.value || event
|
||||
|
||||
this.$emit('trashed-changed', value)
|
||||
},
|
||||
get() {
|
||||
return this.trashed
|
||||
},
|
||||
},
|
||||
|
||||
perPageValue: {
|
||||
set(event) {
|
||||
let value = event?.target?.value || event
|
||||
|
||||
this.$emit('per-page-changed', value)
|
||||
},
|
||||
get() {
|
||||
return this.perPage
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the values for the per page filter
|
||||
*/
|
||||
perPageOptionsForFilter() {
|
||||
return map(this.perPageOptions, option => {
|
||||
return { value: option, label: option }
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
58
nova/resources/js/components/Filters/BooleanFilter.vue
Normal file
58
nova/resources/js/components/Filters/BooleanFilter.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<FilterContainer>
|
||||
<span>{{ filter.name }}</span>
|
||||
|
||||
<template #filter>
|
||||
<div class="space-y-2 mt-2">
|
||||
<BooleanOption
|
||||
:dusk="`${filter.name}-boolean-filter-${option.value}-option`"
|
||||
:resource-name="resourceName"
|
||||
:key="option.value"
|
||||
v-for="option in options"
|
||||
:filter="filter"
|
||||
:option="option"
|
||||
@change="handleChange"
|
||||
label="label"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</FilterContainer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
emits: ['change'],
|
||||
|
||||
props: {
|
||||
resourceName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filterKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
lens: String,
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleChange() {
|
||||
this.$emit('change')
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
filter() {
|
||||
return this.$store.getters[`${this.resourceName}/getFilter`](
|
||||
this.filterKey
|
||||
)
|
||||
},
|
||||
|
||||
options() {
|
||||
return this.$store.getters[`${this.resourceName}/getOptionsForFilter`](
|
||||
this.filterKey
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
71
nova/resources/js/components/Filters/DateFilter.vue
Normal file
71
nova/resources/js/components/Filters/DateFilter.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<FilterContainer>
|
||||
<span>{{ filter.name }}</span>
|
||||
|
||||
<template #filter>
|
||||
<input
|
||||
class="w-full flex form-control h-8 text-xs form-input form-control-bordered"
|
||||
type="date"
|
||||
:dusk="`${filter.name}-date-filter`"
|
||||
name="date-filter"
|
||||
autocomplete="off"
|
||||
:value="value"
|
||||
:placeholder="placeholder"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
</FilterContainer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
emits: ['change'],
|
||||
|
||||
props: {
|
||||
resourceName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filterKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
lens: String,
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleChange(event) {
|
||||
let value = event?.target?.value ?? event
|
||||
|
||||
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
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
11
nova/resources/js/components/Filters/FilterContainer.vue
Normal file
11
nova/resources/js/components/Filters/FilterContainer.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="pt-2 pb-3">
|
||||
<h3 class="px-3 text-xs uppercase font-bold tracking-wide">
|
||||
<slot />
|
||||
</h3>
|
||||
|
||||
<div class="mt-1 px-3">
|
||||
<slot name="filter" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
86
nova/resources/js/components/Filters/SelectFilter.vue
Normal file
86
nova/resources/js/components/Filters/SelectFilter.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<FilterContainer>
|
||||
<span>{{ filter.name }}</span>
|
||||
|
||||
<template #filter>
|
||||
<SelectControl
|
||||
class="w-full block"
|
||||
size="sm"
|
||||
:dusk="`${filter.name}-select-filter`"
|
||||
v-model:selected="value"
|
||||
@change="value = $event"
|
||||
:options="filter.options"
|
||||
label="label"
|
||||
>
|
||||
<option value="" :selected="value == ''">{{ __('—') }}</option>
|
||||
</SelectControl>
|
||||
</template>
|
||||
</FilterContainer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import debounce from 'lodash/debounce'
|
||||
|
||||
export default {
|
||||
emits: ['change'],
|
||||
|
||||
props: {
|
||||
resourceName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filterKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
lens: String,
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
value: null,
|
||||
debouncedHandleChange: null,
|
||||
}),
|
||||
|
||||
created() {
|
||||
this.debouncedHandleChange = debounce(() => this.handleChange(), 500)
|
||||
this.setCurrentFilterValue()
|
||||
},
|
||||
|
||||
mounted() {
|
||||
Nova.$on('filter-reset', this.setCurrentFilterValue)
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
Nova.$off('filter-reset', this.setCurrentFilterValue)
|
||||
},
|
||||
|
||||
watch: {
|
||||
value() {
|
||||
this.debouncedHandleChange()
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
setCurrentFilterValue() {
|
||||
this.value = this.filter.currentValue
|
||||
},
|
||||
|
||||
handleChange() {
|
||||
this.$store.commit(`${this.resourceName}/updateFilterState`, {
|
||||
filterClass: this.filterKey,
|
||||
value: this.value,
|
||||
})
|
||||
|
||||
this.$emit('change')
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
filter() {
|
||||
return this.$store.getters[`${this.resourceName}/getFilter`](
|
||||
this.filterKey
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
53
nova/resources/js/components/FormButton.vue
Normal file
53
nova/resources/js/components/FormButton.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<form :action="href" method="POST" @submit="handleSubmit" dusk="form-button">
|
||||
<input
|
||||
v-for="(value, key) in data"
|
||||
type="hidden"
|
||||
:name="key"
|
||||
:value="value"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-if="method !== 'POST'"
|
||||
type="hidden"
|
||||
name="_method"
|
||||
:value="method"
|
||||
/>
|
||||
|
||||
<component :is="component" v-bind="$attrs" type="submit">
|
||||
<slot />
|
||||
</component>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import isNil from 'lodash/isNil'
|
||||
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
href: { type: String, required: true },
|
||||
method: { type: String, required: true },
|
||||
data: { type: Object, required: false, default: {} },
|
||||
headers: { type: Object, required: false, default: null },
|
||||
component: { type: String, default: 'button' },
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleSubmit(e) {
|
||||
if (isNil(this.headers)) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
this.$inertia.visit(this.href, {
|
||||
method: this.method,
|
||||
data: this.data,
|
||||
headers: this.headers,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
15
nova/resources/js/components/FormLabel.vue
Normal file
15
nova/resources/js/components/FormLabel.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<label :for="labelFor" class="inline-block leading-tight">
|
||||
<slot />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
labelFor: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
378
nova/resources/js/components/GlobalSearch.vue
Normal file
378
nova/resources/js/components/GlobalSearch.vue
Normal file
@@ -0,0 +1,378 @@
|
||||
<template>
|
||||
<div class="flex items-center w-full max-w-xs h-12">
|
||||
<div class="flex-1 relative">
|
||||
<!-- Search -->
|
||||
<div class="relative z-10" ref="searchInput">
|
||||
<Icon
|
||||
type="search"
|
||||
width="20"
|
||||
class="absolute ml-2 text-gray-400"
|
||||
:style="{ top: '4px' }"
|
||||
/>
|
||||
|
||||
<input
|
||||
dusk="global-search"
|
||||
ref="input"
|
||||
@keydown.enter.stop="goToCurrentlySelectedResource"
|
||||
@keydown.esc.stop="closeSearch"
|
||||
@keydown.down.prevent="move(1)"
|
||||
@keydown.up.prevent="move(-1)"
|
||||
v-model="searchTerm"
|
||||
@focus="focusSearch"
|
||||
type="search"
|
||||
:placeholder="__('Press / to search')"
|
||||
class="appearance-none rounded-full h-8 pl-10 w-full bg-gray-100 dark:bg-gray-900 dark:focus:bg-gray-800 focus:bg-white focus:outline-none focus:ring focus:ring-primary-200 dark:focus:ring-gray-600"
|
||||
role="search"
|
||||
:aria-label="__('Search')"
|
||||
:aria-expanded="resultsVisible === true ? 'true' : 'false'"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<teleport to="body">
|
||||
<transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-show="resultsVisible"
|
||||
ref="results"
|
||||
class="w-full max-w-lg z-10"
|
||||
>
|
||||
<!-- Loader -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="bg-white dark:bg-gray-800 py-6 rounded-lg shadow-lg w-full mt-2 max-h-[calc(100vh-5em)] overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
<Loader class="text-gray-300" width="40" />
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div
|
||||
v-if="results.length > 0"
|
||||
dusk="global-search-results"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg w-full mt-2 max-h-[calc(100vh-5em)] overflow-x-hidden overflow-y-auto"
|
||||
ref="container"
|
||||
>
|
||||
<div v-for="group in formattedResults" :key="group.resourceTitle">
|
||||
<h3
|
||||
class="text-xs font-bold uppercase tracking-wide bg-gray-300 dark:bg-gray-900 py-2 px-3"
|
||||
>
|
||||
{{ group.resourceTitle }}
|
||||
</h3>
|
||||
|
||||
<ul>
|
||||
<li
|
||||
v-for="item in group.items"
|
||||
:key="item.resourceName + ' ' + item.index"
|
||||
:ref="item.index === selected ? 'selected' : null"
|
||||
>
|
||||
<button
|
||||
:dusk="item.resourceName + ' ' + item.index"
|
||||
@click.exact="goToSelectedResource(item, false)"
|
||||
@click.ctrl="goToSelectedResource(item, true)"
|
||||
@click.meta="goToSelectedResource(item, true)"
|
||||
class="w-full flex items-center hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-300 py-2 px-3 no-underline font-normal"
|
||||
:class="{
|
||||
'bg-white dark:bg-gray-800': selected !== item.index,
|
||||
'bg-gray-100 dark:bg-gray-700': selected === item.index,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="item.avatar"
|
||||
:src="item.avatar"
|
||||
class="flex-none h-8 w-8 mr-3"
|
||||
:class="{
|
||||
'rounded-full': item.rounded,
|
||||
rounded: !item.rounded,
|
||||
}"
|
||||
/>
|
||||
|
||||
<div class="flex-auto text-left">
|
||||
<p>{{ item.title }}</p>
|
||||
<p v-if="item.subTitle" class="text-xs mt-1">
|
||||
{{ item.subTitle }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Results Found -->
|
||||
<div
|
||||
v-if="!loading && results.length === 0"
|
||||
dusk="global-search-empty-results"
|
||||
class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg shadow-lg w-full mt-2 max-h-search overflow-y-auto"
|
||||
>
|
||||
<h3
|
||||
class="text-xs font-bold uppercase tracking-wide bg-40 py-4 px-3"
|
||||
>
|
||||
{{ __('No Results Found.') }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<Backdrop
|
||||
@click="closeSearch"
|
||||
:show="showOverlay"
|
||||
class="bg-gray-500/75 dark:bg-gray-900/75 z-0"
|
||||
/>
|
||||
</transition>
|
||||
</teleport>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createPopper } from '@popperjs/core'
|
||||
import { CancelToken, Cancel } from 'axios'
|
||||
import map from 'lodash/map'
|
||||
import debounce from 'lodash/debounce'
|
||||
import filter from 'lodash/filter'
|
||||
import find from 'lodash/find'
|
||||
import isNil from 'lodash/isNil'
|
||||
import uniqBy from 'lodash/uniqBy'
|
||||
|
||||
function fetchSearchResults(search, cancelCallback) {
|
||||
return Nova.request().get('/nova-api/search', {
|
||||
params: { search },
|
||||
cancelToken: new CancelToken(canceller => cancelCallback(canceller)),
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
searchFunction: null,
|
||||
canceller: null,
|
||||
showOverlay: false,
|
||||
loading: false,
|
||||
resultsVisible: false,
|
||||
searchTerm: '',
|
||||
results: [],
|
||||
selected: 0,
|
||||
}),
|
||||
|
||||
watch: {
|
||||
searchTerm(newValue) {
|
||||
if (this.canceller !== null) this.canceller()
|
||||
|
||||
if (newValue !== '') {
|
||||
this.search()
|
||||
return
|
||||
}
|
||||
|
||||
this.resultsVisible = false
|
||||
this.selected = -1
|
||||
this.results = []
|
||||
// this.showOverlay = false
|
||||
},
|
||||
|
||||
resultsVisible(newValue) {
|
||||
if (newValue === true) {
|
||||
document.body.classList.add('overflow-y-hidden')
|
||||
return
|
||||
}
|
||||
|
||||
document.body.classList.remove('overflow-y-hidden')
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.searchFunction = debounce(async () => {
|
||||
this.showOverlay = true
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.popper = createPopper(this.$refs.searchInput, this.$refs.results, {
|
||||
placement: 'bottom-start',
|
||||
boundary: 'viewPort',
|
||||
modifiers: [{ name: 'offset', options: { offset: [0, 8] } }],
|
||||
})
|
||||
})
|
||||
|
||||
if (this.searchTerm === '') {
|
||||
this.canceller()
|
||||
this.resultsVisible = false
|
||||
this.results = []
|
||||
return
|
||||
}
|
||||
|
||||
this.resultsVisible = true
|
||||
this.loading = true
|
||||
this.results = []
|
||||
this.selected = 0
|
||||
|
||||
try {
|
||||
const { data: results } = await fetchSearchResults(
|
||||
this.searchTerm,
|
||||
canceller => (this.canceller = canceller)
|
||||
)
|
||||
|
||||
this.results = results
|
||||
this.loading = false
|
||||
} catch (e) {
|
||||
if (e instanceof Cancel) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = false
|
||||
|
||||
throw e
|
||||
}
|
||||
}, Nova.config('debounce'))
|
||||
},
|
||||
|
||||
mounted() {
|
||||
Nova.addShortcut('/', () => {
|
||||
this.focusSearch()
|
||||
|
||||
return false
|
||||
})
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
if (this.canceller !== null) this.canceller()
|
||||
|
||||
this.resultsVisible = false
|
||||
Nova.disableShortcut('/')
|
||||
},
|
||||
|
||||
methods: {
|
||||
async focusSearch() {
|
||||
if (this.results.length > 0) {
|
||||
this.showOverlay = true
|
||||
this.resultsVisible = true
|
||||
await this.popper.update()
|
||||
}
|
||||
this.$refs.input.focus()
|
||||
},
|
||||
|
||||
closeSearch() {
|
||||
this.$refs.input.blur()
|
||||
this.resultsVisible = false
|
||||
this.showOverlay = false
|
||||
},
|
||||
|
||||
search() {
|
||||
this.searchFunction()
|
||||
},
|
||||
|
||||
move(offset) {
|
||||
if (this.results.length) {
|
||||
let newIndex = this.selected + offset
|
||||
|
||||
if (newIndex < 0) {
|
||||
this.selected = this.results.length - 1
|
||||
this.updateScrollPosition()
|
||||
} else if (newIndex > this.results.length - 1) {
|
||||
this.selected = 0
|
||||
this.updateScrollPosition()
|
||||
} else if (newIndex >= 0 && newIndex < this.results.length) {
|
||||
this.selected = newIndex
|
||||
this.updateScrollPosition()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
updateScrollPosition() {
|
||||
const selection = this.$refs.selected
|
||||
const container = this.$refs.container
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (selection) {
|
||||
if (
|
||||
selection[0].offsetTop >
|
||||
container.scrollTop +
|
||||
container.clientHeight -
|
||||
selection[0].clientHeight
|
||||
) {
|
||||
container.scrollTop =
|
||||
selection[0].offsetTop +
|
||||
selection[0].clientHeight -
|
||||
container.clientHeight
|
||||
}
|
||||
if (selection[0].offsetTop < container.scrollTop) {
|
||||
container.scrollTop = selection[0].offsetTop
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
goToCurrentlySelectedResource(event) {
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
|
||||
if (this.searchTerm !== '') {
|
||||
const resource = find(
|
||||
this.indexedResults,
|
||||
res => res.index === this.selected
|
||||
)
|
||||
|
||||
this.goToSelectedResource(resource, false)
|
||||
}
|
||||
},
|
||||
|
||||
goToSelectedResource(resource, commandPressed = false) {
|
||||
if (this.canceller !== null) this.canceller()
|
||||
|
||||
this.closeSearch()
|
||||
|
||||
if (isNil(resource)) {
|
||||
return
|
||||
}
|
||||
|
||||
let url = Nova.url(
|
||||
`/resources/${resource.resourceName}/${resource.resourceId}`
|
||||
)
|
||||
|
||||
if (resource.linksTo === 'edit') {
|
||||
url += '/edit'
|
||||
}
|
||||
|
||||
commandPressed
|
||||
? window.open(url, '_blank')
|
||||
: Nova.visit({ url, remote: false })
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
indexedResults() {
|
||||
return map(this.results, (item, index) => ({ index, ...item }))
|
||||
},
|
||||
|
||||
formattedGroups() {
|
||||
return uniqBy(
|
||||
map(this.indexedResults, item => ({
|
||||
resourceName: item.resourceName,
|
||||
resourceTitle: item.resourceTitle,
|
||||
})),
|
||||
'resourceName'
|
||||
)
|
||||
},
|
||||
|
||||
formattedResults() {
|
||||
return map(this.formattedGroups, group => ({
|
||||
resourceName: group.resourceName,
|
||||
resourceTitle: group.resourceTitle,
|
||||
items: filter(
|
||||
this.indexedResults,
|
||||
item => item.resourceName === group.resourceName
|
||||
),
|
||||
}))
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
33
nova/resources/js/components/Heading.vue
Normal file
33
nova/resources/js/components/Heading.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<component :is="component" :class="classes" :dusk="dusk">
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const classes = {
|
||||
1: 'font-normal text-xl md:text-xl',
|
||||
2: 'font-normal md:text-xl',
|
||||
3: 'uppercase tracking-wide font-bold text-xs',
|
||||
4: 'font-normal md:text-2xl',
|
||||
}
|
||||
|
||||
export default {
|
||||
props: {
|
||||
dusk: { type: String, default: 'heading' },
|
||||
level: {
|
||||
default: 1,
|
||||
type: Number,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
component() {
|
||||
return 'h' + this.level
|
||||
},
|
||||
classes() {
|
||||
return classes[this.level]
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
9
nova/resources/js/components/HelpText.vue
Normal file
9
nova/resources/js/components/HelpText.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<p class="help-text">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {}
|
||||
</script>
|
||||
22
nova/resources/js/components/HelpTextTooltip.vue
Normal file
22
nova/resources/js/components/HelpTextTooltip.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div v-if="text" class="absolute right-0 bottom-0 p-2 z-20">
|
||||
<span class="sr-only" v-html="text" />
|
||||
<Tooltip :triggers="['click']" placement="top-start">
|
||||
<Icon
|
||||
:solid="true"
|
||||
type="question-mark-circle"
|
||||
class="cursor-pointer text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
|
||||
<template #content>
|
||||
<TooltipContent v-html="text" :max-width="width" />
|
||||
</template>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['text', 'width'],
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M12 14l9-5-9-5-9 5 9 5z" />
|
||||
<path
|
||||
d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 14l9-5-9-5-9 5 9 5zm0 0l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14zm-4 6v-7.5l4-2.222"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
;
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
;
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
;
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 13l-3 3m0 0l-3-3m3 3V8m0 13a9 9 0 110-18 9 9 0 010 18z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 15l-3-3m0 0l3-3m-3 3h8M3 12a9 9 0 1118 0 9 9 0 01-18 0z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 11l3-3m0 0l3 3m-3-3v8m0-13a9 9 0 110 18 9 9 0 010-18z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 17l-4 4m0 0l-4-4m4 4V3"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16l-4-4m0 0l4-4m-4 4h18"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7l4-4m0 0l4 4m-4-4v18"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14 5l7 7m0 0l-7 7m7-7H3"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M3 12l6.414 6.414a2 2 0 001.414.586H19a2 2 0 002-2V7a2 2 0 00-2-2h-8.172a2 2 0 00-1.414.586L3 12z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
;
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
;
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user