This commit is contained in:
2024-09-01 18:54:23 +05:00
parent 76d18365a5
commit 061f09eca1
1597 changed files with 109451 additions and 1 deletions

View File

@@ -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]'
)
})

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

View File

@@ -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')
})

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,7 @@
<template>
<Button variant="link" size="small" leading-icon="plus-circle" />
</template>
<script setup>
import { Button } from 'laravel-nova-ui'
</script>

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

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

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

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

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

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

View File

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
<template>
<h3 class="mt-3 px-3 text-xs font-bold">
<slot />
</h3>
</template>

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

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

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

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

View 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>&mdash;</div>
</template>
<script>
export default {
props: {
plainText: {
type: Boolean,
default: false,
},
shouldShow: {
type: Boolean,
default: false,
},
content: {
type: String,
},
},
data: () => ({ expanded: false }),
methods: {
toggle() {
this.expanded = !this.expanded
},
},
computed: {
hasContent() {
return this.content !== '' && this.content !== null
},
showHideLabel() {
return !this.expanded ? this.__('Show Content') : this.__('Hide Content')
},
},
}
</script>

View File

@@ -0,0 +1,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>

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

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

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

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

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

View 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 == ''">{{ __('&mdash;') }}</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>

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

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

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

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

View File

@@ -0,0 +1,9 @@
<template>
<p class="help-text">
<slot />
</p>
</template>
<script>
export default {}
</script>

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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