├── .babelrc ├── .gitignore ├── .prettierrc ├── package.json ├── src ├── index.js ├── mixins │ ├── BehavesAsPanel.js │ ├── Deletable.js │ ├── Filterable.js │ ├── FormField.js │ ├── HandlesValidationErrors.js │ ├── HasCards.js │ ├── InteractsWithDates.js │ ├── InteractsWithQueryString.js │ ├── InteractsWithResourceInformation.js │ ├── Paginatable.js │ ├── PerPageable.js │ ├── PerformsSearches.js │ ├── PreventsFormAbandonment.js │ └── TogglesTrashed.js ├── propTypes │ └── index.js └── util │ ├── capitalize.js │ ├── cardSizes.js │ ├── minimum.js │ └── singularOrPlural.js ├── tests └── SingularOrPlural.test.js ├── webpack.base.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["transform-runtime", "transform-object-rest-spread"], 3 | "presets": [["env"]], 4 | "env": { 5 | "test": { 6 | "presets": [["env", { "targets": { "node": "current" } }]] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "singleQuote": true, 6 | "trailingComma": "es5", 7 | "bracketSpacing": true, 8 | "jsxBracketSameLine": false, 9 | "semi": false, 10 | "requirePragma": false, 11 | "proseWrap": "preserve", 12 | "arrowParens": "avoid" 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-nova", 3 | "version": "1.12.3", 4 | "description": "Supporting modules for Laravel Nova", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "start": "NODE_ENV=production webpack --watch --config webpack.config.js", 8 | "demo": "NODE_ENV=production webpack --config webpack.config.js", 9 | "build": "rm -rf dist && NODE_ENV=production webpack", 10 | "prepare": "npm run build", 11 | "test": "jest" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/laravel/nova-js" 16 | }, 17 | "keywords": [ 18 | "laravel" 19 | ], 20 | "author": "Taylor Otwell", 21 | "contributors": [ 22 | "Taylor Otwell (http://laravel.com/)", 23 | "David Hemphill (http://davidhemphill.com/)" 24 | ], 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/laravel/nova/issues" 28 | }, 29 | "homepage": "https://nova.laravel.com", 30 | "devDependencies": { 31 | "babel-core": "^6.24.1", 32 | "babel-loader": "^7.0.0", 33 | "babel-plugin-transform-object-rest-spread": "^6.16.0", 34 | "babel-plugin-transform-runtime": "^6.23.0", 35 | "babel-plugin-transform-vue-jsx": "^3.7.0", 36 | "babel-preset-env": "^1.4.0", 37 | "jest": "^23.0.1", 38 | "webpack": "^2.3.3", 39 | "webpack-dev-server": "^2.4.2", 40 | "webpack-merge": "^4.1.0" 41 | }, 42 | "jest": { 43 | "testRegex": "test.js$", 44 | "moduleFileExtensions": [ 45 | "js" 46 | ], 47 | "transform": { 48 | "^.+\\.js$": "/node_modules/babel-jest" 49 | } 50 | }, 51 | "dependencies": { 52 | "babel-plugin-syntax-jsx": "^6.18.0", 53 | "form-backend-validation": "^2.3.3", 54 | "inflector-js": "^1.0.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Mixins 2 | import BehavesAsPanel from './mixins/BehavesAsPanel' 3 | import Deletable from './mixins/Deletable' 4 | import Filterable from './mixins/Filterable' 5 | import FormField from './mixins/FormField' 6 | import HandlesValidationErrors from './mixins/HandlesValidationErrors' 7 | import HasCards from './mixins/HasCards' 8 | import InteractsWithDates from './mixins/InteractsWithDates' 9 | import InteractsWithQueryString from './mixins/InteractsWithQueryString' 10 | import InteractsWithResourceInformation from './mixins/InteractsWithResourceInformation' 11 | import Paginatable from './mixins/Paginatable' 12 | import PerformsSearches from './mixins/PerformsSearches' 13 | import PerPageable from './mixins/PerPageable' 14 | import PreventsFormAbandonment from './mixins/PreventsFormAbandonment' 15 | import TogglesTrashed from './mixins/TogglesTrashed' 16 | 17 | // Util 18 | import Inflector from 'inflector-js' 19 | import CardSizes from './util/cardSizes' 20 | import Capitalize from './util/capitalize' 21 | import Minimum from './util/minimum' 22 | import { Errors } from 'form-backend-validation' 23 | import SingularOrPlural from './util/singularOrPlural' 24 | 25 | // PropTypes 26 | import { mapProps } from './propTypes' 27 | 28 | export { 29 | // Mixins 30 | BehavesAsPanel, 31 | Deletable, 32 | Filterable, 33 | FormField, 34 | HandlesValidationErrors, 35 | HasCards, 36 | InteractsWithDates, 37 | InteractsWithQueryString, 38 | InteractsWithResourceInformation, 39 | Paginatable, 40 | PerformsSearches, 41 | PerPageable, 42 | PreventsFormAbandonment, 43 | TogglesTrashed, 44 | // Util 45 | Errors, 46 | Inflector, 47 | Capitalize, 48 | Minimum, 49 | SingularOrPlural, 50 | CardSizes, 51 | // PropTypes 52 | mapProps, 53 | } 54 | -------------------------------------------------------------------------------- /src/mixins/BehavesAsPanel.js: -------------------------------------------------------------------------------- 1 | export default { 2 | props: ['resourceName', 'resourceId', 'resource', 'panel'], 3 | 4 | methods: { 5 | /** 6 | * Handle the actionExecuted event and pass it up the chain. 7 | */ 8 | actionExecuted() { 9 | this.$emit('actionExecuted') 10 | }, 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /src/mixins/Deletable.js: -------------------------------------------------------------------------------- 1 | export default { 2 | methods: { 3 | /** 4 | * Open the delete menu modal. 5 | */ 6 | openDeleteModal() { 7 | this.deleteModalOpen = true 8 | }, 9 | 10 | /** 11 | * Delete the given resources. 12 | */ 13 | deleteResources(resources, callback = null) { 14 | if (this.viaManyToMany) { 15 | return this.detachResources(resources) 16 | } 17 | 18 | return Nova.request({ 19 | url: '/nova-api/' + this.resourceName, 20 | method: 'delete', 21 | params: { 22 | ...this.queryString, 23 | ...{ resources: mapResources(resources) }, 24 | }, 25 | }).then( 26 | callback 27 | ? callback 28 | : () => { 29 | this.deleteModalOpen = false 30 | this.getResources() 31 | } 32 | ).then(() => { 33 | Nova.$emit('resources-deleted') 34 | }) 35 | }, 36 | 37 | /** 38 | * Delete the selected resources. 39 | */ 40 | deleteSelectedResources() { 41 | this.deleteResources(this.selectedResources) 42 | }, 43 | 44 | /** 45 | * Delete all of the matching resources. 46 | */ 47 | deleteAllMatchingResources() { 48 | if (this.viaManyToMany) { 49 | return this.detachAllMatchingResources() 50 | } 51 | 52 | return Nova.request({ 53 | url: this.deleteAllMatchingResourcesEndpoint, 54 | method: 'delete', 55 | params: { 56 | ...this.queryString, 57 | ...{ resources: 'all' }, 58 | }, 59 | }).then(() => { 60 | this.deleteModalOpen = false 61 | this.getResources() 62 | }).then(() => { 63 | Nova.$emit('resources-deleted') 64 | }) 65 | }, 66 | 67 | /** 68 | * Detach the given resources. 69 | */ 70 | detachResources(resources) { 71 | return Nova.request({ 72 | url: '/nova-api/' + this.resourceName + '/detach', 73 | method: 'delete', 74 | params: { 75 | ...this.queryString, 76 | ...{ resources: mapResources(resources) }, 77 | ...{ pivots: mapPivots(resources) }, 78 | }, 79 | }).then(() => { 80 | this.deleteModalOpen = false 81 | this.getResources() 82 | }).then(() => { 83 | Nova.$emit('resources-detached') 84 | }) 85 | }, 86 | 87 | /** 88 | * Detach all of the matching resources. 89 | */ 90 | detachAllMatchingResources() { 91 | return Nova.request({ 92 | url: '/nova-api/' + this.resourceName + '/detach', 93 | method: 'delete', 94 | params: { 95 | ...this.queryString, 96 | ...{ resources: 'all' }, 97 | }, 98 | }).then(() => { 99 | this.deleteModalOpen = false 100 | this.getResources() 101 | }).then(() => { 102 | Nova.$emit('resources-detached') 103 | }) 104 | }, 105 | 106 | /** 107 | * Force delete the given resources. 108 | */ 109 | forceDeleteResources(resources, callback = null) { 110 | return Nova.request({ 111 | url: '/nova-api/' + this.resourceName + '/force', 112 | method: 'delete', 113 | params: { 114 | ...this.queryString, 115 | ...{ resources: mapResources(resources) }, 116 | }, 117 | }).then( 118 | callback 119 | ? callback 120 | : () => { 121 | this.deleteModalOpen = false 122 | 123 | this.getResources() 124 | } 125 | ).then(() => { 126 | Nova.$emit('resources-deleted') 127 | }) 128 | }, 129 | 130 | /** 131 | * Force delete the selected resources. 132 | */ 133 | forceDeleteSelectedResources() { 134 | this.forceDeleteResources(this.selectedResources) 135 | }, 136 | 137 | /** 138 | * Force delete all of the matching resources. 139 | */ 140 | forceDeleteAllMatchingResources() { 141 | return Nova.request({ 142 | url: this.forceDeleteSelectedResourcesEndpoint, 143 | method: 'delete', 144 | params: { 145 | ...this.queryString, 146 | ...{ resources: 'all' }, 147 | }, 148 | }).then(() => { 149 | this.deleteModalOpen = false 150 | this.getResources() 151 | }).then(() => { 152 | Nova.$emit('resources-deleted') 153 | }) 154 | }, 155 | 156 | /** 157 | * Restore the given resources. 158 | */ 159 | restoreResources(resources, callback = null) { 160 | return Nova.request({ 161 | url: '/nova-api/' + this.resourceName + '/restore', 162 | method: 'put', 163 | params: { 164 | ...this.queryString, 165 | ...{ resources: mapResources(resources) }, 166 | }, 167 | }).then( 168 | callback 169 | ? callback 170 | : () => { 171 | this.restoreModalOpen = false 172 | 173 | this.getResources() 174 | } 175 | ).then(() => { 176 | Nova.$emit('resources-restored') 177 | }) 178 | }, 179 | 180 | /** 181 | * Restore the selected resources. 182 | */ 183 | restoreSelectedResources() { 184 | this.restoreResources(this.selectedResources) 185 | }, 186 | 187 | /** 188 | * Restore all of the matching resources. 189 | */ 190 | restoreAllMatchingResources() { 191 | return Nova.request({ 192 | url: this.restoreAllMatchingResourcesEndpoint, 193 | method: 'put', 194 | params: { 195 | ...this.queryString, 196 | ...{ resources: 'all' }, 197 | }, 198 | }).then(() => { 199 | this.restoreModalOpen = false 200 | this.getResources() 201 | }).then(() => { 202 | Nova.$emit('resources-restored') 203 | }) 204 | }, 205 | }, 206 | 207 | computed: { 208 | /** 209 | * Get the delete all matching resources endpoint. 210 | */ 211 | deleteAllMatchingResourcesEndpoint() { 212 | if (this.lens) { 213 | return '/nova-api/' + this.resourceName + '/lens/' + this.lens 214 | } 215 | 216 | return '/nova-api/' + this.resourceName 217 | }, 218 | 219 | /** 220 | * Get the force delete all of the matching resources endpoint. 221 | */ 222 | forceDeleteSelectedResourcesEndpoint() { 223 | if (this.lens) { 224 | return '/nova-api/' + this.resourceName + '/lens/' + this.lens + '/force' 225 | } 226 | 227 | return '/nova-api/' + this.resourceName + '/force' 228 | }, 229 | 230 | /** 231 | * Get the restore all of the matching resources endpoint. 232 | */ 233 | restoreAllMatchingResourcesEndpoint() { 234 | if (this.lens) { 235 | return '/nova-api/' + this.resourceName + '/lens/' + this.lens + '/restore' 236 | } 237 | 238 | return '/nova-api/' + this.resourceName + '/restore' 239 | }, 240 | 241 | /** 242 | * Get the query string for a deletable resource request. 243 | */ 244 | queryString() { 245 | return { 246 | search: this.currentSearch, 247 | filters: this.encodedFilters, 248 | trashed: this.currentTrashed, 249 | viaResource: this.viaResource, 250 | viaResourceId: this.viaResourceId, 251 | viaRelationship: this.viaRelationship, 252 | } 253 | }, 254 | }, 255 | } 256 | 257 | function mapResources(resources) { 258 | return _.map(resources, resource => resource.id.value) 259 | } 260 | 261 | function mapPivots(resources) { 262 | return _.filter(_.map(resources, resource => resource.id.pivotValue)) 263 | } 264 | -------------------------------------------------------------------------------- /src/mixins/Filterable.js: -------------------------------------------------------------------------------- 1 | export default { 2 | methods: { 3 | /** 4 | * Clear filters and reset the resource table 5 | */ 6 | async clearSelectedFilters(lens) { 7 | if (lens) { 8 | await this.$store.dispatch(`${this.resourceName}/resetFilterState`, { 9 | resourceName: this.resourceName, 10 | lens, 11 | }) 12 | } else { 13 | await this.$store.dispatch(`${this.resourceName}/resetFilterState`, { 14 | resourceName: this.resourceName, 15 | }) 16 | } 17 | 18 | this.updateQueryString({ 19 | [this.pageParameter]: 1, 20 | [this.filterParameter]: '', 21 | }) 22 | }, 23 | 24 | /** 25 | * Handle a filter state change. 26 | */ 27 | filterChanged() { 28 | this.updateQueryString({ 29 | [this.pageParameter]: 1, 30 | [this.filterParameter]: this.$store.getters[`${this.resourceName}/currentEncodedFilters`], 31 | }) 32 | }, 33 | 34 | /** 35 | * Set up filters for the current view 36 | */ 37 | async initializeFilters(lens) { 38 | // Clear out the filters from the store first 39 | this.$store.commit(`${this.resourceName}/clearFilters`) 40 | 41 | await this.$store.dispatch(`${this.resourceName}/fetchFilters`, { 42 | resourceName: this.resourceName, 43 | viaResource: this.viaResource, 44 | viaResourceId: this.viaResourceId, 45 | viaRelationship: this.viaRelationship, 46 | lens, 47 | }) 48 | await this.initializeState(lens) 49 | }, 50 | 51 | /** 52 | * Initialize the filter state 53 | */ 54 | async initializeState(lens) { 55 | this.initialEncodedFilters 56 | ? await this.$store.dispatch( 57 | `${this.resourceName}/initializeCurrentFilterValuesFromQueryString`, 58 | this.initialEncodedFilters 59 | ) 60 | : await this.$store.dispatch(`${this.resourceName}/resetFilterState`, { 61 | resourceName: this.resourceName, 62 | lens, 63 | }) 64 | }, 65 | }, 66 | 67 | computed: { 68 | /** 69 | * Get the name of the filter query string variable. 70 | */ 71 | filterParameter() { 72 | return this.resourceName + '_filter' 73 | }, 74 | }, 75 | } 76 | -------------------------------------------------------------------------------- /src/mixins/FormField.js: -------------------------------------------------------------------------------- 1 | import { mapProps } from '../propTypes' 2 | 3 | export default { 4 | props: mapProps([ 5 | 'shownViaNewRelationModal', 6 | 'field', 7 | 'viaResource', 8 | 'viaResourceId', 9 | 'viaRelationship', 10 | 'resourceName', 11 | 'showHelpText', 12 | ]), 13 | 14 | data: () => ({ 15 | value: '', 16 | }), 17 | 18 | mounted() { 19 | this.setInitialValue() 20 | 21 | // Add a default fill method for the field 22 | this.field.fill = this.fill 23 | 24 | // Register a global event for setting the field's value 25 | Nova.$on(this.field.attribute + '-value', value => { 26 | this.value = value 27 | }) 28 | }, 29 | 30 | destroyed() { 31 | Nova.$off(this.field.attribute + '-value') 32 | }, 33 | 34 | methods: { 35 | /* 36 | * Set the initial value for the field 37 | */ 38 | setInitialValue() { 39 | this.value = !(this.field.value === undefined || this.field.value === null) 40 | ? this.field.value 41 | : '' 42 | }, 43 | 44 | /** 45 | * Provide a function that fills a passed FormData object with the 46 | * field's internal value attribute 47 | */ 48 | fill(formData) { 49 | formData.append(this.field.attribute, String(this.value)) 50 | }, 51 | 52 | /** 53 | * Update the field's internal value 54 | */ 55 | handleChange(event) { 56 | this.value = event.target.value 57 | 58 | if (this.field) { 59 | Nova.$emit(this.field.attribute + '-change', this.value) 60 | } 61 | }, 62 | }, 63 | 64 | computed: { 65 | /** 66 | * Determine if the field is in readonly mode 67 | */ 68 | isReadonly() { 69 | return this.field.readonly || _.get(this.field, 'extraAttributes.readonly') 70 | }, 71 | }, 72 | } 73 | -------------------------------------------------------------------------------- /src/mixins/HandlesValidationErrors.js: -------------------------------------------------------------------------------- 1 | import { Errors } from 'form-backend-validation' 2 | 3 | export default { 4 | props: { 5 | errors: { 6 | default: () => new Errors(), 7 | }, 8 | }, 9 | 10 | data: () => ({ 11 | errorClass: 'border-danger', 12 | }), 13 | 14 | computed: { 15 | errorClasses() { 16 | return this.hasError ? [this.errorClass] : [] 17 | }, 18 | 19 | fieldAttribute() { 20 | return this.field.attribute 21 | }, 22 | 23 | validationKey() { 24 | return this.field.validationKey 25 | }, 26 | 27 | hasError() { 28 | return this.errors.has(this.validationKey) 29 | }, 30 | 31 | firstError() { 32 | if (this.hasError) { 33 | return this.errors.first(this.validationKey) 34 | } 35 | }, 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /src/mixins/HasCards.js: -------------------------------------------------------------------------------- 1 | import cardSizes from '../util/cardSizes' 2 | 3 | export default { 4 | props: { 5 | loadCards: { 6 | type: Boolean, 7 | default: true, 8 | }, 9 | }, 10 | 11 | data: () => ({ cards: [] }), 12 | 13 | /** 14 | * Fetch all of the metrics panels for this view 15 | */ 16 | created() { 17 | this.fetchCards() 18 | }, 19 | 20 | watch: { 21 | cardsEndpoint() { 22 | this.fetchCards() 23 | }, 24 | }, 25 | 26 | methods: { 27 | async fetchCards() { 28 | // We disable fetching of cards when the component is being show 29 | // on a resource detail view to avoid extra network requests 30 | if (this.loadCards) { 31 | const { data: cards } = await Nova.request().get(this.cardsEndpoint, { 32 | params: this.extraCardParams, 33 | }) 34 | this.cards = cards 35 | } 36 | }, 37 | }, 38 | 39 | computed: { 40 | /** 41 | * Determine whether we have cards to show on the Dashboard 42 | */ 43 | shouldShowCards() { 44 | return this.cards.length > 0 45 | }, 46 | 47 | /** 48 | * Return the small cards used for the Dashboard 49 | */ 50 | smallCards() { 51 | return _.filter(this.cards, c => cardSizes.indexOf(c.width) !== -1) 52 | }, 53 | 54 | /** 55 | * Return the full-width cards used for the Dashboard 56 | */ 57 | largeCards() { 58 | return _.filter(this.cards, c => c.width == 'full') 59 | }, 60 | 61 | /** 62 | * Get the extra card params to pass to the endpoint. 63 | */ 64 | extraCardParams() { 65 | return null 66 | }, 67 | }, 68 | } 69 | -------------------------------------------------------------------------------- /src/mixins/InteractsWithDates.js: -------------------------------------------------------------------------------- 1 | export default { 2 | methods: { 3 | /** 4 | * Convert the given localized date time string to the application's timezone. 5 | */ 6 | toAppTimezone(value) { 7 | return value 8 | ? moment 9 | .tz(value, this.userTimezone) 10 | .clone() 11 | .tz(Nova.config.timezone) 12 | .format('YYYY-MM-DD HH:mm:ss') 13 | : value 14 | }, 15 | 16 | /** 17 | * Convert the given application timezone date time string to the local timezone. 18 | */ 19 | fromAppTimezone(value) { 20 | if (!value) { 21 | return value 22 | } 23 | 24 | return moment 25 | .tz(value, Nova.config.timezone) 26 | .clone() 27 | .tz(this.userTimezone) 28 | .format('YYYY-MM-DD HH:mm:ss') 29 | }, 30 | 31 | /** 32 | * Get the localized date time for the given field. 33 | */ 34 | localizeDateTimeField(field) { 35 | if (!field.value) { 36 | return field.value 37 | } 38 | 39 | const localized = moment 40 | .tz(field.value, Nova.config.timezone) 41 | .clone() 42 | .tz(this.userTimezone) 43 | 44 | if (field.format) { 45 | return localized.format(field.format) 46 | } 47 | 48 | return this.usesTwelveHourTime 49 | ? localized.format('YYYY-MM-DD h:mm:ss A') 50 | : localized.format('YYYY-MM-DD HH:mm:ss') 51 | }, 52 | 53 | /** 54 | * Get the localized date for the given field. 55 | */ 56 | localizeDateField(field) { 57 | if (!field.value) { 58 | return field.value 59 | } 60 | 61 | const localized = moment 62 | .tz(field.value, Nova.config.timezone) 63 | .clone() 64 | .tz(this.userTimezone) 65 | 66 | if (field.format) { 67 | return localized.format(field.format) 68 | } 69 | 70 | return localized.format('YYYY-MM-DD') 71 | }, 72 | }, 73 | 74 | computed: { 75 | /** 76 | * Get the user's local timezone. 77 | */ 78 | userTimezone() { 79 | return Nova.config.userTimezone ? Nova.config.userTimezone : moment.tz.guess() 80 | }, 81 | 82 | /** 83 | * Determine if the user is used to 12 hour time. 84 | */ 85 | usesTwelveHourTime() { 86 | return ( 87 | _.endsWith(new Date().toLocaleString(), 'AM') || 88 | _.endsWith(new Date().toLocaleString(), 'PM') 89 | ) 90 | }, 91 | }, 92 | } 93 | -------------------------------------------------------------------------------- /src/mixins/InteractsWithQueryString.js: -------------------------------------------------------------------------------- 1 | import defaults from 'lodash/defaults' 2 | 3 | export default { 4 | methods: { 5 | /** 6 | * Update the given query string values. 7 | */ 8 | updateQueryString(value) { 9 | this.$router.push({ query: defaults(value, this.$route.query) }) 10 | .catch(error => { 11 | if (error.name != "NavigationDuplicated") { 12 | throw error; 13 | } 14 | }); 15 | }, 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /src/mixins/InteractsWithResourceInformation.js: -------------------------------------------------------------------------------- 1 | export default { 2 | computed: { 3 | /** 4 | * Get the resource information object for the current resource. 5 | */ 6 | resourceInformation() { 7 | return _.find(Nova.config.resources, resource => { 8 | return resource.uriKey == this.resourceName 9 | }) 10 | }, 11 | 12 | /** 13 | * Get the resource information object for the current resource. 14 | */ 15 | viaResourceInformation() { 16 | if (!this.viaResource) { 17 | return 18 | } 19 | 20 | return _.find(Nova.config.resources, resource => { 21 | return resource.uriKey == this.viaResource 22 | }) 23 | }, 24 | 25 | /** 26 | * Determine if the user is authorized to create the current resource. 27 | */ 28 | authorizedToCreate() { 29 | return this.resourceInformation.authorizedToCreate 30 | }, 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /src/mixins/Paginatable.js: -------------------------------------------------------------------------------- 1 | export default { 2 | methods: { 3 | /** 4 | * Select the previous page. 5 | */ 6 | selectPreviousPage() { 7 | this.updateQueryString({ [this.pageParameter]: this.currentPage - 1 }) 8 | }, 9 | 10 | /** 11 | * Select the next page. 12 | */ 13 | selectNextPage() { 14 | this.updateQueryString({ [this.pageParameter]: this.currentPage + 1 }) 15 | }, 16 | }, 17 | 18 | computed: { 19 | /** 20 | * Get the current page from the query string. 21 | */ 22 | currentPage() { 23 | return parseInt(this.$route.query[this.pageParameter] || 1) 24 | }, 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /src/mixins/PerPageable.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data: () => ({ perPage: 25 }), 3 | 4 | methods: { 5 | /** 6 | * Sync the per page values from the query string. 7 | */ 8 | initializePerPageFromQueryString() { 9 | this.perPage = this.currentPerPage 10 | }, 11 | 12 | /** 13 | * Update the desired amount of resources per page. 14 | */ 15 | perPageChanged() { 16 | this.updateQueryString({ [this.perPageParameter]: this.perPage }) 17 | }, 18 | }, 19 | 20 | computed: { 21 | /** 22 | * Get the current per page value from the query string. 23 | */ 24 | currentPerPage() { 25 | return this.$route.query[this.perPageParameter] || 25 26 | }, 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /src/mixins/PerformsSearches.js: -------------------------------------------------------------------------------- 1 | import debounce from 'lodash/debounce' 2 | 3 | export default { 4 | data: () => ({ 5 | search: '', 6 | selectedResource: '', 7 | availableResources: [], 8 | }), 9 | 10 | methods: { 11 | /** 12 | * Set the currently selected resource 13 | */ 14 | selectResource(resource) { 15 | this.selectedResource = resource 16 | 17 | if (this.field) { 18 | Nova.$emit(this.field.attribute + '-change', this.selectedResource.value) 19 | } 20 | }, 21 | 22 | /** 23 | * Handle the search box being cleared. 24 | */ 25 | handleSearchCleared() { 26 | this.availableResources = [] 27 | }, 28 | 29 | /** 30 | * Clear the selected resource and availableResources 31 | */ 32 | clearSelection() { 33 | this.selectedResource = '' 34 | this.availableResources = [] 35 | 36 | if (this.field) { 37 | Nova.$emit(this.field.attribute + '-change', null) 38 | } 39 | }, 40 | 41 | /** 42 | * Perform a search to get the relatable resources. 43 | */ 44 | performSearch(search) { 45 | this.search = search 46 | 47 | const trimmedSearch = search.trim() 48 | // If the user performs an empty search, it will load all the results 49 | // so let's just set the availableResources to an empty array to avoid 50 | // loading a huge result set 51 | if (trimmedSearch == '') { 52 | return 53 | } 54 | 55 | this.debouncer(() => { 56 | this.getAvailableResources(trimmedSearch) 57 | }, 500) 58 | }, 59 | 60 | /** 61 | * Debounce function for the search handler 62 | */ 63 | debouncer: debounce(callback => callback(), 500), 64 | }, 65 | } 66 | -------------------------------------------------------------------------------- /src/mixins/PreventsFormAbandonment.js: -------------------------------------------------------------------------------- 1 | export default { 2 | beforeRouteLeave(to, from, next) { 3 | if (this.canLeave) { 4 | next() 5 | return 6 | } 7 | 8 | const answer = window.confirm(this.__('Do you really want to leave? You have unsaved changes.')) 9 | 10 | if (answer) { 11 | next() 12 | return 13 | } 14 | 15 | next(false) 16 | }, 17 | 18 | data: () => ({ 19 | canLeave: true, 20 | }), 21 | 22 | methods: { 23 | /** 24 | * Prevent accidental abandonment only if form was changed. 25 | */ 26 | updateFormStatus() { 27 | this.canLeave = false 28 | }, 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /src/mixins/TogglesTrashed.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data: () => ({ 3 | withTrashed: false, 4 | }), 5 | 6 | methods: { 7 | /** 8 | * Toggle the trashed state of the search 9 | */ 10 | toggleWithTrashed() { 11 | this.withTrashed = !this.withTrashed 12 | }, 13 | 14 | /** 15 | * Enable searching for trashed resources 16 | */ 17 | enableWithTrashed() { 18 | this.withTrashed = true 19 | }, 20 | 21 | /** 22 | * Disable searching for trashed resources 23 | */ 24 | disableWithTrashed() { 25 | this.withTrashed = false 26 | }, 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /src/propTypes/index.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | const propTypes = { 4 | showHelpText: { 5 | type: Boolean, 6 | default: false, 7 | }, 8 | 9 | shownViaNewRelationModal: { 10 | type: Boolean, 11 | default: false, 12 | }, 13 | 14 | resourceId: { type: [Number, String] }, 15 | 16 | resourceName: { type: String }, 17 | 18 | field: { 19 | type: Object, 20 | required: true, 21 | }, 22 | 23 | viaResource: { 24 | type: String, 25 | required: false, 26 | }, 27 | 28 | viaResourceId: { 29 | type: [String, Number], 30 | required: false, 31 | }, 32 | 33 | viaRelationship: { 34 | type: String, 35 | required: false, 36 | }, 37 | 38 | shouldOverrideMeta: { 39 | type: Boolean, 40 | default: false, 41 | }, 42 | } 43 | 44 | function mapProps(attributes) { 45 | return _.pick(propTypes, attributes) 46 | } 47 | 48 | export { mapProps } 49 | -------------------------------------------------------------------------------- /src/util/capitalize.js: -------------------------------------------------------------------------------- 1 | import upperFirst from 'lodash/upperFirst' 2 | 3 | export default function(string) { 4 | return upperFirst(string) 5 | } 6 | -------------------------------------------------------------------------------- /src/util/cardSizes.js: -------------------------------------------------------------------------------- 1 | export default ['1/2', '1/3', '2/3', '1/4', '3/4', '1/5', '2/5', '3/5', '4/5', '1/6', '5/6'] 2 | -------------------------------------------------------------------------------- /src/util/minimum.js: -------------------------------------------------------------------------------- 1 | export default function(originalPromise, delay = 100) { 2 | return Promise.all([ 3 | originalPromise, 4 | new Promise(resolve => { 5 | setTimeout(() => resolve(), delay) 6 | }), 7 | ]).then(result => result[0]) 8 | } 9 | 10 | // Usage 11 | // minimum(axios.get('/')) 12 | // .then(response => console.log('done')) 13 | // .catch(error => console.log(error)) 14 | -------------------------------------------------------------------------------- /src/util/singularOrPlural.js: -------------------------------------------------------------------------------- 1 | import { Inflector } from '../' 2 | import isString from 'lodash/isString' 3 | 4 | export default function singularOrPlural(value, suffix) { 5 | if (isString(suffix) && suffix.match(/^(.*)[A-Za-zÀ-ÖØ-öø-ÿ]$/) == null) return suffix 6 | else if (value > 1 || value == 0) return Inflector.pluralize(suffix) 7 | return Inflector.singularize(suffix) 8 | } 9 | -------------------------------------------------------------------------------- /tests/SingularOrPlural.test.js: -------------------------------------------------------------------------------- 1 | let { SingularOrPlural } = require('../dist/index') 2 | 3 | test('it can return correct inflector results', () => { 4 | expect(SingularOrPlural(0, 'hour')).toBe('hours') 5 | expect(SingularOrPlural(1, 'hour')).toBe('hour') 6 | expect(SingularOrPlural(1.23, 'hour')).toBe('hours') 7 | expect(SingularOrPlural(40, 'hour')).toBe('hours') 8 | expect(SingularOrPlural(40, 'Bouqueté')).toBe('Bouquetés') 9 | }); 10 | 11 | test('it does ignore when suffix is a symbol', () => { 12 | expect(SingularOrPlural(40, '%')).toBe('%') 13 | expect(SingularOrPlural(40, '!')).toBe('!') 14 | }) 15 | -------------------------------------------------------------------------------- /webpack.base.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | module: { 3 | rules: [ 4 | { 5 | test: /\.js/, 6 | loaders: ['babel-loader'], 7 | exclude: /node_modules/, 8 | }, 9 | ], 10 | }, 11 | 12 | resolve: { 13 | extensions: ['.js'], 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const merge = require('webpack-merge'); 4 | 5 | module.exports = merge(require('./webpack.base'), { 6 | context: __dirname, 7 | 8 | entry: { 9 | 'index': './src/index.js', 10 | }, 11 | 12 | output: { 13 | path: path.resolve(__dirname, 'dist'), 14 | filename: '[name].js', 15 | library: 'laravel-nova', 16 | libraryTarget: 'umd', 17 | }, 18 | }); 19 | --------------------------------------------------------------------------------