├── dist ├── mix-manifest.json └── js │ └── field.js ├── .github ├── FUNDING.yml └── workflows │ └── update-assets.yml ├── screenshots ├── screenshot-1.png └── nova-phone-number-input-social-image.png ├── mix-manifest.json ├── .gitignore ├── resources └── js │ ├── field.js │ └── components │ ├── DetailField.vue │ ├── IndexField.vue │ └── FormField.vue ├── webpack.mix.js ├── package.json ├── nova.mix.js ├── src ├── FieldServiceProvider.php └── PhoneNumber.php ├── LICENSE ├── composer.json └── README.md /dist/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/js/field.js": "/js/field.js" 3 | } 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ dniccum ] 4 | -------------------------------------------------------------------------------- /screenshots/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dniccum/nova-phone-number/HEAD/screenshots/screenshot-1.png -------------------------------------------------------------------------------- /mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/dist/js/field.js": "/dist/js/field.js", 3 | "/dist/css/field.css": "/dist/css/field.css" 4 | } 5 | -------------------------------------------------------------------------------- /screenshots/nova-phone-number-input-social-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dniccum/nova-phone-number/HEAD/screenshots/nova-phone-number-input-social-image.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor 3 | /node_modules 4 | package-lock.json 5 | composer.phar 6 | composer.lock 7 | phpunit.xml 8 | .phpunit.result.cache 9 | .DS_Store 10 | Thumbs.db 11 | auth.json -------------------------------------------------------------------------------- /resources/js/field.js: -------------------------------------------------------------------------------- 1 | import Maska from 'maska' 2 | 3 | import IndexField from './components/IndexField' 4 | import DetailField from './components/DetailField' 5 | import FormField from './components/FormField' 6 | 7 | Nova.booting((app, router) => { 8 | app.use(Maska); 9 | 10 | app.component('index-phone-number', IndexField); 11 | app.component('detail-phone-number', DetailField); 12 | app.component('form-phone-number', FormField); 13 | }) 14 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | let mix = require('laravel-mix') 2 | 3 | require('./nova.mix') 4 | 5 | // NOTE: stop .LICENSE FILES 6 | // REF: https://github.com/laravel-mix/laravel-mix/issues/2738 7 | mix.options({ 8 | terser: { 9 | extractComments: false, 10 | terserOptions: { 11 | output: { 12 | comments: false, 13 | }, 14 | }, 15 | }, 16 | }); 17 | 18 | mix 19 | .setPublicPath('dist') 20 | .js('resources/js/field.js', 'js') 21 | .vue({ version: 3 }) 22 | .nova('dniccum/phone-number') 23 | -------------------------------------------------------------------------------- /resources/js/components/DetailField.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /resources/js/components/IndexField.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "npm run development", 5 | "development": "mix", 6 | "watch": "mix watch", 7 | "watch-poll": "mix watch -- --watch-options-poll=1000", 8 | "hot": "mix watch --hot", 9 | "prod": "npm run production", 10 | "production": "mix --production" 11 | }, 12 | "devDependencies": { 13 | "@vue/compiler-sfc": "^3.2.22", 14 | "laravel-mix": "^6.0", 15 | "vue-loader": "^16.8.3" 16 | }, 17 | "dependencies": { 18 | "maska": "^1.5.0", 19 | "vue": "^3.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /nova.mix.js: -------------------------------------------------------------------------------- 1 | const mix = require('laravel-mix') 2 | const path = require('path') 3 | 4 | class NovaExtension { 5 | name() { 6 | return 'nova-extension' 7 | } 8 | 9 | register(name) { 10 | this.name = name 11 | } 12 | 13 | webpackConfig(webpackConfig) { 14 | webpackConfig.externals = { 15 | vue: 'Vue', 16 | 'laravel-nova': 'LaravelNova' 17 | } 18 | 19 | // webpackConfig.resolve.alias = { 20 | // ...(webpackConfig.resolve.alias || {}), 21 | // 'laravel-nova': path.join( 22 | // __dirname, 23 | // 'vendor/laravel/nova/resources/js/mixins/packages.js' 24 | // ), 25 | // } 26 | 27 | webpackConfig.output = { 28 | uniqueName: this.name, 29 | } 30 | } 31 | } 32 | 33 | mix.extend('nova', new NovaExtension()) 34 | -------------------------------------------------------------------------------- /.github/workflows/update-assets.yml: -------------------------------------------------------------------------------- 1 | name: "Update Assets" 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [master] 7 | pull_request: 8 | 9 | jobs: 10 | update: 11 | name: Update assets 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v3 18 | 19 | - name: Setup Node 16 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: '16.x' 23 | 24 | - name: Compile Asset 25 | run: | 26 | yarn install 27 | yarn run prod 28 | env: 29 | TAILWIND_MODE: build 30 | 31 | - name: Commit changes 32 | uses: stefanzweifel/git-auto-commit-action@v4 33 | with: 34 | commit_message: Update Assets 35 | -------------------------------------------------------------------------------- /src/FieldServiceProvider.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | 14 | 15 | 61 | -------------------------------------------------------------------------------- /src/PhoneNumber.php: -------------------------------------------------------------------------------- 1 | setRules(); 29 | 30 | parent::resolve($resource, $attribute); 31 | } 32 | 33 | /** 34 | * Tells the VueJS component what format to implement on the mask 35 | * @param string $newFormat 36 | * @return PhoneNumber 37 | */ 38 | public function format(string $newFormat="(###) ###-####") 39 | { 40 | return $this->withMeta([ 41 | 'format' => $newFormat 42 | ]); 43 | } 44 | 45 | /** 46 | * Set the placeholder text for the field if supported. 47 | * 48 | * @param string $text 49 | * @return $this 50 | */ 51 | public function placeholder($text) 52 | { 53 | $this->placeholder = $text; 54 | $this->withMeta([ 55 | 'placeholder' => $text, 56 | 'extraAttributes' => [ 57 | 'placeholder' => $text 58 | ] 59 | ]); 60 | 61 | return $this; 62 | } 63 | 64 | /** 65 | * Tells the field to use the defined or default mask as a placeholder 66 | * @param bool $useMaskPlaceholder 67 | * @return PhoneNumber 68 | */ 69 | public function useMaskPlaceholder(bool $useMaskPlaceholder=true) 70 | { 71 | return $this->withMeta([ 72 | 'useMaskPlaceholder' => $useMaskPlaceholder 73 | ]); 74 | } 75 | 76 | /** 77 | * Tells the field to show as a clickable tel link on the index view 78 | * @param bool $linkOnIndex 79 | * @return PhoneNumber 80 | */ 81 | public function linkOnIndex(bool $linkOnIndex=true) 82 | { 83 | return $this->withMeta([ 84 | 'linkOnIndex' => $linkOnIndex 85 | ]); 86 | } 87 | 88 | /** 89 | * Tells the field to show as a clickable tel link on the detail view 90 | * @param bool $linkOnDetail 91 | * @return PhoneNumber 92 | */ 93 | public function linkOnDetail(bool $linkOnDetail=true) 94 | { 95 | return $this->withMeta([ 96 | 'linkOnDetail' => $linkOnDetail 97 | ]); 98 | } 99 | 100 | /** 101 | * Overrides the default US country phone number validation 102 | * @param string $country 103 | * @return PhoneNumber 104 | */ 105 | public function country(string $country="US") 106 | { 107 | $this->countriesToValidate = $country; 108 | 109 | return $this->setRules(); 110 | } 111 | 112 | /** 113 | * Provides a list of countries to validate against 114 | * @param array $countries 115 | * @return PhoneNumber 116 | */ 117 | public function countries(array $countries) 118 | { 119 | $this->countriesToValidate = implode(',', $countries); 120 | 121 | return $this->setRules(); 122 | } 123 | 124 | /** 125 | * Tells the plugin to disable any of the field validation 126 | * @param bool $ignore 127 | * @return $this 128 | */ 129 | public function disableValidation(bool $ignore=true) 130 | { 131 | $this->ignoreValidation = $ignore; 132 | 133 | $this->withMeta([ 134 | 'disableValidation' => $ignore 135 | ]); 136 | 137 | $this->setRules(); 138 | 139 | return $this; 140 | } 141 | 142 | /** 143 | * Appends the Phone number validation rules 144 | * 145 | * @param callable|array|string $rules 146 | * @return $this 147 | */ 148 | public function rules($rules) 149 | { 150 | parent::rules($rules); 151 | 152 | return $this->setRules(); 153 | } 154 | 155 | /** 156 | * Sets the rules for the class 157 | * 158 | * @return void 159 | */ 160 | private function setRules() 161 | { 162 | $phoneValidationRules = []; 163 | 164 | if ($this->ignoreValidation === false) { 165 | $phoneValidationRules = ["phone:".$this->countriesToValidate]; 166 | 167 | if ($this->nullable) { 168 | array_push($phoneValidationRules, 'nullable'); 169 | } 170 | } 171 | 172 | $this->rules = array_merge_recursive( 173 | $phoneValidationRules, $this->rules 174 | ); 175 | 176 | return $this; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Laravel Nova Phone Number Field](https://github.com/dniccum/nova-phone-number/blob/master/screenshots/nova-phone-number-input-social-image.png?raw=true) 2 | 3 | [![Latest Version on Packagist](https://poser.pugx.org/dniccum/phone-number/v/stable?format=flat-square&color=#0E7FC0)](https://packagist.org/packages/dniccum/phone-number) 4 | [![License](https://poser.pugx.org/dniccum/phone-number/license?format=flat-square)](https://packagist.org/packages/dniccum/phone-number) 5 | [![Total Downloads](https://poser.pugx.org/dniccum/phone-number/downloads?format=flat-square)](https://packagist.org/packages/dniccum/phone-number) 6 | 7 | A Laravel Nova field to format using a dynamic input mask and additional phone number validation. 8 | 9 | **NOTE: This field utilizes [Propaganistas / Laravel-Phone package](https://github.com/Propaganistas/Laravel-Phone) for validation.** 10 | 11 | ![Image 1](./screenshots/screenshot-1.png "Phone number input") 12 | 13 | ## Installation 14 | 15 | To install this tool, use the installation code below: 16 | 17 | ``` 18 | composer require dniccum/phone-number 19 | ``` 20 | 21 | ## Code 22 | 23 | To use the field, add the following code to your Nova resource. As this is a field, all of the default field properties can be applied. 24 | 25 | ```php 26 | use Dniccum\PhoneNumber\PhoneNumber; 27 | 28 | PhoneNumber::make('Phone Number') 29 | ``` 30 | 31 | ### Options 32 | 33 | To support multiple types and formats of phone numbers, this field has multiple methods for input masking and validation that are available. 34 | 35 | #### Defaults 36 | 37 | | Method/Options | Default | 38 | |--------------------|-------------------------------| 39 | | format | **string:** '(###) ###-####' | 40 | | placeholder | **string:** '[Name of Field]' | 41 | | useMaskPlaceholder | **boolean:** false | 42 | | country | **string:** 'US' | 43 | | countries | **string[]:** ['US'] | 44 | | disableValidation | **boolean:** false | 45 | | linkOnIndex | **boolean:** false | 46 | | linkOnDetail | **boolean:** false | 47 | 48 | #### format 49 | 50 | ```php 51 | PhoneNumber::make('Phone Number') 52 | ->format('###-###-####') 53 | ``` 54 | 55 | **Type:** string 56 | 57 | **Default:** (###) ###-#### 58 | 59 | This is the value that the javascript controlling the input mask will use define it's values; and depending the field's configuration the placeholder text. To indicate numbers, use the hash (#) symbol. 60 | 61 | **Note:** Other types of content can be included within this input like an phone extension: 62 | 63 | ```php 64 | PhoneNumber::make('Phone Number') 65 | ->format('###-###-#### ext ####') 66 | ``` 67 | 68 | However the built-in phone number validation will **FAIL** as this is technically an invalid phone number. To prevent the validation from failing, turn off the phone number validation like so: 69 | 70 | ```php 71 | PhoneNumber::make('Phone Number') 72 | ->format('###-###-####') 73 | ->disableValidation() 74 | ``` 75 | 76 | #### placeholder 77 | 78 | ```php 79 | PhoneNumber::make('Phone Number') 80 | ->placeholder('Personal Home Number') 81 | ``` 82 | 83 | **Type:** string 84 | 85 | **Default:** [Name of the Field] 86 | 87 | If you would like to override the default placeholder supplied by Nova, which is the name of field, user a simple string. 88 | 89 | **Note:** If you are telling the input to override the placeholder by using the input's mask with the `useMaskPlaceholder` method, this will not work. 90 | 91 | #### useMaskPlaceholder 92 | 93 | ```php 94 | PhoneNumber::make('Phone Number') 95 | ->useMaskPlaceholder() 96 | ``` 97 | 98 | **Type:** boolean 99 | 100 | **Default:** false 101 | 102 | This will tell the field to replace the input's defined placeholder with the input mask from the `->format()` method. 103 | 104 | #### country 105 | 106 | ```php 107 | PhoneNumber::make('Phone Number') 108 | ->country('CA') 109 | ``` 110 | 111 | **Type:** string 112 | 113 | **Default:** US 114 | 115 | This tells the field what type of phone number validation to use. To define a type of validation, define a [ISO 3166-1 alpha-2 compliant](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements) country code. 116 | 117 | You can only define one country here. If you would like to define more than one, please see the `->countries()` method. 118 | 119 | **NOTE: This field utilizes [Propaganistas / Laravel-Phone package](https://github.com/Propaganistas/Laravel-Phone) for validation.** 120 | 121 | #### countries 122 | 123 | ```php 124 | PhoneNumber::make('Phone Number') 125 | ->countries(['US', 'CA']) 126 | ``` 127 | 128 | **Type:** string[] 129 | 130 | **Default:** US 131 | 132 | If you would like to define more than one country to validate against, define string-based array of [ISO 3166-1 alpha-2 compliant](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements) country codes. 133 | 134 | **NOTE: This field utilizes [Propaganistas / Laravel-Phone package](https://github.com/Propaganistas/Laravel-Phone) for validation.** 135 | 136 | #### linkOnIndex 137 | 138 | ```php 139 | PhoneNumber::make('Phone Number') 140 | ->linkOnIndex() 141 | ``` 142 | 143 | **Type:** boolean 144 | 145 | **Default:** false 146 | 147 | Render's the phone number as a clickable link on the index view. 148 | 149 | #### linkOnDetail 150 | 151 | ```php 152 | PhoneNumber::make('Phone Number') 153 | ->linkOnDetail() 154 | ``` 155 | 156 | **Type:** boolean 157 | 158 | **Default:** false 159 | 160 | Render's the phone number as a clickable link on the detail view. 161 | 162 | ## Credits 163 | 164 | * [Doug Niccum](https://github.com/dniccum) 165 | * [Braden Keith](https://github.com/bradenkeith) 166 | * [lintaba](https://github.com/lintaba) 167 | * [Maxim Kot](https://github.com/batFormat) 168 | * [Shawn Heide](https://github.com/shawnheide) 169 | 170 | ## License 171 | 172 | The MIT License (MIT). Please see [License File](./LICENSE.md) for more information. 173 | -------------------------------------------------------------------------------- /dist/js/field.js: -------------------------------------------------------------------------------- 1 | (()=>{"use strict";var e={262:(e,t)=>{t.A=(e,t)=>{const n=e.__vccOpts||e;for(const[e,r]of t)n[e]=r;return n}}},t={};function n(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function r(e,t){for(var n=0;n2&&void 0!==arguments[2]?arguments[2]:s,r=!(arguments.length>3&&void 0!==arguments[3])||arguments[3];return u(t).length>1?c(t)(e,t,n,r):p(e,t,n,r)}function u(e){try{return JSON.parse(e)}catch(t){return[e]}}function c(e){var t=u(e).sort((function(e,t){return e.length-t.length}));return function(e,r,a){var o=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],i=t.map((function(t){return p(e,t,a,!1)})),s=i.pop();for(var l in t)if(n(s,t[l],a))return p(e,t[l],a,o);return""};function n(e,t,n){for(var r in n)n[r].escape&&(t=t.replace(new RegExp(r+".{1}","g"),""));return t.split("").filter((function(e){return n[e]&&n[e].pattern})).length>=e.length}}function p(e,t,n){for(var r=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],a=0,o=0,i="",s="";a1&&void 0!==arguments[1]?arguments[1]:{};if(n(this,e),!t)throw new Error("Maska: no element for mask");if(null!=a.preprocessor&&"function"!=typeof a.preprocessor)throw new Error("Maska: preprocessor must be a function");if(a.tokens)for(var o in a.tokens)a.tokens[o]=i({},a.tokens[o]),a.tokens[o].pattern&&v(a.tokens[o].pattern)&&(a.tokens[o].pattern=new RegExp(a.tokens[o].pattern));this._opts={mask:a.mask,tokens:i(i({},s),a.tokens),preprocessor:a.preprocessor},this._el=v(t)?document.querySelectorAll(t):t.length?t:[t],this.inputEvent=function(e){return r.updateValue(e.target,e)},this.init()}var t,a;return t=e,(a=[{key:"init",value:function(){for(var e=this,t=function(t){var n=d(e._el[t]);!e._opts.mask||n.dataset.mask&&n.dataset.mask===e._opts.mask||(n.dataset.mask=e._opts.mask),setTimeout((function(){return e.updateValue(n)}),0),n.dataset.maskInited||(n.dataset.maskInited=!0,n.addEventListener("input",e.inputEvent),n.addEventListener("beforeinput",e.beforeInput))},n=0;n1&&void 0!==arguments[1]?arguments[1]:null,n=document.createEvent("Event");return n.initEvent(e,!0,!0),t&&(n.inputType=t),n}(e,n&&n.inputType||null))}}])&&r(t.prototype,a),e}(),k=(h=new WeakMap,function(e,t){t.value&&(h.has(e)&&!function(e){return!(v(e.value)&&e.value===e.oldValue||Array.isArray(e.value)&&JSON.stringify(e.value)===JSON.stringify(e.oldValue)||e.value&&e.value.mask&&e.oldValue&&e.oldValue.mask&&e.value.mask===e.oldValue.mask)}(t)||h.set(e,new m(e,function(e){var t={};return e.mask?(t.mask=Array.isArray(e.mask)?JSON.stringify(e.mask):e.mask,t.tokens=e.tokens?i({},e.tokens):{},t.preprocessor=e.preprocessor):t.mask=Array.isArray(e)?JSON.stringify(e):e,t}(t.value))))});function y(e){e.directive("maska",k)}"undefined"!=typeof window&&window.Vue&&window.Vue.use&&window.Vue.use(y);const g=y,b=Vue;var w=["innerHTML"],O={key:1},E=["innerHTML"];const x={props:["resourceName","field"],beforeMount:function(){this.field.linkOnIndex&&(this.field.value='').concat(this.field.value,""))}};var V=function n(r){var a=t[r];if(void 0!==a)return a.exports;var o=t[r]={exports:{}};return e[r](o,o.exports,n),o.exports}(262);const A=(0,V.A)(x,[["render",function(e,t,n,r,a,o){return this.field.linkOnIndex?((0,b.openBlock)(),(0,b.createElementBlock)("div",{key:0,onClick:t[0]||(t[0]=(0,b.withModifiers)((function(){}),["stop"]))},[(0,b.createElementVNode)("span",{innerHTML:n.field.value},null,8,w)])):((0,b.openBlock)(),(0,b.createElementBlock)("div",O,[(0,b.createElementVNode)("span",{innerHTML:n.field.value},null,8,E)]))}]]);const _={props:["index","resource","resourceName","resourceId","field"],beforeMount:function(){this.field.linkOnDetail&&this.field.value&&(this.field.asHtml=!0,this.field.value='').concat(this.field.value,""))}},j=(0,V.A)(_,[["render",function(e,t,n,r,a,o){var i=(0,b.resolveComponent)("PanelItem");return(0,b.openBlock)(),(0,b.createBlock)(i,{index:n.index,field:n.field},null,8,["index","field"])}]]);var N=["id","placeholder"];const I=LaravelNova;const S={mixins:[I.DependentFormField,I.HandlesValidationErrors],props:["resourceName","resourceId","field"],computed:{placeholder:function(){var e=this.field.useMaskPlaceholder,t=this.field.placeholder;return e?this.mask:t||this.field.name},mask:function(){return this.field.format?this.field.format:"(###) ###-####"}},methods:{setInitialValue:function(){this.value=this.field.value||""},fill:function(e){e.append(this.field.attribute,this.value||"")},handleChange:function(e){this.value=e}}},T=(0,V.A)(S,[["render",function(e,t,n,r,a,o){var i=(0,b.resolveComponent)("DefaultField"),s=(0,b.resolveDirective)("maska");return(0,b.openBlock)(),(0,b.createBlock)(i,{field:e.currentField,errors:e.errors,"show-help-text":e.showHelpText},{field:(0,b.withCtx)((function(){return[(0,b.withDirectives)((0,b.createElementVNode)("input",{id:e.currentField.name,type:"tel",class:(0,b.normalizeClass)(["w-full form-control form-input form-input-bordered",e.errorClasses]),placeholder:o.placeholder,"onUpdate:modelValue":t[0]||(t[0]=function(t){return e.value=t})},null,10,N),[[s,o.mask],[b.vModelText,e.value]])]})),_:1},8,["field","errors","show-help-text"])}]]);Nova.booting((function(e,t){e.use(g),e.component("index-phone-number",A),e.component("detail-phone-number",j),e.component("form-phone-number",T)}))})(); --------------------------------------------------------------------------------