ui.js 134 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512
  1. 'use strict';
  2. 'require validation';
  3. 'require baseclass';
  4. 'require request';
  5. 'require session';
  6. 'require poll';
  7. 'require dom';
  8. 'require rpc';
  9. 'require uci';
  10. 'require fs';
  11. var modalDiv = null,
  12. tooltipDiv = null,
  13. indicatorDiv = null,
  14. tooltipTimeout = null;
  15. /**
  16. * @class AbstractElement
  17. * @memberof LuCI.ui
  18. * @hideconstructor
  19. * @classdesc
  20. *
  21. * The `AbstractElement` class serves as abstract base for the different widgets
  22. * implemented by `LuCI.ui`. It provides the common logic for getting and
  23. * setting values, for checking the validity state and for wiring up required
  24. * events.
  25. *
  26. * UI widget instances are usually not supposed to be created by view code
  27. * directly, instead they're implicitely created by `LuCI.form` when
  28. * instantiating CBI forms.
  29. *
  30. * This class is automatically instantiated as part of `LuCI.ui`. To use it
  31. * in views, use `'require ui'` and refer to `ui.AbstractElement`. To import
  32. * it in external JavaScript, use `L.require("ui").then(...)` and access the
  33. * `AbstractElement` property of the class instance value.
  34. */
  35. var UIElement = baseclass.extend(/** @lends LuCI.ui.AbstractElement.prototype */ {
  36. /**
  37. * @typedef {Object} InitOptions
  38. * @memberof LuCI.ui.AbstractElement
  39. *
  40. * @property {string} [id]
  41. * Specifies the widget ID to use. It will be used as HTML `id` attribute
  42. * on the toplevel widget DOM node.
  43. *
  44. * @property {string} [name]
  45. * Specifies the widget name which is set as HTML `name` attribute on the
  46. * corresponding `<input>` element.
  47. *
  48. * @property {boolean} [optional=true]
  49. * Specifies whether the input field allows empty values.
  50. *
  51. * @property {string} [datatype=string]
  52. * An expression describing the input data validation constraints.
  53. * It defaults to `string` which will allow any value.
  54. * See {@link LuCI.validation} for details on the expression format.
  55. *
  56. * @property {function} [validator]
  57. * Specifies a custom validator function which is invoked after the
  58. * standard validation constraints are checked. The function should return
  59. * `true` to accept the given input value. Any other return value type is
  60. * converted to a string and treated as validation error message.
  61. *
  62. * @property {boolean} [disabled=false]
  63. * Specifies whether the widget should be rendered in disabled state
  64. * (`true`) or not (`false`). Disabled widgets cannot be interacted with
  65. * and are displayed in a slightly faded style.
  66. */
  67. /**
  68. * Read the current value of the input widget.
  69. *
  70. * @instance
  71. * @memberof LuCI.ui.AbstractElement
  72. * @returns {string|string[]|null}
  73. * The current value of the input element. For simple inputs like text
  74. * fields or selects, the return value type will be a - possibly empty -
  75. * string. Complex widgets such as `DynamicList` instances may result in
  76. * an array of strings or `null` for unset values.
  77. */
  78. getValue: function() {
  79. if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input'))
  80. return this.node.value;
  81. return null;
  82. },
  83. /**
  84. * Set the current value of the input widget.
  85. *
  86. * @instance
  87. * @memberof LuCI.ui.AbstractElement
  88. * @param {string|string[]|null} value
  89. * The value to set the input element to. For simple inputs like text
  90. * fields or selects, the value should be a - possibly empty - string.
  91. * Complex widgets such as `DynamicList` instances may accept string array
  92. * or `null` values.
  93. */
  94. setValue: function(value) {
  95. if (dom.matches(this.node, 'select') || dom.matches(this.node, 'input'))
  96. this.node.value = value;
  97. },
  98. /**
  99. * Set the current placeholder value of the input widget.
  100. *
  101. * @instance
  102. * @memberof LuCI.ui.AbstractElement
  103. * @param {string|string[]|null} value
  104. * The placeholder to set for the input element. Only applicable to text
  105. * inputs, not to radio buttons, selects or similar.
  106. */
  107. setPlaceholder: function(value) {
  108. var node = this.node ? this.node.querySelector('input,textarea') : null;
  109. if (node) {
  110. switch (node.getAttribute('type') || 'text') {
  111. case 'password':
  112. case 'search':
  113. case 'tel':
  114. case 'text':
  115. case 'url':
  116. if (value != null && value != '')
  117. node.setAttribute('placeholder', value);
  118. else
  119. node.removeAttribute('placeholder');
  120. }
  121. }
  122. },
  123. /**
  124. * Check whether the input value was altered by the user.
  125. *
  126. * @instance
  127. * @memberof LuCI.ui.AbstractElement
  128. * @returns {boolean}
  129. * Returns `true` if the input value has been altered by the user or
  130. * `false` if it is unchaged. Note that if the user modifies the initial
  131. * value and changes it back to the original state, it is still reported
  132. * as changed.
  133. */
  134. isChanged: function() {
  135. return (this.node ? this.node.getAttribute('data-changed') : null) == 'true';
  136. },
  137. /**
  138. * Check whether the current input value is valid.
  139. *
  140. * @instance
  141. * @memberof LuCI.ui.AbstractElement
  142. * @returns {boolean}
  143. * Returns `true` if the current input value is valid or `false` if it does
  144. * not meet the validation constraints.
  145. */
  146. isValid: function() {
  147. return (this.validState !== false);
  148. },
  149. /**
  150. * Force validation of the current input value.
  151. *
  152. * Usually input validation is automatically triggered by various DOM events
  153. * bound to the input widget. In some cases it is required though to manually
  154. * trigger validation runs, e.g. when programmatically altering values.
  155. *
  156. * @instance
  157. * @memberof LuCI.ui.AbstractElement
  158. */
  159. triggerValidation: function() {
  160. if (typeof(this.vfunc) != 'function')
  161. return false;
  162. var wasValid = this.isValid();
  163. this.vfunc();
  164. return (wasValid != this.isValid());
  165. },
  166. /**
  167. * Dispatch a custom (synthetic) event in response to received events.
  168. *
  169. * Sets up event handlers on the given target DOM node for the given event
  170. * names that dispatch a custom event of the given type to the widget root
  171. * DOM node.
  172. *
  173. * The primary purpose of this function is to set up a series of custom
  174. * uniform standard events such as `widget-update`, `validation-success`,
  175. * `validation-failure` etc. which are triggered by various different
  176. * widget specific native DOM events.
  177. *
  178. * @instance
  179. * @memberof LuCI.ui.AbstractElement
  180. * @param {Node} targetNode
  181. * Specifies the DOM node on which the native event listeners should be
  182. * registered.
  183. *
  184. * @param {string} synevent
  185. * The name of the custom event to dispatch to the widget root DOM node.
  186. *
  187. * @param {string[]} events
  188. * The native DOM events for which event handlers should be registered.
  189. */
  190. registerEvents: function(targetNode, synevent, events) {
  191. var dispatchFn = L.bind(function(ev) {
  192. this.node.dispatchEvent(new CustomEvent(synevent, { bubbles: true }));
  193. }, this);
  194. for (var i = 0; i < events.length; i++)
  195. targetNode.addEventListener(events[i], dispatchFn);
  196. },
  197. /**
  198. * Setup listeners for native DOM events that may update the widget value.
  199. *
  200. * Sets up event handlers on the given target DOM node for the given event
  201. * names which may cause the input value to update, such as `keyup` or
  202. * `onclick` events. In contrast to change events, such update events will
  203. * trigger input value validation.
  204. *
  205. * @instance
  206. * @memberof LuCI.ui.AbstractElement
  207. * @param {Node} targetNode
  208. * Specifies the DOM node on which the event listeners should be registered.
  209. *
  210. * @param {...string} events
  211. * The DOM events for which event handlers should be registered.
  212. */
  213. setUpdateEvents: function(targetNode /*, ... */) {
  214. var datatype = this.options.datatype,
  215. optional = this.options.hasOwnProperty('optional') ? this.options.optional : true,
  216. validate = this.options.validate,
  217. events = this.varargs(arguments, 1);
  218. this.registerEvents(targetNode, 'widget-update', events);
  219. if (!datatype && !validate)
  220. return;
  221. this.vfunc = UI.prototype.addValidator.apply(UI.prototype, [
  222. targetNode, datatype || 'string',
  223. optional, validate
  224. ].concat(events));
  225. this.node.addEventListener('validation-success', L.bind(function(ev) {
  226. this.validState = true;
  227. }, this));
  228. this.node.addEventListener('validation-failure', L.bind(function(ev) {
  229. this.validState = false;
  230. }, this));
  231. },
  232. /**
  233. * Setup listeners for native DOM events that may change the widget value.
  234. *
  235. * Sets up event handlers on the given target DOM node for the given event
  236. * names which may cause the input value to change completely, such as
  237. * `change` events in a select menu. In contrast to update events, such
  238. * change events will not trigger input value validation but they may cause
  239. * field dependencies to get re-evaluated and will mark the input widget
  240. * as dirty.
  241. *
  242. * @instance
  243. * @memberof LuCI.ui.AbstractElement
  244. * @param {Node} targetNode
  245. * Specifies the DOM node on which the event listeners should be registered.
  246. *
  247. * @param {...string} events
  248. * The DOM events for which event handlers should be registered.
  249. */
  250. setChangeEvents: function(targetNode /*, ... */) {
  251. var tag_changed = L.bind(function(ev) { this.setAttribute('data-changed', true) }, this.node);
  252. for (var i = 1; i < arguments.length; i++)
  253. targetNode.addEventListener(arguments[i], tag_changed);
  254. this.registerEvents(targetNode, 'widget-change', this.varargs(arguments, 1));
  255. },
  256. /**
  257. * Render the widget, setup event listeners and return resulting markup.
  258. *
  259. * @instance
  260. * @memberof LuCI.ui.AbstractElement
  261. *
  262. * @returns {Node}
  263. * Returns a DOM Node or DocumentFragment containing the rendered
  264. * widget markup.
  265. */
  266. render: function() {}
  267. });
  268. /**
  269. * Instantiate a text input widget.
  270. *
  271. * @constructor Textfield
  272. * @memberof LuCI.ui
  273. * @augments LuCI.ui.AbstractElement
  274. *
  275. * @classdesc
  276. *
  277. * The `Textfield` class implements a standard single line text input field.
  278. *
  279. * UI widget instances are usually not supposed to be created by view code
  280. * directly, instead they're implicitely created by `LuCI.form` when
  281. * instantiating CBI forms.
  282. *
  283. * This class is automatically instantiated as part of `LuCI.ui`. To use it
  284. * in views, use `'require ui'` and refer to `ui.Textfield`. To import it in
  285. * external JavaScript, use `L.require("ui").then(...)` and access the
  286. * `Textfield` property of the class instance value.
  287. *
  288. * @param {string} [value=null]
  289. * The initial input value.
  290. *
  291. * @param {LuCI.ui.Textfield.InitOptions} [options]
  292. * Object describing the widget specific options to initialize the input.
  293. */
  294. var UITextfield = UIElement.extend(/** @lends LuCI.ui.Textfield.prototype */ {
  295. /**
  296. * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
  297. * the following properties are recognized:
  298. *
  299. * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
  300. * @memberof LuCI.ui.Textfield
  301. *
  302. * @property {boolean} [password=false]
  303. * Specifies whether the input should be rendered as concealed password field.
  304. *
  305. * @property {boolean} [readonly=false]
  306. * Specifies whether the input widget should be rendered readonly.
  307. *
  308. * @property {number} [maxlength]
  309. * Specifies the HTML `maxlength` attribute to set on the corresponding
  310. * `<input>` element. Note that this a legacy property that exists for
  311. * compatibility reasons. It is usually better to `maxlength(N)` validation
  312. * expression.
  313. *
  314. * @property {string} [placeholder]
  315. * Specifies the HTML `placeholder` attribute which is displayed when the
  316. * corresponding `<input>` element is empty.
  317. */
  318. __init__: function(value, options) {
  319. this.value = value;
  320. this.options = Object.assign({
  321. optional: true,
  322. password: false
  323. }, options);
  324. },
  325. /** @override */
  326. render: function() {
  327. var frameEl = E('div', { 'id': this.options.id });
  328. var inputEl = E('input', {
  329. 'id': this.options.id ? 'widget.' + this.options.id : null,
  330. 'name': this.options.name,
  331. 'type': 'text',
  332. 'class': this.options.password ? 'cbi-input-password' : 'cbi-input-text',
  333. 'readonly': this.options.readonly ? '' : null,
  334. 'disabled': this.options.disabled ? '' : null,
  335. 'maxlength': this.options.maxlength,
  336. 'placeholder': this.options.placeholder,
  337. 'value': this.value,
  338. });
  339. if (this.options.password) {
  340. frameEl.appendChild(E('div', { 'class': 'control-group' }, [
  341. inputEl,
  342. E('button', {
  343. 'class': 'cbi-button cbi-button-neutral',
  344. 'title': _('Reveal/hide password'),
  345. 'aria-label': _('Reveal/hide password'),
  346. 'click': function(ev) {
  347. var e = this.previousElementSibling;
  348. e.type = (e.type === 'password') ? 'text' : 'password';
  349. ev.preventDefault();
  350. }
  351. }, '∗')
  352. ]));
  353. window.requestAnimationFrame(function() { inputEl.type = 'password' });
  354. }
  355. else {
  356. frameEl.appendChild(inputEl);
  357. }
  358. return this.bind(frameEl);
  359. },
  360. /** @private */
  361. bind: function(frameEl) {
  362. var inputEl = frameEl.querySelector('input');
  363. this.node = frameEl;
  364. this.setUpdateEvents(inputEl, 'keyup', 'blur');
  365. this.setChangeEvents(inputEl, 'change');
  366. dom.bindClassInstance(frameEl, this);
  367. return frameEl;
  368. },
  369. /** @override */
  370. getValue: function() {
  371. var inputEl = this.node.querySelector('input');
  372. return inputEl.value;
  373. },
  374. /** @override */
  375. setValue: function(value) {
  376. var inputEl = this.node.querySelector('input');
  377. inputEl.value = value;
  378. }
  379. });
  380. /**
  381. * Instantiate a textarea widget.
  382. *
  383. * @constructor Textarea
  384. * @memberof LuCI.ui
  385. * @augments LuCI.ui.AbstractElement
  386. *
  387. * @classdesc
  388. *
  389. * The `Textarea` class implements a multiline text area input field.
  390. *
  391. * UI widget instances are usually not supposed to be created by view code
  392. * directly, instead they're implicitely created by `LuCI.form` when
  393. * instantiating CBI forms.
  394. *
  395. * This class is automatically instantiated as part of `LuCI.ui`. To use it
  396. * in views, use `'require ui'` and refer to `ui.Textarea`. To import it in
  397. * external JavaScript, use `L.require("ui").then(...)` and access the
  398. * `Textarea` property of the class instance value.
  399. *
  400. * @param {string} [value=null]
  401. * The initial input value.
  402. *
  403. * @param {LuCI.ui.Textarea.InitOptions} [options]
  404. * Object describing the widget specific options to initialize the input.
  405. */
  406. var UITextarea = UIElement.extend(/** @lends LuCI.ui.Textarea.prototype */ {
  407. /**
  408. * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
  409. * the following properties are recognized:
  410. *
  411. * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
  412. * @memberof LuCI.ui.Textarea
  413. *
  414. * @property {boolean} [readonly=false]
  415. * Specifies whether the input widget should be rendered readonly.
  416. *
  417. * @property {string} [placeholder]
  418. * Specifies the HTML `placeholder` attribute which is displayed when the
  419. * corresponding `<textarea>` element is empty.
  420. *
  421. * @property {boolean} [monospace=false]
  422. * Specifies whether a monospace font should be forced for the textarea
  423. * contents.
  424. *
  425. * @property {number} [cols]
  426. * Specifies the HTML `cols` attribute to set on the corresponding
  427. * `<textarea>` element.
  428. *
  429. * @property {number} [rows]
  430. * Specifies the HTML `rows` attribute to set on the corresponding
  431. * `<textarea>` element.
  432. *
  433. * @property {boolean} [wrap=false]
  434. * Specifies whether the HTML `wrap` attribute should be set.
  435. */
  436. __init__: function(value, options) {
  437. this.value = value;
  438. this.options = Object.assign({
  439. optional: true,
  440. wrap: false,
  441. cols: null,
  442. rows: null
  443. }, options);
  444. },
  445. /** @override */
  446. render: function() {
  447. var style = !this.options.cols ? 'width:100%' : null,
  448. frameEl = E('div', { 'id': this.options.id, 'style': style }),
  449. value = (this.value != null) ? String(this.value) : '';
  450. frameEl.appendChild(E('textarea', {
  451. 'id': this.options.id ? 'widget.' + this.options.id : null,
  452. 'name': this.options.name,
  453. 'class': 'cbi-input-textarea',
  454. 'readonly': this.options.readonly ? '' : null,
  455. 'disabled': this.options.disabled ? '' : null,
  456. 'placeholder': this.options.placeholder,
  457. 'style': style,
  458. 'cols': this.options.cols,
  459. 'rows': this.options.rows,
  460. 'wrap': this.options.wrap ? '' : null
  461. }, [ value ]));
  462. if (this.options.monospace)
  463. frameEl.firstElementChild.style.fontFamily = 'monospace';
  464. return this.bind(frameEl);
  465. },
  466. /** @private */
  467. bind: function(frameEl) {
  468. var inputEl = frameEl.firstElementChild;
  469. this.node = frameEl;
  470. this.setUpdateEvents(inputEl, 'keyup', 'blur');
  471. this.setChangeEvents(inputEl, 'change');
  472. dom.bindClassInstance(frameEl, this);
  473. return frameEl;
  474. },
  475. /** @override */
  476. getValue: function() {
  477. return this.node.firstElementChild.value;
  478. },
  479. /** @override */
  480. setValue: function(value) {
  481. this.node.firstElementChild.value = value;
  482. }
  483. });
  484. /**
  485. * Instantiate a checkbox widget.
  486. *
  487. * @constructor Checkbox
  488. * @memberof LuCI.ui
  489. * @augments LuCI.ui.AbstractElement
  490. *
  491. * @classdesc
  492. *
  493. * The `Checkbox` class implements a simple checkbox input field.
  494. *
  495. * UI widget instances are usually not supposed to be created by view code
  496. * directly, instead they're implicitely created by `LuCI.form` when
  497. * instantiating CBI forms.
  498. *
  499. * This class is automatically instantiated as part of `LuCI.ui`. To use it
  500. * in views, use `'require ui'` and refer to `ui.Checkbox`. To import it in
  501. * external JavaScript, use `L.require("ui").then(...)` and access the
  502. * `Checkbox` property of the class instance value.
  503. *
  504. * @param {string} [value=null]
  505. * The initial input value.
  506. *
  507. * @param {LuCI.ui.Checkbox.InitOptions} [options]
  508. * Object describing the widget specific options to initialize the input.
  509. */
  510. var UICheckbox = UIElement.extend(/** @lends LuCI.ui.Checkbox.prototype */ {
  511. /**
  512. * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
  513. * the following properties are recognized:
  514. *
  515. * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
  516. * @memberof LuCI.ui.Checkbox
  517. *
  518. * @property {string} [value_enabled=1]
  519. * Specifies the value corresponding to a checked checkbox.
  520. *
  521. * @property {string} [value_disabled=0]
  522. * Specifies the value corresponding to an unchecked checkbox.
  523. *
  524. * @property {string} [hiddenname]
  525. * Specifies the HTML `name` attribute of the hidden input backing the
  526. * checkbox. This is a legacy property existing for compatibility reasons,
  527. * it is required for HTML based form submissions.
  528. */
  529. __init__: function(value, options) {
  530. this.value = value;
  531. this.options = Object.assign({
  532. value_enabled: '1',
  533. value_disabled: '0'
  534. }, options);
  535. },
  536. /** @override */
  537. render: function() {
  538. var id = 'cb%08x'.format(Math.random() * 0xffffffff);
  539. var frameEl = E('div', {
  540. 'id': this.options.id,
  541. 'class': 'cbi-checkbox'
  542. });
  543. if (this.options.hiddenname)
  544. frameEl.appendChild(E('input', {
  545. 'type': 'hidden',
  546. 'name': this.options.hiddenname,
  547. 'value': 1
  548. }));
  549. frameEl.appendChild(E('input', {
  550. 'id': id,
  551. 'name': this.options.name,
  552. 'type': 'checkbox',
  553. 'value': this.options.value_enabled,
  554. 'checked': (this.value == this.options.value_enabled) ? '' : null,
  555. 'disabled': this.options.disabled ? '' : null,
  556. 'data-widget-id': this.options.id ? 'widget.' + this.options.id : null
  557. }));
  558. frameEl.appendChild(E('label', { 'for': id }));
  559. if (this.options.tooltip != null) {
  560. var icon = "⚠️";
  561. if (this.options.tooltipicon != null)
  562. icon = this.options.tooltipicon;
  563. frameEl.appendChild(
  564. E('label', { 'class': 'cbi-tooltip-container' },[
  565. icon,
  566. E('div', { 'class': 'cbi-tooltip' },
  567. this.options.tooltip
  568. )
  569. ])
  570. );
  571. }
  572. return this.bind(frameEl);
  573. },
  574. /** @private */
  575. bind: function(frameEl) {
  576. this.node = frameEl;
  577. var input = frameEl.querySelector('input[type="checkbox"]');
  578. this.setUpdateEvents(input, 'click', 'blur');
  579. this.setChangeEvents(input, 'change');
  580. dom.bindClassInstance(frameEl, this);
  581. return frameEl;
  582. },
  583. /**
  584. * Test whether the checkbox is currently checked.
  585. *
  586. * @instance
  587. * @memberof LuCI.ui.Checkbox
  588. * @returns {boolean}
  589. * Returns `true` when the checkbox is currently checked, otherwise `false`.
  590. */
  591. isChecked: function() {
  592. return this.node.querySelector('input[type="checkbox"]').checked;
  593. },
  594. /** @override */
  595. getValue: function() {
  596. return this.isChecked()
  597. ? this.options.value_enabled
  598. : this.options.value_disabled;
  599. },
  600. /** @override */
  601. setValue: function(value) {
  602. this.node.querySelector('input[type="checkbox"]').checked = (value == this.options.value_enabled);
  603. }
  604. });
  605. /**
  606. * Instantiate a select dropdown or checkbox/radiobutton group.
  607. *
  608. * @constructor Select
  609. * @memberof LuCI.ui
  610. * @augments LuCI.ui.AbstractElement
  611. *
  612. * @classdesc
  613. *
  614. * The `Select` class implements either a traditional HTML `<select>` element
  615. * or a group of checkboxes or radio buttons, depending on whether multiple
  616. * values are enabled or not.
  617. *
  618. * UI widget instances are usually not supposed to be created by view code
  619. * directly, instead they're implicitely created by `LuCI.form` when
  620. * instantiating CBI forms.
  621. *
  622. * This class is automatically instantiated as part of `LuCI.ui`. To use it
  623. * in views, use `'require ui'` and refer to `ui.Select`. To import it in
  624. * external JavaScript, use `L.require("ui").then(...)` and access the
  625. * `Select` property of the class instance value.
  626. *
  627. * @param {string|string[]} [value=null]
  628. * The initial input value(s).
  629. *
  630. * @param {Object<string, string>} choices
  631. * Object containing the selectable choices of the widget. The object keys
  632. * serve as values for the different choices while the values are used as
  633. * choice labels.
  634. *
  635. * @param {LuCI.ui.Select.InitOptions} [options]
  636. * Object describing the widget specific options to initialize the inputs.
  637. */
  638. var UISelect = UIElement.extend(/** @lends LuCI.ui.Select.prototype */ {
  639. /**
  640. * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
  641. * the following properties are recognized:
  642. *
  643. * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
  644. * @memberof LuCI.ui.Select
  645. *
  646. * @property {boolean} [multiple=false]
  647. * Specifies whether multiple choice values may be selected.
  648. *
  649. * @property {string} [widget=select]
  650. * Specifies the kind of widget to render. May be either `select` or
  651. * `individual`. When set to `select` an HTML `<select>` element will be
  652. * used, otherwise a group of checkbox or radio button elements is created,
  653. * depending on the value of the `multiple` option.
  654. *
  655. * @property {string} [orientation=horizontal]
  656. * Specifies whether checkbox / radio button groups should be rendered
  657. * in a `horizontal` or `vertical` manner. Does not apply to the `select`
  658. * widget type.
  659. *
  660. * @property {boolean|string[]} [sort=false]
  661. * Specifies if and how to sort choice values. If set to `true`, the choice
  662. * values will be sorted alphabetically. If set to an array of strings, the
  663. * choice sort order is derived from the array.
  664. *
  665. * @property {number} [size]
  666. * Specifies the HTML `size` attribute to set on the `<select>` element.
  667. * Only applicable to the `select` widget type.
  668. *
  669. * @property {string} [placeholder=-- Please choose --]
  670. * Specifies a placeholder text which is displayed when no choice is
  671. * selected yet. Only applicable to the `select` widget type.
  672. */
  673. __init__: function(value, choices, options) {
  674. if (!L.isObject(choices))
  675. choices = {};
  676. if (!Array.isArray(value))
  677. value = (value != null && value != '') ? [ value ] : [];
  678. if (!options.multiple && value.length > 1)
  679. value.length = 1;
  680. this.values = value;
  681. this.choices = choices;
  682. this.options = Object.assign({
  683. multiple: false,
  684. widget: 'select',
  685. orientation: 'horizontal'
  686. }, options);
  687. if (this.choices.hasOwnProperty(''))
  688. this.options.optional = true;
  689. },
  690. /** @override */
  691. render: function() {
  692. var frameEl = E('div', { 'id': this.options.id }),
  693. keys = Object.keys(this.choices);
  694. if (this.options.sort === true)
  695. keys.sort();
  696. else if (Array.isArray(this.options.sort))
  697. keys = this.options.sort;
  698. if (this.options.widget != 'radio' && this.options.widget != 'checkbox') {
  699. frameEl.appendChild(E('select', {
  700. 'id': this.options.id ? 'widget.' + this.options.id : null,
  701. 'name': this.options.name,
  702. 'size': this.options.size,
  703. 'class': 'cbi-input-select',
  704. 'multiple': this.options.multiple ? '' : null,
  705. 'disabled': this.options.disabled ? '' : null
  706. }));
  707. if (this.options.optional)
  708. frameEl.lastChild.appendChild(E('option', {
  709. 'value': '',
  710. 'selected': (this.values.length == 0 || this.values[0] == '') ? '' : null
  711. }, [ this.choices[''] || this.options.placeholder || _('-- Please choose --') ]));
  712. for (var i = 0; i < keys.length; i++) {
  713. if (keys[i] == null || keys[i] == '')
  714. continue;
  715. frameEl.lastChild.appendChild(E('option', {
  716. 'value': keys[i],
  717. 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
  718. }, [ this.choices[keys[i]] || keys[i] ]));
  719. }
  720. }
  721. else {
  722. var brEl = (this.options.orientation === 'horizontal') ? document.createTextNode(' \xa0 ') : E('br');
  723. for (var i = 0; i < keys.length; i++) {
  724. frameEl.appendChild(E('span', {
  725. 'class': 'cbi-%s'.format(this.options.multiple ? 'checkbox' : 'radio')
  726. }, [
  727. E('input', {
  728. 'id': this.options.id ? 'widget.%s.%d'.format(this.options.id, i) : null,
  729. 'name': this.options.id || this.options.name,
  730. 'type': this.options.multiple ? 'checkbox' : 'radio',
  731. 'class': this.options.multiple ? 'cbi-input-checkbox' : 'cbi-input-radio',
  732. 'value': keys[i],
  733. 'checked': (this.values.indexOf(keys[i]) > -1) ? '' : null,
  734. 'disabled': this.options.disabled ? '' : null
  735. }),
  736. E('label', { 'for': this.options.id ? 'widget.%s.%d'.format(this.options.id, i) : null }),
  737. E('span', {
  738. 'click': function(ev) {
  739. ev.currentTarget.previousElementSibling.previousElementSibling.click();
  740. }
  741. }, [ this.choices[keys[i]] || keys[i] ])
  742. ]));
  743. frameEl.appendChild(brEl.cloneNode());
  744. }
  745. }
  746. return this.bind(frameEl);
  747. },
  748. /** @private */
  749. bind: function(frameEl) {
  750. this.node = frameEl;
  751. if (this.options.widget != 'radio' && this.options.widget != 'checkbox') {
  752. this.setUpdateEvents(frameEl.firstChild, 'change', 'click', 'blur');
  753. this.setChangeEvents(frameEl.firstChild, 'change');
  754. }
  755. else {
  756. var radioEls = frameEl.querySelectorAll('input[type="radio"]');
  757. for (var i = 0; i < radioEls.length; i++) {
  758. this.setUpdateEvents(radioEls[i], 'change', 'click', 'blur');
  759. this.setChangeEvents(radioEls[i], 'change', 'click', 'blur');
  760. }
  761. }
  762. dom.bindClassInstance(frameEl, this);
  763. return frameEl;
  764. },
  765. /** @override */
  766. getValue: function() {
  767. if (this.options.widget != 'radio' && this.options.widget != 'checkbox')
  768. return this.node.firstChild.value;
  769. var radioEls = this.node.querySelectorAll('input[type="radio"]');
  770. for (var i = 0; i < radioEls.length; i++)
  771. if (radioEls[i].checked)
  772. return radioEls[i].value;
  773. return null;
  774. },
  775. /** @override */
  776. setValue: function(value) {
  777. if (this.options.widget != 'radio' && this.options.widget != 'checkbox') {
  778. if (value == null)
  779. value = '';
  780. for (var i = 0; i < this.node.firstChild.options.length; i++)
  781. this.node.firstChild.options[i].selected = (this.node.firstChild.options[i].value == value);
  782. return;
  783. }
  784. var radioEls = frameEl.querySelectorAll('input[type="radio"]');
  785. for (var i = 0; i < radioEls.length; i++)
  786. radioEls[i].checked = (radioEls[i].value == value);
  787. }
  788. });
  789. /**
  790. * Instantiate a rich dropdown choice widget.
  791. *
  792. * @constructor Dropdown
  793. * @memberof LuCI.ui
  794. * @augments LuCI.ui.AbstractElement
  795. *
  796. * @classdesc
  797. *
  798. * The `Dropdown` class implements a rich, stylable dropdown menu which
  799. * supports non-text choice labels.
  800. *
  801. * UI widget instances are usually not supposed to be created by view code
  802. * directly, instead they're implicitely created by `LuCI.form` when
  803. * instantiating CBI forms.
  804. *
  805. * This class is automatically instantiated as part of `LuCI.ui`. To use it
  806. * in views, use `'require ui'` and refer to `ui.Dropdown`. To import it in
  807. * external JavaScript, use `L.require("ui").then(...)` and access the
  808. * `Dropdown` property of the class instance value.
  809. *
  810. * @param {string|string[]} [value=null]
  811. * The initial input value(s).
  812. *
  813. * @param {Object<string, *>} choices
  814. * Object containing the selectable choices of the widget. The object keys
  815. * serve as values for the different choices while the values are used as
  816. * choice labels.
  817. *
  818. * @param {LuCI.ui.Dropdown.InitOptions} [options]
  819. * Object describing the widget specific options to initialize the dropdown.
  820. */
  821. var UIDropdown = UIElement.extend(/** @lends LuCI.ui.Dropdown.prototype */ {
  822. /**
  823. * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
  824. * the following properties are recognized:
  825. *
  826. * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
  827. * @memberof LuCI.ui.Dropdown
  828. *
  829. * @property {boolean} [optional=true]
  830. * Specifies whether the dropdown selection is optional. In contrast to
  831. * other widgets, the `optional` constraint of dropdowns works differently;
  832. * instead of marking the widget invalid on empty values when set to `false`,
  833. * the user is not allowed to deselect all choices.
  834. *
  835. * For single value dropdowns that means that no empty "please select"
  836. * choice is offered and for multi value dropdowns, the last selected choice
  837. * may not be deselected without selecting another choice first.
  838. *
  839. * @property {boolean} [multiple]
  840. * Specifies whether multiple choice values may be selected. It defaults
  841. * to `true` when an array is passed as input value to the constructor.
  842. *
  843. * @property {boolean|string[]} [sort=false]
  844. * Specifies if and how to sort choice values. If set to `true`, the choice
  845. * values will be sorted alphabetically. If set to an array of strings, the
  846. * choice sort order is derived from the array.
  847. *
  848. * @property {string} [select_placeholder=-- Please choose --]
  849. * Specifies a placeholder text which is displayed when no choice is
  850. * selected yet.
  851. *
  852. * @property {string} [custom_placeholder=-- custom --]
  853. * Specifies a placeholder text which is displayed in the text input
  854. * field allowing to enter custom choice values. Only applicable if the
  855. * `create` option is set to `true`.
  856. *
  857. * @property {boolean} [create=false]
  858. * Specifies whether custom choices may be entered into the dropdown
  859. * widget.
  860. *
  861. * @property {string} [create_query=.create-item-input]
  862. * Specifies a CSS selector expression used to find the input element
  863. * which is used to enter custom choice values. This should not normally
  864. * be used except by widgets derived from the Dropdown class.
  865. *
  866. * @property {string} [create_template=script[type="item-template"]]
  867. * Specifies a CSS selector expression used to find an HTML element
  868. * serving as template for newly added custom choice values.
  869. *
  870. * Any `{{value}}` placeholder string within the template elements text
  871. * content will be replaced by the user supplied choice value, the
  872. * resulting string is parsed as HTML and appended to the end of the
  873. * choice list. The template markup may specify one HTML element with a
  874. * `data-label-placeholder` attribute which is replaced by a matching
  875. * label value from the `choices` object or with the user supplied value
  876. * itself in case `choices` contains no matching choice label.
  877. *
  878. * If the template element is not found or if no `create_template` selector
  879. * expression is specified, the default markup for newly created elements is
  880. * `<li data-value="{{value}}"><span data-label-placeholder="true" /></li>`.
  881. *
  882. * @property {string} [create_markup]
  883. * This property allows specifying the markup for custom choices directly
  884. * instead of referring to a template element through CSS selectors.
  885. *
  886. * Apart from that it works exactly like `create_template`.
  887. *
  888. * @property {number} [display_items=3]
  889. * Specifies the maximum amount of choice labels that should be shown in
  890. * collapsed dropdown state before further selected choices are cut off.
  891. *
  892. * Only applicable when `multiple` is `true`.
  893. *
  894. * @property {number} [dropdown_items=-1]
  895. * Specifies the maximum amount of choices that should be shown when the
  896. * dropdown is open. If the amount of available choices exceeds this number,
  897. * the dropdown area must be scrolled to reach further items.
  898. *
  899. * If set to `-1`, the dropdown menu will attempt to show all choice values
  900. * and only resort to scrolling if the amount of choices exceeds the available
  901. * screen space above and below the dropdown widget.
  902. *
  903. * @property {string} [placeholder]
  904. * This property serves as a shortcut to set both `select_placeholder` and
  905. * `custom_placeholder`. Either of these properties will fallback to
  906. * `placeholder` if not specified.
  907. *
  908. * @property {boolean} [readonly=false]
  909. * Specifies whether the custom choice input field should be rendered
  910. * readonly. Only applicable when `create` is `true`.
  911. *
  912. * @property {number} [maxlength]
  913. * Specifies the HTML `maxlength` attribute to set on the custom choice
  914. * `<input>` element. Note that this a legacy property that exists for
  915. * compatibility reasons. It is usually better to `maxlength(N)` validation
  916. * expression. Only applicable when `create` is `true`.
  917. */
  918. __init__: function(value, choices, options) {
  919. if (typeof(choices) != 'object')
  920. choices = {};
  921. if (!Array.isArray(value))
  922. this.values = (value != null && value != '') ? [ value ] : [];
  923. else
  924. this.values = value;
  925. this.choices = choices;
  926. this.options = Object.assign({
  927. sort: true,
  928. multiple: Array.isArray(value),
  929. optional: true,
  930. select_placeholder: _('-- Please choose --'),
  931. custom_placeholder: _('-- custom --'),
  932. display_items: 3,
  933. dropdown_items: -1,
  934. create: false,
  935. create_query: '.create-item-input',
  936. create_template: 'script[type="item-template"]'
  937. }, options);
  938. },
  939. /** @override */
  940. render: function() {
  941. var sb = E('div', {
  942. 'id': this.options.id,
  943. 'class': 'cbi-dropdown',
  944. 'multiple': this.options.multiple ? '' : null,
  945. 'optional': this.options.optional ? '' : null,
  946. 'disabled': this.options.disabled ? '' : null
  947. }, E('ul'));
  948. var keys = Object.keys(this.choices);
  949. if (this.options.sort === true)
  950. keys.sort();
  951. else if (Array.isArray(this.options.sort))
  952. keys = this.options.sort;
  953. if (this.options.create)
  954. for (var i = 0; i < this.values.length; i++)
  955. if (!this.choices.hasOwnProperty(this.values[i]))
  956. keys.push(this.values[i]);
  957. for (var i = 0; i < keys.length; i++) {
  958. var label = this.choices[keys[i]];
  959. if (dom.elem(label))
  960. label = label.cloneNode(true);
  961. sb.lastElementChild.appendChild(E('li', {
  962. 'data-value': keys[i],
  963. 'selected': (this.values.indexOf(keys[i]) > -1) ? '' : null
  964. }, [ label || keys[i] ]));
  965. }
  966. if (this.options.create) {
  967. var createEl = E('input', {
  968. 'type': 'text',
  969. 'class': 'create-item-input',
  970. 'readonly': this.options.readonly ? '' : null,
  971. 'maxlength': this.options.maxlength,
  972. 'placeholder': this.options.custom_placeholder || this.options.placeholder
  973. });
  974. if (this.options.datatype || this.options.validate)
  975. UI.prototype.addValidator(createEl, this.options.datatype || 'string',
  976. true, this.options.validate, 'blur', 'keyup');
  977. sb.lastElementChild.appendChild(E('li', { 'data-value': '-' }, createEl));
  978. }
  979. if (this.options.create_markup)
  980. sb.appendChild(E('script', { type: 'item-template' },
  981. this.options.create_markup));
  982. return this.bind(sb);
  983. },
  984. /** @private */
  985. bind: function(sb) {
  986. var o = this.options;
  987. o.multiple = sb.hasAttribute('multiple');
  988. o.optional = sb.hasAttribute('optional');
  989. o.placeholder = sb.getAttribute('placeholder') || o.placeholder;
  990. o.display_items = parseInt(sb.getAttribute('display-items') || o.display_items);
  991. o.dropdown_items = parseInt(sb.getAttribute('dropdown-items') || o.dropdown_items);
  992. o.create_query = sb.getAttribute('item-create') || o.create_query;
  993. o.create_template = sb.getAttribute('item-template') || o.create_template;
  994. var ul = sb.querySelector('ul'),
  995. more = sb.appendChild(E('span', { class: 'more', tabindex: -1 }, '···')),
  996. open = sb.appendChild(E('span', { class: 'open', tabindex: -1 }, '▾')),
  997. canary = sb.appendChild(E('div')),
  998. create = sb.querySelector(this.options.create_query),
  999. ndisplay = this.options.display_items,
  1000. n = 0;
  1001. if (this.options.multiple) {
  1002. var items = ul.querySelectorAll('li');
  1003. for (var i = 0; i < items.length; i++) {
  1004. this.transformItem(sb, items[i]);
  1005. if (items[i].hasAttribute('selected') && ndisplay-- > 0)
  1006. items[i].setAttribute('display', n++);
  1007. }
  1008. }
  1009. else {
  1010. if (this.options.optional && !ul.querySelector('li[data-value=""]')) {
  1011. var placeholder = E('li', { placeholder: '' },
  1012. this.options.select_placeholder || this.options.placeholder);
  1013. ul.firstChild
  1014. ? ul.insertBefore(placeholder, ul.firstChild)
  1015. : ul.appendChild(placeholder);
  1016. }
  1017. var items = ul.querySelectorAll('li'),
  1018. sel = sb.querySelectorAll('[selected]');
  1019. sel.forEach(function(s) {
  1020. s.removeAttribute('selected');
  1021. });
  1022. var s = sel[0] || items[0];
  1023. if (s) {
  1024. s.setAttribute('selected', '');
  1025. s.setAttribute('display', n++);
  1026. }
  1027. ndisplay--;
  1028. }
  1029. this.saveValues(sb, ul);
  1030. ul.setAttribute('tabindex', -1);
  1031. sb.setAttribute('tabindex', 0);
  1032. if (ndisplay < 0)
  1033. sb.setAttribute('more', '')
  1034. else
  1035. sb.removeAttribute('more');
  1036. if (ndisplay == this.options.display_items)
  1037. sb.setAttribute('empty', '')
  1038. else
  1039. sb.removeAttribute('empty');
  1040. dom.content(more, (ndisplay == this.options.display_items)
  1041. ? (this.options.select_placeholder || this.options.placeholder) : '···');
  1042. sb.addEventListener('click', this.handleClick.bind(this));
  1043. sb.addEventListener('keydown', this.handleKeydown.bind(this));
  1044. sb.addEventListener('cbi-dropdown-close', this.handleDropdownClose.bind(this));
  1045. sb.addEventListener('cbi-dropdown-select', this.handleDropdownSelect.bind(this));
  1046. if ('ontouchstart' in window) {
  1047. sb.addEventListener('touchstart', function(ev) { ev.stopPropagation(); });
  1048. window.addEventListener('touchstart', this.closeAllDropdowns);
  1049. }
  1050. else {
  1051. sb.addEventListener('mouseover', this.handleMouseover.bind(this));
  1052. sb.addEventListener('focus', this.handleFocus.bind(this));
  1053. canary.addEventListener('focus', this.handleCanaryFocus.bind(this));
  1054. window.addEventListener('mouseover', this.setFocus);
  1055. window.addEventListener('click', this.closeAllDropdowns);
  1056. }
  1057. if (create) {
  1058. create.addEventListener('keydown', this.handleCreateKeydown.bind(this));
  1059. create.addEventListener('focus', this.handleCreateFocus.bind(this));
  1060. create.addEventListener('blur', this.handleCreateBlur.bind(this));
  1061. var li = findParent(create, 'li');
  1062. li.setAttribute('unselectable', '');
  1063. li.addEventListener('click', this.handleCreateClick.bind(this));
  1064. }
  1065. this.node = sb;
  1066. this.setUpdateEvents(sb, 'cbi-dropdown-open', 'cbi-dropdown-close');
  1067. this.setChangeEvents(sb, 'cbi-dropdown-change', 'cbi-dropdown-close');
  1068. dom.bindClassInstance(sb, this);
  1069. return sb;
  1070. },
  1071. /** @private */
  1072. openDropdown: function(sb) {
  1073. var st = window.getComputedStyle(sb, null),
  1074. ul = sb.querySelector('ul'),
  1075. li = ul.querySelectorAll('li'),
  1076. fl = findParent(sb, '.cbi-value-field'),
  1077. sel = ul.querySelector('[selected]'),
  1078. rect = sb.getBoundingClientRect(),
  1079. items = Math.min(this.options.dropdown_items, li.length);
  1080. document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
  1081. s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
  1082. });
  1083. sb.setAttribute('open', '');
  1084. var pv = ul.cloneNode(true);
  1085. pv.classList.add('preview');
  1086. if (fl)
  1087. fl.classList.add('cbi-dropdown-open');
  1088. if ('ontouchstart' in window) {
  1089. var vpWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
  1090. vpHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
  1091. start = null;
  1092. ul.style.top = sb.offsetHeight + 'px';
  1093. ul.style.left = -rect.left + 'px';
  1094. ul.style.right = (rect.right - vpWidth) + 'px';
  1095. ul.style.maxHeight = (vpHeight * 0.5) + 'px';
  1096. ul.style.WebkitOverflowScrolling = 'touch';
  1097. var getScrollParent = function(element) {
  1098. var parent = element,
  1099. style = getComputedStyle(element),
  1100. excludeStaticParent = (style.position === 'absolute');
  1101. if (style.position === 'fixed')
  1102. return document.body;
  1103. while ((parent = parent.parentElement) != null) {
  1104. style = getComputedStyle(parent);
  1105. if (excludeStaticParent && style.position === 'static')
  1106. continue;
  1107. if (/(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX))
  1108. return parent;
  1109. }
  1110. return document.body;
  1111. }
  1112. var scrollParent = getScrollParent(sb),
  1113. scrollFrom = scrollParent.scrollTop,
  1114. scrollTo = scrollFrom + rect.top - vpHeight * 0.5;
  1115. var scrollStep = function(timestamp) {
  1116. if (!start) {
  1117. start = timestamp;
  1118. ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
  1119. }
  1120. var duration = Math.max(timestamp - start, 1);
  1121. if (duration < 100) {
  1122. scrollParent.scrollTop = scrollFrom + (scrollTo - scrollFrom) * (duration / 100);
  1123. window.requestAnimationFrame(scrollStep);
  1124. }
  1125. else {
  1126. scrollParent.scrollTop = scrollTo;
  1127. }
  1128. };
  1129. window.requestAnimationFrame(scrollStep);
  1130. }
  1131. else {
  1132. ul.style.maxHeight = '1px';
  1133. ul.style.top = ul.style.bottom = '';
  1134. window.requestAnimationFrame(function() {
  1135. var itemHeight = li[Math.max(0, li.length - 2)].getBoundingClientRect().height,
  1136. fullHeight = 0,
  1137. spaceAbove = rect.top,
  1138. spaceBelow = window.innerHeight - rect.height - rect.top;
  1139. for (var i = 0; i < (items == -1 ? li.length : items); i++)
  1140. fullHeight += li[i].getBoundingClientRect().height;
  1141. if (fullHeight <= spaceBelow) {
  1142. ul.style.top = rect.height + 'px';
  1143. ul.style.maxHeight = spaceBelow + 'px';
  1144. }
  1145. else if (fullHeight <= spaceAbove) {
  1146. ul.style.bottom = rect.height + 'px';
  1147. ul.style.maxHeight = spaceAbove + 'px';
  1148. }
  1149. else if (spaceBelow >= spaceAbove) {
  1150. ul.style.top = rect.height + 'px';
  1151. ul.style.maxHeight = (spaceBelow - (spaceBelow % itemHeight)) + 'px';
  1152. }
  1153. else {
  1154. ul.style.bottom = rect.height + 'px';
  1155. ul.style.maxHeight = (spaceAbove - (spaceAbove % itemHeight)) + 'px';
  1156. }
  1157. ul.scrollTop = sel ? Math.max(sel.offsetTop - sel.offsetHeight, 0) : 0;
  1158. });
  1159. }
  1160. var cboxes = ul.querySelectorAll('[selected] input[type="checkbox"]');
  1161. for (var i = 0; i < cboxes.length; i++) {
  1162. cboxes[i].checked = true;
  1163. cboxes[i].disabled = (cboxes.length == 1 && !this.options.optional);
  1164. };
  1165. ul.classList.add('dropdown');
  1166. sb.insertBefore(pv, ul.nextElementSibling);
  1167. li.forEach(function(l) {
  1168. l.setAttribute('tabindex', 0);
  1169. });
  1170. sb.lastElementChild.setAttribute('tabindex', 0);
  1171. this.setFocus(sb, sel || li[0], true);
  1172. },
  1173. /** @private */
  1174. closeDropdown: function(sb, no_focus) {
  1175. if (!sb.hasAttribute('open'))
  1176. return;
  1177. var pv = sb.querySelector('ul.preview'),
  1178. ul = sb.querySelector('ul.dropdown'),
  1179. li = ul.querySelectorAll('li'),
  1180. fl = findParent(sb, '.cbi-value-field');
  1181. li.forEach(function(l) { l.removeAttribute('tabindex'); });
  1182. sb.lastElementChild.removeAttribute('tabindex');
  1183. sb.removeChild(pv);
  1184. sb.removeAttribute('open');
  1185. sb.style.width = sb.style.height = '';
  1186. ul.classList.remove('dropdown');
  1187. ul.style.top = ul.style.bottom = ul.style.maxHeight = '';
  1188. if (fl)
  1189. fl.classList.remove('cbi-dropdown-open');
  1190. if (!no_focus)
  1191. this.setFocus(sb, sb);
  1192. this.saveValues(sb, ul);
  1193. },
  1194. /** @private */
  1195. toggleItem: function(sb, li, force_state) {
  1196. var ul = li.parentNode;
  1197. if (li.hasAttribute('unselectable'))
  1198. return;
  1199. if (this.options.multiple) {
  1200. var cbox = li.querySelector('input[type="checkbox"]'),
  1201. items = li.parentNode.querySelectorAll('li'),
  1202. label = sb.querySelector('ul.preview'),
  1203. sel = li.parentNode.querySelectorAll('[selected]').length,
  1204. more = sb.querySelector('.more'),
  1205. ndisplay = this.options.display_items,
  1206. n = 0;
  1207. if (li.hasAttribute('selected')) {
  1208. if (force_state !== true) {
  1209. if (sel > 1 || this.options.optional) {
  1210. li.removeAttribute('selected');
  1211. cbox.checked = cbox.disabled = false;
  1212. sel--;
  1213. }
  1214. else {
  1215. cbox.disabled = true;
  1216. }
  1217. }
  1218. }
  1219. else {
  1220. if (force_state !== false) {
  1221. li.setAttribute('selected', '');
  1222. cbox.checked = true;
  1223. cbox.disabled = false;
  1224. sel++;
  1225. }
  1226. }
  1227. while (label && label.firstElementChild)
  1228. label.removeChild(label.firstElementChild);
  1229. for (var i = 0; i < items.length; i++) {
  1230. items[i].removeAttribute('display');
  1231. if (items[i].hasAttribute('selected')) {
  1232. if (ndisplay-- > 0) {
  1233. items[i].setAttribute('display', n++);
  1234. if (label)
  1235. label.appendChild(items[i].cloneNode(true));
  1236. }
  1237. var c = items[i].querySelector('input[type="checkbox"]');
  1238. if (c)
  1239. c.disabled = (sel == 1 && !this.options.optional);
  1240. }
  1241. }
  1242. if (ndisplay < 0)
  1243. sb.setAttribute('more', '');
  1244. else
  1245. sb.removeAttribute('more');
  1246. if (ndisplay === this.options.display_items)
  1247. sb.setAttribute('empty', '');
  1248. else
  1249. sb.removeAttribute('empty');
  1250. dom.content(more, (ndisplay === this.options.display_items)
  1251. ? (this.options.select_placeholder || this.options.placeholder) : '···');
  1252. }
  1253. else {
  1254. var sel = li.parentNode.querySelector('[selected]');
  1255. if (sel) {
  1256. sel.removeAttribute('display');
  1257. sel.removeAttribute('selected');
  1258. }
  1259. li.setAttribute('display', 0);
  1260. li.setAttribute('selected', '');
  1261. this.closeDropdown(sb, true);
  1262. }
  1263. this.saveValues(sb, ul);
  1264. },
  1265. /** @private */
  1266. transformItem: function(sb, li) {
  1267. var cbox = E('form', {}, E('input', { type: 'checkbox', tabindex: -1, onclick: 'event.preventDefault()' })),
  1268. label = E('label');
  1269. while (li.firstChild)
  1270. label.appendChild(li.firstChild);
  1271. li.appendChild(cbox);
  1272. li.appendChild(label);
  1273. },
  1274. /** @private */
  1275. saveValues: function(sb, ul) {
  1276. var sel = ul.querySelectorAll('li[selected]'),
  1277. div = sb.lastElementChild,
  1278. name = this.options.name,
  1279. strval = '',
  1280. values = [];
  1281. while (div.lastElementChild)
  1282. div.removeChild(div.lastElementChild);
  1283. sel.forEach(function (s) {
  1284. if (s.hasAttribute('placeholder'))
  1285. return;
  1286. var v = {
  1287. text: s.innerText,
  1288. value: s.hasAttribute('data-value') ? s.getAttribute('data-value') : s.innerText,
  1289. element: s
  1290. };
  1291. div.appendChild(E('input', {
  1292. type: 'hidden',
  1293. name: name,
  1294. value: v.value
  1295. }));
  1296. values.push(v);
  1297. strval += strval.length ? ' ' + v.value : v.value;
  1298. });
  1299. var detail = {
  1300. instance: this,
  1301. element: sb
  1302. };
  1303. if (this.options.multiple)
  1304. detail.values = values;
  1305. else
  1306. detail.value = values.length ? values[0] : null;
  1307. sb.value = strval;
  1308. sb.dispatchEvent(new CustomEvent('cbi-dropdown-change', {
  1309. bubbles: true,
  1310. detail: detail
  1311. }));
  1312. },
  1313. /** @private */
  1314. setValues: function(sb, values) {
  1315. var ul = sb.querySelector('ul');
  1316. if (this.options.create) {
  1317. for (var value in values) {
  1318. this.createItems(sb, value);
  1319. if (!this.options.multiple)
  1320. break;
  1321. }
  1322. }
  1323. if (this.options.multiple) {
  1324. var lis = ul.querySelectorAll('li[data-value]');
  1325. for (var i = 0; i < lis.length; i++) {
  1326. var value = lis[i].getAttribute('data-value');
  1327. if (values === null || !(value in values))
  1328. this.toggleItem(sb, lis[i], false);
  1329. else
  1330. this.toggleItem(sb, lis[i], true);
  1331. }
  1332. }
  1333. else {
  1334. var ph = ul.querySelector('li[placeholder]');
  1335. if (ph)
  1336. this.toggleItem(sb, ph);
  1337. var lis = ul.querySelectorAll('li[data-value]');
  1338. for (var i = 0; i < lis.length; i++) {
  1339. var value = lis[i].getAttribute('data-value');
  1340. if (values !== null && (value in values))
  1341. this.toggleItem(sb, lis[i]);
  1342. }
  1343. }
  1344. },
  1345. /** @private */
  1346. setFocus: function(sb, elem, scroll) {
  1347. if (sb && sb.hasAttribute && sb.hasAttribute('locked-in'))
  1348. return;
  1349. if (sb.target && findParent(sb.target, 'ul.dropdown'))
  1350. return;
  1351. document.querySelectorAll('.focus').forEach(function(e) {
  1352. if (!matchesElem(e, 'input')) {
  1353. e.classList.remove('focus');
  1354. e.blur();
  1355. }
  1356. });
  1357. if (elem) {
  1358. elem.focus();
  1359. elem.classList.add('focus');
  1360. if (scroll)
  1361. elem.parentNode.scrollTop = elem.offsetTop - elem.parentNode.offsetTop;
  1362. }
  1363. },
  1364. /** @private */
  1365. createChoiceElement: function(sb, value, label) {
  1366. var tpl = sb.querySelector(this.options.create_template),
  1367. markup = null;
  1368. if (tpl)
  1369. markup = (tpl.textContent || tpl.innerHTML || tpl.firstChild.data).replace(/^<!--|-->$/, '').trim();
  1370. else
  1371. markup = '<li data-value="{{value}}"><span data-label-placeholder="true" /></li>';
  1372. var new_item = E(markup.replace(/{{value}}/g, '%h'.format(value))),
  1373. placeholder = new_item.querySelector('[data-label-placeholder]');
  1374. if (placeholder) {
  1375. var content = E('span', {}, label || this.choices[value] || [ value ]);
  1376. while (content.firstChild)
  1377. placeholder.parentNode.insertBefore(content.firstChild, placeholder);
  1378. placeholder.parentNode.removeChild(placeholder);
  1379. }
  1380. if (this.options.multiple)
  1381. this.transformItem(sb, new_item);
  1382. return new_item;
  1383. },
  1384. /** @private */
  1385. createItems: function(sb, value) {
  1386. var sbox = this,
  1387. val = (value || '').trim(),
  1388. ul = sb.querySelector('ul');
  1389. if (!sbox.options.multiple)
  1390. val = val.length ? [ val ] : [];
  1391. else
  1392. val = val.length ? val.split(/\s+/) : [];
  1393. val.forEach(function(item) {
  1394. var new_item = null;
  1395. ul.childNodes.forEach(function(li) {
  1396. if (li.getAttribute && li.getAttribute('data-value') === item)
  1397. new_item = li;
  1398. });
  1399. if (!new_item) {
  1400. new_item = sbox.createChoiceElement(sb, item);
  1401. if (!sbox.options.multiple) {
  1402. var old = ul.querySelector('li[created]');
  1403. if (old)
  1404. ul.removeChild(old);
  1405. new_item.setAttribute('created', '');
  1406. }
  1407. new_item = ul.insertBefore(new_item, ul.lastElementChild);
  1408. }
  1409. sbox.toggleItem(sb, new_item, true);
  1410. sbox.setFocus(sb, new_item, true);
  1411. });
  1412. },
  1413. /**
  1414. * Remove all existing choices from the dropdown menu.
  1415. *
  1416. * This function removes all preexisting dropdown choices from the widget,
  1417. * keeping only choices currently being selected unless `reset_values` is
  1418. * given, in which case all choices and deselected and removed.
  1419. *
  1420. * @instance
  1421. * @memberof LuCI.ui.Dropdown
  1422. * @param {boolean} [reset_value=false]
  1423. * If set to `true`, deselect and remove selected choices as well instead
  1424. * of keeping them.
  1425. */
  1426. clearChoices: function(reset_value) {
  1427. var ul = this.node.querySelector('ul'),
  1428. lis = ul ? ul.querySelectorAll('li[data-value]') : [],
  1429. len = lis.length - (this.options.create ? 1 : 0),
  1430. val = reset_value ? null : this.getValue();
  1431. for (var i = 0; i < len; i++) {
  1432. var lival = lis[i].getAttribute('data-value');
  1433. if (val == null ||
  1434. (!this.options.multiple && val != lival) ||
  1435. (this.options.multiple && val.indexOf(lival) == -1))
  1436. ul.removeChild(lis[i]);
  1437. }
  1438. if (reset_value)
  1439. this.setValues(this.node, {});
  1440. },
  1441. /**
  1442. * Add new choices to the dropdown menu.
  1443. *
  1444. * This function adds further choices to an existing dropdown menu,
  1445. * ignoring choice values which are already present.
  1446. *
  1447. * @instance
  1448. * @memberof LuCI.ui.Dropdown
  1449. * @param {string[]} values
  1450. * The choice values to add to the dropdown widget.
  1451. *
  1452. * @param {Object<string, *>} labels
  1453. * The choice label values to use when adding dropdown choices. If no
  1454. * label is found for a particular choice value, the value itself is used
  1455. * as label text. Choice labels may be any valid value accepted by
  1456. * {@link LuCI.dom#content}.
  1457. */
  1458. addChoices: function(values, labels) {
  1459. var sb = this.node,
  1460. ul = sb.querySelector('ul'),
  1461. lis = ul ? ul.querySelectorAll('li[data-value]') : [];
  1462. if (!Array.isArray(values))
  1463. values = L.toArray(values);
  1464. if (!L.isObject(labels))
  1465. labels = {};
  1466. for (var i = 0; i < values.length; i++) {
  1467. var found = false;
  1468. for (var j = 0; j < lis.length; j++) {
  1469. if (lis[j].getAttribute('data-value') === values[i]) {
  1470. found = true;
  1471. break;
  1472. }
  1473. }
  1474. if (found)
  1475. continue;
  1476. ul.insertBefore(
  1477. this.createChoiceElement(sb, values[i], labels[values[i]]),
  1478. ul.lastElementChild);
  1479. }
  1480. },
  1481. /**
  1482. * Close all open dropdown widgets in the current document.
  1483. */
  1484. closeAllDropdowns: function() {
  1485. document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
  1486. s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
  1487. });
  1488. },
  1489. /** @private */
  1490. handleClick: function(ev) {
  1491. var sb = ev.currentTarget;
  1492. if (!sb.hasAttribute('open')) {
  1493. if (!matchesElem(ev.target, 'input'))
  1494. this.openDropdown(sb);
  1495. }
  1496. else {
  1497. var li = findParent(ev.target, 'li');
  1498. if (li && li.parentNode.classList.contains('dropdown'))
  1499. this.toggleItem(sb, li);
  1500. else if (li && li.parentNode.classList.contains('preview'))
  1501. this.closeDropdown(sb);
  1502. else if (matchesElem(ev.target, 'span.open, span.more'))
  1503. this.closeDropdown(sb);
  1504. }
  1505. ev.preventDefault();
  1506. ev.stopPropagation();
  1507. },
  1508. /** @private */
  1509. handleKeydown: function(ev) {
  1510. var sb = ev.currentTarget;
  1511. if (matchesElem(ev.target, 'input'))
  1512. return;
  1513. if (!sb.hasAttribute('open')) {
  1514. switch (ev.keyCode) {
  1515. case 37:
  1516. case 38:
  1517. case 39:
  1518. case 40:
  1519. this.openDropdown(sb);
  1520. ev.preventDefault();
  1521. }
  1522. }
  1523. else {
  1524. var active = findParent(document.activeElement, 'li');
  1525. switch (ev.keyCode) {
  1526. case 27:
  1527. this.closeDropdown(sb);
  1528. break;
  1529. case 13:
  1530. if (active) {
  1531. if (!active.hasAttribute('selected'))
  1532. this.toggleItem(sb, active);
  1533. this.closeDropdown(sb);
  1534. ev.preventDefault();
  1535. }
  1536. break;
  1537. case 32:
  1538. if (active) {
  1539. this.toggleItem(sb, active);
  1540. ev.preventDefault();
  1541. }
  1542. break;
  1543. case 38:
  1544. if (active && active.previousElementSibling) {
  1545. this.setFocus(sb, active.previousElementSibling);
  1546. ev.preventDefault();
  1547. }
  1548. break;
  1549. case 40:
  1550. if (active && active.nextElementSibling) {
  1551. this.setFocus(sb, active.nextElementSibling);
  1552. ev.preventDefault();
  1553. }
  1554. break;
  1555. }
  1556. }
  1557. },
  1558. /** @private */
  1559. handleDropdownClose: function(ev) {
  1560. var sb = ev.currentTarget;
  1561. this.closeDropdown(sb, true);
  1562. },
  1563. /** @private */
  1564. handleDropdownSelect: function(ev) {
  1565. var sb = ev.currentTarget,
  1566. li = findParent(ev.target, 'li');
  1567. if (!li)
  1568. return;
  1569. this.toggleItem(sb, li);
  1570. this.closeDropdown(sb, true);
  1571. },
  1572. /** @private */
  1573. handleMouseover: function(ev) {
  1574. var sb = ev.currentTarget;
  1575. if (!sb.hasAttribute('open'))
  1576. return;
  1577. var li = findParent(ev.target, 'li');
  1578. if (li && li.parentNode.classList.contains('dropdown'))
  1579. this.setFocus(sb, li);
  1580. },
  1581. /** @private */
  1582. handleFocus: function(ev) {
  1583. var sb = ev.currentTarget;
  1584. document.querySelectorAll('.cbi-dropdown[open]').forEach(function(s) {
  1585. if (s !== sb || sb.hasAttribute('open'))
  1586. s.dispatchEvent(new CustomEvent('cbi-dropdown-close', {}));
  1587. });
  1588. },
  1589. /** @private */
  1590. handleCanaryFocus: function(ev) {
  1591. this.closeDropdown(ev.currentTarget.parentNode);
  1592. },
  1593. /** @private */
  1594. handleCreateKeydown: function(ev) {
  1595. var input = ev.currentTarget,
  1596. sb = findParent(input, '.cbi-dropdown');
  1597. switch (ev.keyCode) {
  1598. case 13:
  1599. ev.preventDefault();
  1600. if (input.classList.contains('cbi-input-invalid'))
  1601. return;
  1602. this.createItems(sb, input.value);
  1603. input.value = '';
  1604. input.blur();
  1605. break;
  1606. }
  1607. },
  1608. /** @private */
  1609. handleCreateFocus: function(ev) {
  1610. var input = ev.currentTarget,
  1611. cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
  1612. sb = findParent(input, '.cbi-dropdown');
  1613. if (cbox)
  1614. cbox.checked = true;
  1615. sb.setAttribute('locked-in', '');
  1616. },
  1617. /** @private */
  1618. handleCreateBlur: function(ev) {
  1619. var input = ev.currentTarget,
  1620. cbox = findParent(input, 'li').querySelector('input[type="checkbox"]'),
  1621. sb = findParent(input, '.cbi-dropdown');
  1622. if (cbox)
  1623. cbox.checked = false;
  1624. sb.removeAttribute('locked-in');
  1625. },
  1626. /** @private */
  1627. handleCreateClick: function(ev) {
  1628. ev.currentTarget.querySelector(this.options.create_query).focus();
  1629. },
  1630. /** @override */
  1631. setValue: function(values) {
  1632. if (this.options.multiple) {
  1633. if (!Array.isArray(values))
  1634. values = (values != null && values != '') ? [ values ] : [];
  1635. var v = {};
  1636. for (var i = 0; i < values.length; i++)
  1637. v[values[i]] = true;
  1638. this.setValues(this.node, v);
  1639. }
  1640. else {
  1641. var v = {};
  1642. if (values != null) {
  1643. if (Array.isArray(values))
  1644. v[values[0]] = true;
  1645. else
  1646. v[values] = true;
  1647. }
  1648. this.setValues(this.node, v);
  1649. }
  1650. },
  1651. /** @override */
  1652. getValue: function() {
  1653. var div = this.node.lastElementChild,
  1654. h = div.querySelectorAll('input[type="hidden"]'),
  1655. v = [];
  1656. for (var i = 0; i < h.length; i++)
  1657. v.push(h[i].value);
  1658. return this.options.multiple ? v : v[0];
  1659. }
  1660. });
  1661. /**
  1662. * Instantiate a rich dropdown choice widget allowing custom values.
  1663. *
  1664. * @constructor Combobox
  1665. * @memberof LuCI.ui
  1666. * @augments LuCI.ui.Dropdown
  1667. *
  1668. * @classdesc
  1669. *
  1670. * The `Combobox` class implements a rich, stylable dropdown menu which allows
  1671. * to enter custom values. Historically, comboboxes used to be a dedicated
  1672. * widget type in LuCI but nowadays they are direct aliases of dropdown widgets
  1673. * with a set of enforced default properties for easier instantiation.
  1674. *
  1675. * UI widget instances are usually not supposed to be created by view code
  1676. * directly, instead they're implicitely created by `LuCI.form` when
  1677. * instantiating CBI forms.
  1678. *
  1679. * This class is automatically instantiated as part of `LuCI.ui`. To use it
  1680. * in views, use `'require ui'` and refer to `ui.Combobox`. To import it in
  1681. * external JavaScript, use `L.require("ui").then(...)` and access the
  1682. * `Combobox` property of the class instance value.
  1683. *
  1684. * @param {string|string[]} [value=null]
  1685. * The initial input value(s).
  1686. *
  1687. * @param {Object<string, *>} choices
  1688. * Object containing the selectable choices of the widget. The object keys
  1689. * serve as values for the different choices while the values are used as
  1690. * choice labels.
  1691. *
  1692. * @param {LuCI.ui.Combobox.InitOptions} [options]
  1693. * Object describing the widget specific options to initialize the dropdown.
  1694. */
  1695. var UICombobox = UIDropdown.extend(/** @lends LuCI.ui.Combobox.prototype */ {
  1696. /**
  1697. * Comboboxes support the same properties as
  1698. * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
  1699. * specific values for the following properties:
  1700. *
  1701. * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
  1702. * @memberof LuCI.ui.Combobox
  1703. *
  1704. * @property {boolean} multiple=false
  1705. * Since Comboboxes never allow selecting multiple values, this property
  1706. * is forcibly set to `false`.
  1707. *
  1708. * @property {boolean} create=true
  1709. * Since Comboboxes always allow custom choice values, this property is
  1710. * forcibly set to `true`.
  1711. *
  1712. * @property {boolean} optional=true
  1713. * Since Comboboxes are always optional, this property is forcibly set to
  1714. * `true`.
  1715. */
  1716. __init__: function(value, choices, options) {
  1717. this.super('__init__', [ value, choices, Object.assign({
  1718. select_placeholder: _('-- Please choose --'),
  1719. custom_placeholder: _('-- custom --'),
  1720. dropdown_items: -1,
  1721. sort: true
  1722. }, options, {
  1723. multiple: false,
  1724. create: true,
  1725. optional: true
  1726. }) ]);
  1727. }
  1728. });
  1729. /**
  1730. * Instantiate a combo button widget offering multiple action choices.
  1731. *
  1732. * @constructor ComboButton
  1733. * @memberof LuCI.ui
  1734. * @augments LuCI.ui.Dropdown
  1735. *
  1736. * @classdesc
  1737. *
  1738. * The `ComboButton` class implements a button element which can be expanded
  1739. * into a dropdown to chose from a set of different action choices.
  1740. *
  1741. * UI widget instances are usually not supposed to be created by view code
  1742. * directly, instead they're implicitely created by `LuCI.form` when
  1743. * instantiating CBI forms.
  1744. *
  1745. * This class is automatically instantiated as part of `LuCI.ui`. To use it
  1746. * in views, use `'require ui'` and refer to `ui.ComboButton`. To import it in
  1747. * external JavaScript, use `L.require("ui").then(...)` and access the
  1748. * `ComboButton` property of the class instance value.
  1749. *
  1750. * @param {string|string[]} [value=null]
  1751. * The initial input value(s).
  1752. *
  1753. * @param {Object<string, *>} choices
  1754. * Object containing the selectable choices of the widget. The object keys
  1755. * serve as values for the different choices while the values are used as
  1756. * choice labels.
  1757. *
  1758. * @param {LuCI.ui.ComboButton.InitOptions} [options]
  1759. * Object describing the widget specific options to initialize the button.
  1760. */
  1761. var UIComboButton = UIDropdown.extend(/** @lends LuCI.ui.ComboButton.prototype */ {
  1762. /**
  1763. * ComboButtons support the same properties as
  1764. * [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions} but enforce
  1765. * specific values for some properties and add aditional button specific
  1766. * properties.
  1767. *
  1768. * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
  1769. * @memberof LuCI.ui.ComboButton
  1770. *
  1771. * @property {boolean} multiple=false
  1772. * Since ComboButtons never allow selecting multiple actions, this property
  1773. * is forcibly set to `false`.
  1774. *
  1775. * @property {boolean} create=false
  1776. * Since ComboButtons never allow creating custom choices, this property
  1777. * is forcibly set to `false`.
  1778. *
  1779. * @property {boolean} optional=false
  1780. * Since ComboButtons must always select one action, this property is
  1781. * forcibly set to `false`.
  1782. *
  1783. * @property {Object<string, string>} [classes]
  1784. * Specifies a mapping of choice values to CSS class names. If an action
  1785. * choice is selected by the user and if a corresponding entry exists in
  1786. * the `classes` object, the class names corresponding to the selected
  1787. * value are set on the button element.
  1788. *
  1789. * This is useful to apply different button styles, such as colors, to the
  1790. * combined button depending on the selected action.
  1791. *
  1792. * @property {function} [click]
  1793. * Specifies a handler function to invoke when the user clicks the button.
  1794. * This function will be called with the button DOM node as `this` context
  1795. * and receive the DOM click event as first as well as the selected action
  1796. * choice value as second argument.
  1797. */
  1798. __init__: function(value, choices, options) {
  1799. this.super('__init__', [ value, choices, Object.assign({
  1800. sort: true
  1801. }, options, {
  1802. multiple: false,
  1803. create: false,
  1804. optional: false
  1805. }) ]);
  1806. },
  1807. /** @override */
  1808. render: function(/* ... */) {
  1809. var node = UIDropdown.prototype.render.apply(this, arguments),
  1810. val = this.getValue();
  1811. if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
  1812. node.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
  1813. return node;
  1814. },
  1815. /** @private */
  1816. handleClick: function(ev) {
  1817. var sb = ev.currentTarget,
  1818. t = ev.target;
  1819. if (sb.hasAttribute('open') || dom.matches(t, '.cbi-dropdown > span.open'))
  1820. return UIDropdown.prototype.handleClick.apply(this, arguments);
  1821. if (this.options.click)
  1822. return this.options.click.call(sb, ev, this.getValue());
  1823. },
  1824. /** @private */
  1825. toggleItem: function(sb /*, ... */) {
  1826. var rv = UIDropdown.prototype.toggleItem.apply(this, arguments),
  1827. val = this.getValue();
  1828. if (L.isObject(this.options.classes) && this.options.classes.hasOwnProperty(val))
  1829. sb.setAttribute('class', 'cbi-dropdown ' + this.options.classes[val]);
  1830. else
  1831. sb.setAttribute('class', 'cbi-dropdown');
  1832. return rv;
  1833. }
  1834. });
  1835. /**
  1836. * Instantiate a dynamic list widget.
  1837. *
  1838. * @constructor DynamicList
  1839. * @memberof LuCI.ui
  1840. * @augments LuCI.ui.AbstractElement
  1841. *
  1842. * @classdesc
  1843. *
  1844. * The `DynamicList` class implements a widget which allows the user to specify
  1845. * an arbitrary amount of input values, either from free formed text input or
  1846. * from a set of predefined choices.
  1847. *
  1848. * UI widget instances are usually not supposed to be created by view code
  1849. * directly, instead they're implicitely created by `LuCI.form` when
  1850. * instantiating CBI forms.
  1851. *
  1852. * This class is automatically instantiated as part of `LuCI.ui`. To use it
  1853. * in views, use `'require ui'` and refer to `ui.DynamicList`. To import it in
  1854. * external JavaScript, use `L.require("ui").then(...)` and access the
  1855. * `DynamicList` property of the class instance value.
  1856. *
  1857. * @param {string|string[]} [value=null]
  1858. * The initial input value(s).
  1859. *
  1860. * @param {Object<string, *>} [choices]
  1861. * Object containing the selectable choices of the widget. The object keys
  1862. * serve as values for the different choices while the values are used as
  1863. * choice labels. If omitted, no default choices are presented to the user,
  1864. * instead a plain text input field is rendered allowing the user to add
  1865. * arbitrary values to the dynamic list.
  1866. *
  1867. * @param {LuCI.ui.DynamicList.InitOptions} [options]
  1868. * Object describing the widget specific options to initialize the dynamic list.
  1869. */
  1870. var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */ {
  1871. /**
  1872. * In case choices are passed to the dynamic list contructor, the widget
  1873. * supports the same properties as [Dropdown.InitOptions]{@link LuCI.ui.Dropdown.InitOptions}
  1874. * but enforces specific values for some dropdown properties.
  1875. *
  1876. * @typedef {LuCI.ui.Dropdown.InitOptions} InitOptions
  1877. * @memberof LuCI.ui.DynamicList
  1878. *
  1879. * @property {boolean} multiple=false
  1880. * Since dynamic lists never allow selecting multiple choices when adding
  1881. * another list item, this property is forcibly set to `false`.
  1882. *
  1883. * @property {boolean} optional=true
  1884. * Since dynamic lists use an embedded dropdown to present a list of
  1885. * predefined choice values, the dropdown must be made optional to allow
  1886. * it to remain unselected.
  1887. */
  1888. __init__: function(values, choices, options) {
  1889. if (!Array.isArray(values))
  1890. values = (values != null && values != '') ? [ values ] : [];
  1891. if (typeof(choices) != 'object')
  1892. choices = null;
  1893. this.values = values;
  1894. this.choices = choices;
  1895. this.options = Object.assign({}, options, {
  1896. multiple: false,
  1897. optional: true
  1898. });
  1899. },
  1900. /** @override */
  1901. render: function() {
  1902. var dl = E('div', {
  1903. 'id': this.options.id,
  1904. 'class': 'cbi-dynlist',
  1905. 'disabled': this.options.disabled ? '' : null
  1906. }, E('div', { 'class': 'add-item' }));
  1907. if (this.choices) {
  1908. if (this.options.placeholder != null)
  1909. this.options.select_placeholder = this.options.placeholder;
  1910. var cbox = new UICombobox(null, this.choices, this.options);
  1911. dl.lastElementChild.appendChild(cbox.render());
  1912. }
  1913. else {
  1914. var inputEl = E('input', {
  1915. 'id': this.options.id ? 'widget.' + this.options.id : null,
  1916. 'type': 'text',
  1917. 'class': 'cbi-input-text',
  1918. 'placeholder': this.options.placeholder,
  1919. 'disabled': this.options.disabled ? '' : null
  1920. });
  1921. dl.lastElementChild.appendChild(inputEl);
  1922. dl.lastElementChild.appendChild(E('div', { 'class': 'btn cbi-button cbi-button-add' }, '+'));
  1923. if (this.options.datatype || this.options.validate)
  1924. UI.prototype.addValidator(inputEl, this.options.datatype || 'string',
  1925. true, this.options.validate, 'blur', 'keyup');
  1926. }
  1927. for (var i = 0; i < this.values.length; i++) {
  1928. var label = this.choices ? this.choices[this.values[i]] : null;
  1929. if (dom.elem(label))
  1930. label = label.cloneNode(true);
  1931. this.addItem(dl, this.values[i], label);
  1932. }
  1933. return this.bind(dl);
  1934. },
  1935. /** @private */
  1936. bind: function(dl) {
  1937. dl.addEventListener('click', L.bind(this.handleClick, this));
  1938. dl.addEventListener('keydown', L.bind(this.handleKeydown, this));
  1939. dl.addEventListener('cbi-dropdown-change', L.bind(this.handleDropdownChange, this));
  1940. this.node = dl;
  1941. this.setUpdateEvents(dl, 'cbi-dynlist-change');
  1942. this.setChangeEvents(dl, 'cbi-dynlist-change');
  1943. dom.bindClassInstance(dl, this);
  1944. return dl;
  1945. },
  1946. /** @private */
  1947. addItem: function(dl, value, text, flash) {
  1948. var exists = false,
  1949. new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
  1950. E('span', {}, [ text || value ]),
  1951. E('input', {
  1952. 'type': 'hidden',
  1953. 'name': this.options.name,
  1954. 'value': value })]);
  1955. dl.querySelectorAll('.item').forEach(function(item) {
  1956. if (exists)
  1957. return;
  1958. var hidden = item.querySelector('input[type="hidden"]');
  1959. if (hidden && hidden.parentNode !== item)
  1960. hidden = null;
  1961. if (hidden && hidden.value === value)
  1962. exists = true;
  1963. });
  1964. if (!exists) {
  1965. var ai = dl.querySelector('.add-item');
  1966. ai.parentNode.insertBefore(new_item, ai);
  1967. }
  1968. dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
  1969. bubbles: true,
  1970. detail: {
  1971. instance: this,
  1972. element: dl,
  1973. value: value,
  1974. add: true
  1975. }
  1976. }));
  1977. },
  1978. /** @private */
  1979. removeItem: function(dl, item) {
  1980. var value = item.querySelector('input[type="hidden"]').value;
  1981. var sb = dl.querySelector('.cbi-dropdown');
  1982. if (sb)
  1983. sb.querySelectorAll('ul > li').forEach(function(li) {
  1984. if (li.getAttribute('data-value') === value) {
  1985. if (li.hasAttribute('dynlistcustom'))
  1986. li.parentNode.removeChild(li);
  1987. else
  1988. li.removeAttribute('unselectable');
  1989. }
  1990. });
  1991. item.parentNode.removeChild(item);
  1992. dl.dispatchEvent(new CustomEvent('cbi-dynlist-change', {
  1993. bubbles: true,
  1994. detail: {
  1995. instance: this,
  1996. element: dl,
  1997. value: value,
  1998. remove: true
  1999. }
  2000. }));
  2001. },
  2002. /** @private */
  2003. handleClick: function(ev) {
  2004. var dl = ev.currentTarget,
  2005. item = findParent(ev.target, '.item');
  2006. if (this.options.disabled)
  2007. return;
  2008. if (item) {
  2009. this.removeItem(dl, item);
  2010. }
  2011. else if (matchesElem(ev.target, '.cbi-button-add')) {
  2012. var input = ev.target.previousElementSibling;
  2013. if (input.value.length && !input.classList.contains('cbi-input-invalid')) {
  2014. this.addItem(dl, input.value, null, true);
  2015. input.value = '';
  2016. }
  2017. }
  2018. },
  2019. /** @private */
  2020. handleDropdownChange: function(ev) {
  2021. var dl = ev.currentTarget,
  2022. sbIn = ev.detail.instance,
  2023. sbEl = ev.detail.element,
  2024. sbVal = ev.detail.value;
  2025. if (sbVal === null)
  2026. return;
  2027. sbIn.setValues(sbEl, null);
  2028. sbVal.element.setAttribute('unselectable', '');
  2029. if (sbVal.element.hasAttribute('created')) {
  2030. sbVal.element.removeAttribute('created');
  2031. sbVal.element.setAttribute('dynlistcustom', '');
  2032. }
  2033. var label = sbVal.text;
  2034. if (sbVal.element) {
  2035. label = E([]);
  2036. for (var i = 0; i < sbVal.element.childNodes.length; i++)
  2037. label.appendChild(sbVal.element.childNodes[i].cloneNode(true));
  2038. }
  2039. this.addItem(dl, sbVal.value, label, true);
  2040. },
  2041. /** @private */
  2042. handleKeydown: function(ev) {
  2043. var dl = ev.currentTarget,
  2044. item = findParent(ev.target, '.item');
  2045. if (item) {
  2046. switch (ev.keyCode) {
  2047. case 8: /* backspace */
  2048. if (item.previousElementSibling)
  2049. item.previousElementSibling.focus();
  2050. this.removeItem(dl, item);
  2051. break;
  2052. case 46: /* delete */
  2053. if (item.nextElementSibling) {
  2054. if (item.nextElementSibling.classList.contains('item'))
  2055. item.nextElementSibling.focus();
  2056. else
  2057. item.nextElementSibling.firstElementChild.focus();
  2058. }
  2059. this.removeItem(dl, item);
  2060. break;
  2061. }
  2062. }
  2063. else if (matchesElem(ev.target, '.cbi-input-text')) {
  2064. switch (ev.keyCode) {
  2065. case 13: /* enter */
  2066. if (ev.target.value.length && !ev.target.classList.contains('cbi-input-invalid')) {
  2067. this.addItem(dl, ev.target.value, null, true);
  2068. ev.target.value = '';
  2069. ev.target.blur();
  2070. ev.target.focus();
  2071. }
  2072. ev.preventDefault();
  2073. break;
  2074. }
  2075. }
  2076. },
  2077. /** @override */
  2078. getValue: function() {
  2079. var items = this.node.querySelectorAll('.item > input[type="hidden"]'),
  2080. input = this.node.querySelector('.add-item > input[type="text"]'),
  2081. v = [];
  2082. for (var i = 0; i < items.length; i++)
  2083. v.push(items[i].value);
  2084. if (input && input.value != null && input.value.match(/\S/) &&
  2085. input.classList.contains('cbi-input-invalid') == false &&
  2086. v.filter(function(s) { return s == input.value }).length == 0)
  2087. v.push(input.value);
  2088. return v;
  2089. },
  2090. /** @override */
  2091. setValue: function(values) {
  2092. if (!Array.isArray(values))
  2093. values = (values != null && values != '') ? [ values ] : [];
  2094. var items = this.node.querySelectorAll('.item');
  2095. for (var i = 0; i < items.length; i++)
  2096. if (items[i].parentNode === this.node)
  2097. this.removeItem(this.node, items[i]);
  2098. for (var i = 0; i < values.length; i++)
  2099. this.addItem(this.node, values[i],
  2100. this.choices ? this.choices[values[i]] : null);
  2101. },
  2102. /**
  2103. * Add new suggested choices to the dynamic list.
  2104. *
  2105. * This function adds further choices to an existing dynamic list,
  2106. * ignoring choice values which are already present.
  2107. *
  2108. * @instance
  2109. * @memberof LuCI.ui.DynamicList
  2110. * @param {string[]} values
  2111. * The choice values to add to the dynamic lists suggestion dropdown.
  2112. *
  2113. * @param {Object<string, *>} labels
  2114. * The choice label values to use when adding suggested choices. If no
  2115. * label is found for a particular choice value, the value itself is used
  2116. * as label text. Choice labels may be any valid value accepted by
  2117. * {@link LuCI.dom#content}.
  2118. */
  2119. addChoices: function(values, labels) {
  2120. var dl = this.node.lastElementChild.firstElementChild;
  2121. dom.callClassMethod(dl, 'addChoices', values, labels);
  2122. },
  2123. /**
  2124. * Remove all existing choices from the dynamic list.
  2125. *
  2126. * This function removes all preexisting suggested choices from the widget.
  2127. *
  2128. * @instance
  2129. * @memberof LuCI.ui.DynamicList
  2130. */
  2131. clearChoices: function() {
  2132. var dl = this.node.lastElementChild.firstElementChild;
  2133. dom.callClassMethod(dl, 'clearChoices');
  2134. }
  2135. });
  2136. /**
  2137. * Instantiate a hidden input field widget.
  2138. *
  2139. * @constructor Hiddenfield
  2140. * @memberof LuCI.ui
  2141. * @augments LuCI.ui.AbstractElement
  2142. *
  2143. * @classdesc
  2144. *
  2145. * The `Hiddenfield` class implements an HTML `<input type="hidden">` field
  2146. * which allows to store form data without exposing it to the user.
  2147. *
  2148. * UI widget instances are usually not supposed to be created by view code
  2149. * directly, instead they're implicitely created by `LuCI.form` when
  2150. * instantiating CBI forms.
  2151. *
  2152. * This class is automatically instantiated as part of `LuCI.ui`. To use it
  2153. * in views, use `'require ui'` and refer to `ui.Hiddenfield`. To import it in
  2154. * external JavaScript, use `L.require("ui").then(...)` and access the
  2155. * `Hiddenfield` property of the class instance value.
  2156. *
  2157. * @param {string|string[]} [value=null]
  2158. * The initial input value.
  2159. *
  2160. * @param {LuCI.ui.AbstractElement.InitOptions} [options]
  2161. * Object describing the widget specific options to initialize the hidden input.
  2162. */
  2163. var UIHiddenfield = UIElement.extend(/** @lends LuCI.ui.Hiddenfield.prototype */ {
  2164. __init__: function(value, options) {
  2165. this.value = value;
  2166. this.options = Object.assign({
  2167. }, options);
  2168. },
  2169. /** @override */
  2170. render: function() {
  2171. var hiddenEl = E('input', {
  2172. 'id': this.options.id,
  2173. 'type': 'hidden',
  2174. 'value': this.value
  2175. });
  2176. return this.bind(hiddenEl);
  2177. },
  2178. /** @private */
  2179. bind: function(hiddenEl) {
  2180. this.node = hiddenEl;
  2181. dom.bindClassInstance(hiddenEl, this);
  2182. return hiddenEl;
  2183. },
  2184. /** @override */
  2185. getValue: function() {
  2186. return this.node.value;
  2187. },
  2188. /** @override */
  2189. setValue: function(value) {
  2190. this.node.value = value;
  2191. }
  2192. });
  2193. /**
  2194. * Instantiate a file upload widget.
  2195. *
  2196. * @constructor FileUpload
  2197. * @memberof LuCI.ui
  2198. * @augments LuCI.ui.AbstractElement
  2199. *
  2200. * @classdesc
  2201. *
  2202. * The `FileUpload` class implements a widget which allows the user to upload,
  2203. * browse, select and delete files beneath a predefined remote directory.
  2204. *
  2205. * UI widget instances are usually not supposed to be created by view code
  2206. * directly, instead they're implicitely created by `LuCI.form` when
  2207. * instantiating CBI forms.
  2208. *
  2209. * This class is automatically instantiated as part of `LuCI.ui`. To use it
  2210. * in views, use `'require ui'` and refer to `ui.FileUpload`. To import it in
  2211. * external JavaScript, use `L.require("ui").then(...)` and access the
  2212. * `FileUpload` property of the class instance value.
  2213. *
  2214. * @param {string|string[]} [value=null]
  2215. * The initial input value.
  2216. *
  2217. * @param {LuCI.ui.DynamicList.InitOptions} [options]
  2218. * Object describing the widget specific options to initialize the file
  2219. * upload control.
  2220. */
  2221. var UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ {
  2222. /**
  2223. * In addition to the [AbstractElement.InitOptions]{@link LuCI.ui.AbstractElement.InitOptions}
  2224. * the following properties are recognized:
  2225. *
  2226. * @typedef {LuCI.ui.AbstractElement.InitOptions} InitOptions
  2227. * @memberof LuCI.ui.FileUpload
  2228. *
  2229. * @property {boolean} [show_hidden=false]
  2230. * Specifies whether hidden files should be displayed when browsing remote
  2231. * files. Note that this is not a security feature, hidden files are always
  2232. * present in the remote file listings received, this option merely controls
  2233. * whether they're displayed or not.
  2234. *
  2235. * @property {boolean} [enable_upload=true]
  2236. * Specifies whether the widget allows the user to upload files. If set to
  2237. * `false`, only existing files may be selected. Note that this is not a
  2238. * security feature. Whether file upload requests are accepted remotely
  2239. * depends on the ACL setup for the current session. This option merely
  2240. * controls whether the upload controls are rendered or not.
  2241. *
  2242. * @property {boolean} [enable_remove=true]
  2243. * Specifies whether the widget allows the user to delete remove files.
  2244. * If set to `false`, existing files may not be removed. Note that this is
  2245. * not a security feature. Whether file delete requests are accepted
  2246. * remotely depends on the ACL setup for the current session. This option
  2247. * merely controls whether the file remove controls are rendered or not.
  2248. *
  2249. * @property {string} [root_directory=/etc/luci-uploads]
  2250. * Specifies the remote directory the upload and file browsing actions take
  2251. * place in. Browsing to directories outside of the root directory is
  2252. * prevented by the widget. Note that this is not a security feature.
  2253. * Whether remote directories are browseable or not solely depends on the
  2254. * ACL setup for the current session.
  2255. */
  2256. __init__: function(value, options) {
  2257. this.value = value;
  2258. this.options = Object.assign({
  2259. show_hidden: false,
  2260. enable_upload: true,
  2261. enable_remove: true,
  2262. root_directory: '/etc/luci-uploads'
  2263. }, options);
  2264. },
  2265. /** @private */
  2266. bind: function(browserEl) {
  2267. this.node = browserEl;
  2268. this.setUpdateEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
  2269. this.setChangeEvents(browserEl, 'cbi-fileupload-select', 'cbi-fileupload-cancel');
  2270. dom.bindClassInstance(browserEl, this);
  2271. return browserEl;
  2272. },
  2273. /** @override */
  2274. render: function() {
  2275. return L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind(function(stat) {
  2276. var label;
  2277. if (L.isObject(stat) && stat.type != 'directory')
  2278. this.stat = stat;
  2279. if (this.stat != null)
  2280. label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ];
  2281. else if (this.value != null)
  2282. label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ];
  2283. else
  2284. label = [ _('Select file…') ];
  2285. return this.bind(E('div', { 'id': this.options.id }, [
  2286. E('button', {
  2287. 'class': 'btn',
  2288. 'click': UI.prototype.createHandlerFn(this, 'handleFileBrowser'),
  2289. 'disabled': this.options.disabled ? '' : null
  2290. }, label),
  2291. E('div', {
  2292. 'class': 'cbi-filebrowser'
  2293. }),
  2294. E('input', {
  2295. 'type': 'hidden',
  2296. 'name': this.options.name,
  2297. 'value': this.value
  2298. })
  2299. ]));
  2300. }, this));
  2301. },
  2302. /** @private */
  2303. truncatePath: function(path) {
  2304. if (path.length > 50)
  2305. path = path.substring(0, 25) + '…' + path.substring(path.length - 25);
  2306. return path;
  2307. },
  2308. /** @private */
  2309. iconForType: function(type) {
  2310. switch (type) {
  2311. case 'symlink':
  2312. return E('img', {
  2313. 'src': L.resource('cbi/link.svg'),
  2314. 'width': 16,
  2315. 'title': _('Symbolic link'),
  2316. 'class': 'middle'
  2317. });
  2318. case 'directory':
  2319. return E('img', {
  2320. 'src': L.resource('cbi/folder.svg'),
  2321. 'width': 16,
  2322. 'title': _('Directory'),
  2323. 'class': 'middle'
  2324. });
  2325. default:
  2326. return E('img', {
  2327. 'src': L.resource('cbi/file.svg'),
  2328. 'width': 16,
  2329. 'title': _('File'),
  2330. 'class': 'middle'
  2331. });
  2332. }
  2333. },
  2334. /** @private */
  2335. canonicalizePath: function(path) {
  2336. return path.replace(/\/{2,}/, '/')
  2337. .replace(/\/\.(\/|$)/g, '/')
  2338. .replace(/[^\/]+\/\.\.(\/|$)/g, '/')
  2339. .replace(/\/$/, '');
  2340. },
  2341. /** @private */
  2342. splitPath: function(path) {
  2343. var croot = this.canonicalizePath(this.options.root_directory || '/'),
  2344. cpath = this.canonicalizePath(path || '/');
  2345. if (cpath.length <= croot.length)
  2346. return [ croot ];
  2347. if (cpath.charAt(croot.length) != '/')
  2348. return [ croot ];
  2349. var parts = cpath.substring(croot.length + 1).split(/\//);
  2350. parts.unshift(croot);
  2351. return parts;
  2352. },
  2353. /** @private */
  2354. handleUpload: function(path, list, ev) {
  2355. var form = ev.target.parentNode,
  2356. fileinput = form.querySelector('input[type="file"]'),
  2357. nameinput = form.querySelector('input[type="text"]'),
  2358. filename = (nameinput.value != null ? nameinput.value : '').trim();
  2359. ev.preventDefault();
  2360. if (filename == '' || filename.match(/\//) || fileinput.files[0] == null)
  2361. return;
  2362. var existing = list.filter(function(e) { return e.name == filename })[0];
  2363. if (existing != null && existing.type == 'directory')
  2364. return alert(_('A directory with the same name already exists.'));
  2365. else if (existing != null && !confirm(_('Overwrite existing file "%s" ?').format(filename)))
  2366. return;
  2367. var data = new FormData();
  2368. data.append('sessionid', L.env.sessionid);
  2369. data.append('filename', path + '/' + filename);
  2370. data.append('filedata', fileinput.files[0]);
  2371. return request.post(L.env.cgi_base + '/cgi-upload', data, {
  2372. progress: L.bind(function(btn, ev) {
  2373. btn.firstChild.data = '%.2f%%'.format((ev.loaded / ev.total) * 100);
  2374. }, this, ev.target)
  2375. }).then(L.bind(function(path, ev, res) {
  2376. var reply = res.json();
  2377. if (L.isObject(reply) && reply.failure)
  2378. alert(_('Upload request failed: %s').format(reply.message));
  2379. return this.handleSelect(path, null, ev);
  2380. }, this, path, ev));
  2381. },
  2382. /** @private */
  2383. handleDelete: function(path, fileStat, ev) {
  2384. var parent = path.replace(/\/[^\/]+$/, '') || '/',
  2385. name = path.replace(/^.+\//, ''),
  2386. msg;
  2387. ev.preventDefault();
  2388. if (fileStat.type == 'directory')
  2389. msg = _('Do you really want to recursively delete the directory "%s" ?').format(name);
  2390. else
  2391. msg = _('Do you really want to delete "%s" ?').format(name);
  2392. if (confirm(msg)) {
  2393. var button = this.node.firstElementChild,
  2394. hidden = this.node.lastElementChild;
  2395. if (path == hidden.value) {
  2396. dom.content(button, _('Select file…'));
  2397. hidden.value = '';
  2398. }
  2399. return fs.remove(path).then(L.bind(function(parent, ev) {
  2400. return this.handleSelect(parent, null, ev);
  2401. }, this, parent, ev)).catch(function(err) {
  2402. alert(_('Delete request failed: %s').format(err.message));
  2403. });
  2404. }
  2405. },
  2406. /** @private */
  2407. renderUpload: function(path, list) {
  2408. if (!this.options.enable_upload)
  2409. return E([]);
  2410. return E([
  2411. E('a', {
  2412. 'href': '#',
  2413. 'class': 'btn cbi-button-positive',
  2414. 'click': function(ev) {
  2415. var uploadForm = ev.target.nextElementSibling,
  2416. fileInput = uploadForm.querySelector('input[type="file"]');
  2417. ev.target.style.display = 'none';
  2418. uploadForm.style.display = '';
  2419. fileInput.click();
  2420. }
  2421. }, _('Upload file…')),
  2422. E('div', { 'class': 'upload', 'style': 'display:none' }, [
  2423. E('input', {
  2424. 'type': 'file',
  2425. 'style': 'display:none',
  2426. 'change': function(ev) {
  2427. var nameinput = ev.target.parentNode.querySelector('input[type="text"]'),
  2428. uploadbtn = ev.target.parentNode.querySelector('button.cbi-button-save');
  2429. nameinput.value = ev.target.value.replace(/^.+[\/\\]/, '');
  2430. uploadbtn.disabled = false;
  2431. }
  2432. }),
  2433. E('button', {
  2434. 'class': 'btn',
  2435. 'click': function(ev) {
  2436. ev.preventDefault();
  2437. ev.target.previousElementSibling.click();
  2438. }
  2439. }, [ _('Browse…') ]),
  2440. E('div', {}, E('input', { 'type': 'text', 'placeholder': _('Filename') })),
  2441. E('button', {
  2442. 'class': 'btn cbi-button-save',
  2443. 'click': UI.prototype.createHandlerFn(this, 'handleUpload', path, list),
  2444. 'disabled': true
  2445. }, [ _('Upload file') ])
  2446. ])
  2447. ]);
  2448. },
  2449. /** @private */
  2450. renderListing: function(container, path, list) {
  2451. var breadcrumb = E('p'),
  2452. rows = E('ul');
  2453. list.sort(function(a, b) {
  2454. var isDirA = (a.type == 'directory'),
  2455. isDirB = (b.type == 'directory');
  2456. if (isDirA != isDirB)
  2457. return isDirA < isDirB;
  2458. return a.name > b.name;
  2459. });
  2460. for (var i = 0; i < list.length; i++) {
  2461. if (!this.options.show_hidden && list[i].name.charAt(0) == '.')
  2462. continue;
  2463. var entrypath = this.canonicalizePath(path + '/' + list[i].name),
  2464. selected = (entrypath == this.node.lastElementChild.value),
  2465. mtime = new Date(list[i].mtime * 1000);
  2466. rows.appendChild(E('li', [
  2467. E('div', { 'class': 'name' }, [
  2468. this.iconForType(list[i].type),
  2469. ' ',
  2470. E('a', {
  2471. 'href': '#',
  2472. 'style': selected ? 'font-weight:bold' : null,
  2473. 'click': UI.prototype.createHandlerFn(this, 'handleSelect',
  2474. entrypath, list[i].type != 'directory' ? list[i] : null)
  2475. }, '%h'.format(list[i].name))
  2476. ]),
  2477. E('div', { 'class': 'mtime hide-xs' }, [
  2478. ' %04d-%02d-%02d %02d:%02d:%02d '.format(
  2479. mtime.getFullYear(),
  2480. mtime.getMonth() + 1,
  2481. mtime.getDate(),
  2482. mtime.getHours(),
  2483. mtime.getMinutes(),
  2484. mtime.getSeconds())
  2485. ]),
  2486. E('div', [
  2487. selected ? E('button', {
  2488. 'class': 'btn',
  2489. 'click': UI.prototype.createHandlerFn(this, 'handleReset')
  2490. }, [ _('Deselect') ]) : '',
  2491. this.options.enable_remove ? E('button', {
  2492. 'class': 'btn cbi-button-negative',
  2493. 'click': UI.prototype.createHandlerFn(this, 'handleDelete', entrypath, list[i])
  2494. }, [ _('Delete') ]) : ''
  2495. ])
  2496. ]));
  2497. }
  2498. if (!rows.firstElementChild)
  2499. rows.appendChild(E('em', _('No entries in this directory')));
  2500. var dirs = this.splitPath(path),
  2501. cur = '';
  2502. for (var i = 0; i < dirs.length; i++) {
  2503. cur = cur ? cur + '/' + dirs[i] : dirs[i];
  2504. dom.append(breadcrumb, [
  2505. i ? ' » ' : '',
  2506. E('a', {
  2507. 'href': '#',
  2508. 'click': UI.prototype.createHandlerFn(this, 'handleSelect', cur || '/', null)
  2509. }, dirs[i] != '' ? '%h'.format(dirs[i]) : E('em', '(root)')),
  2510. ]);
  2511. }
  2512. dom.content(container, [
  2513. breadcrumb,
  2514. rows,
  2515. E('div', { 'class': 'right' }, [
  2516. this.renderUpload(path, list),
  2517. E('a', {
  2518. 'href': '#',
  2519. 'class': 'btn',
  2520. 'click': UI.prototype.createHandlerFn(this, 'handleCancel')
  2521. }, _('Cancel'))
  2522. ]),
  2523. ]);
  2524. },
  2525. /** @private */
  2526. handleCancel: function(ev) {
  2527. var button = this.node.firstElementChild,
  2528. browser = button.nextElementSibling;
  2529. browser.classList.remove('open');
  2530. button.style.display = '';
  2531. this.node.dispatchEvent(new CustomEvent('cbi-fileupload-cancel', {}));
  2532. ev.preventDefault();
  2533. },
  2534. /** @private */
  2535. handleReset: function(ev) {
  2536. var button = this.node.firstElementChild,
  2537. hidden = this.node.lastElementChild;
  2538. hidden.value = '';
  2539. dom.content(button, _('Select file…'));
  2540. this.handleCancel(ev);
  2541. },
  2542. /** @private */
  2543. handleSelect: function(path, fileStat, ev) {
  2544. var browser = dom.parent(ev.target, '.cbi-filebrowser'),
  2545. ul = browser.querySelector('ul');
  2546. if (fileStat == null) {
  2547. dom.content(ul, E('em', { 'class': 'spinning' }, _('Loading directory contents…')));
  2548. L.resolveDefault(fs.list(path), []).then(L.bind(this.renderListing, this, browser, path));
  2549. }
  2550. else {
  2551. var button = this.node.firstElementChild,
  2552. hidden = this.node.lastElementChild;
  2553. path = this.canonicalizePath(path);
  2554. dom.content(button, [
  2555. this.iconForType(fileStat.type),
  2556. ' %s (%1000mB)'.format(this.truncatePath(path), fileStat.size)
  2557. ]);
  2558. browser.classList.remove('open');
  2559. button.style.display = '';
  2560. hidden.value = path;
  2561. this.stat = Object.assign({ path: path }, fileStat);
  2562. this.node.dispatchEvent(new CustomEvent('cbi-fileupload-select', { detail: this.stat }));
  2563. }
  2564. },
  2565. /** @private */
  2566. handleFileBrowser: function(ev) {
  2567. var button = ev.target,
  2568. browser = button.nextElementSibling,
  2569. path = this.stat ? this.stat.path.replace(/\/[^\/]+$/, '') : (this.options.initial_directory || this.options.root_directory);
  2570. if (path.indexOf(this.options.root_directory) != 0)
  2571. path = this.options.root_directory;
  2572. ev.preventDefault();
  2573. return L.resolveDefault(fs.list(path), []).then(L.bind(function(button, browser, path, list) {
  2574. document.querySelectorAll('.cbi-filebrowser.open').forEach(function(browserEl) {
  2575. dom.findClassInstance(browserEl).handleCancel(ev);
  2576. });
  2577. button.style.display = 'none';
  2578. browser.classList.add('open');
  2579. return this.renderListing(browser, path, list);
  2580. }, this, button, browser, path));
  2581. },
  2582. /** @override */
  2583. getValue: function() {
  2584. return this.node.lastElementChild.value;
  2585. },
  2586. /** @override */
  2587. setValue: function(value) {
  2588. this.node.lastElementChild.value = value;
  2589. }
  2590. });
  2591. function scrubMenu(node) {
  2592. var hasSatisfiedChild = false;
  2593. if (L.isObject(node.children)) {
  2594. for (var k in node.children) {
  2595. var child = scrubMenu(node.children[k]);
  2596. if (child.title)
  2597. hasSatisfiedChild = hasSatisfiedChild || child.satisfied;
  2598. }
  2599. }
  2600. if (L.isObject(node.action) &&
  2601. node.action.type == 'firstchild' &&
  2602. hasSatisfiedChild == false)
  2603. node.satisfied = false;
  2604. return node;
  2605. };
  2606. /**
  2607. * Handle menu.
  2608. *
  2609. * @constructor menu
  2610. * @memberof LuCI.ui
  2611. *
  2612. * @classdesc
  2613. *
  2614. * Handles menus.
  2615. */
  2616. var UIMenu = baseclass.singleton(/** @lends LuCI.ui.menu.prototype */ {
  2617. /**
  2618. * @typedef {Object} MenuNode
  2619. * @memberof LuCI.ui.menu
  2620. * @property {string} name - The internal name of the node, as used in the URL
  2621. * @property {number} order - The sort index of the menu node
  2622. * @property {string} [title] - The title of the menu node, `null` if the node should be hidden
  2623. * @property {satisified} boolean - Boolean indicating whether the menu enries dependencies are satisfied
  2624. * @property {readonly} [boolean] - Boolean indicating whether the menu entries underlying ACLs are readonly
  2625. * @property {LuCI.ui.menu.MenuNode[]} [children] - Array of child menu nodes.
  2626. */
  2627. /**
  2628. * Load and cache current menu tree.
  2629. *
  2630. * @returns {Promise<LuCI.ui.menu.MenuNode>}
  2631. * Returns a promise resolving to the root element of the menu tree.
  2632. */
  2633. load: function() {
  2634. if (this.menu == null)
  2635. this.menu = session.getLocalData('menu');
  2636. if (!L.isObject(this.menu)) {
  2637. this.menu = request.get(L.url('admin/menu')).then(L.bind(function(menu) {
  2638. this.menu = scrubMenu(menu.json());
  2639. session.setLocalData('menu', this.menu);
  2640. return this.menu;
  2641. }, this));
  2642. }
  2643. return Promise.resolve(this.menu);
  2644. },
  2645. /**
  2646. * Flush the internal menu cache to force loading a new structure on the
  2647. * next page load.
  2648. */
  2649. flushCache: function() {
  2650. session.setLocalData('menu', null);
  2651. },
  2652. /**
  2653. * @param {LuCI.ui.menu.MenuNode} [node]
  2654. * The menu node to retrieve the children for. Defaults to the menu's
  2655. * internal root node if omitted.
  2656. *
  2657. * @returns {LuCI.ui.menu.MenuNode[]}
  2658. * Returns an array of child menu nodes.
  2659. */
  2660. getChildren: function(node) {
  2661. var children = [];
  2662. if (node == null)
  2663. node = this.menu;
  2664. for (var k in node.children) {
  2665. if (!node.children.hasOwnProperty(k))
  2666. continue;
  2667. if (!node.children[k].satisfied)
  2668. continue;
  2669. if (!node.children[k].hasOwnProperty('title'))
  2670. continue;
  2671. children.push(Object.assign(node.children[k], { name: k }));
  2672. }
  2673. return children.sort(function(a, b) {
  2674. var wA = a.order || 1000,
  2675. wB = b.order || 1000;
  2676. if (wA != wB)
  2677. return wA - wB;
  2678. return a.name > b.name;
  2679. });
  2680. }
  2681. });
  2682. /**
  2683. * @class ui
  2684. * @memberof LuCI
  2685. * @hideconstructor
  2686. * @classdesc
  2687. *
  2688. * Provides high level UI helper functionality.
  2689. * To import the class in views, use `'require ui'`, to import it in
  2690. * external JavaScript, use `L.require("ui").then(...)`.
  2691. */
  2692. var UI = baseclass.extend(/** @lends LuCI.ui.prototype */ {
  2693. __init__: function() {
  2694. modalDiv = document.body.appendChild(
  2695. dom.create('div', { id: 'modal_overlay' },
  2696. dom.create('div', { class: 'modal', role: 'dialog', 'aria-modal': true })));
  2697. tooltipDiv = document.body.appendChild(
  2698. dom.create('div', { class: 'cbi-tooltip' }));
  2699. /* setup old aliases */
  2700. L.showModal = this.showModal;
  2701. L.hideModal = this.hideModal;
  2702. L.showTooltip = this.showTooltip;
  2703. L.hideTooltip = this.hideTooltip;
  2704. L.itemlist = this.itemlist;
  2705. document.addEventListener('mouseover', this.showTooltip.bind(this), true);
  2706. document.addEventListener('mouseout', this.hideTooltip.bind(this), true);
  2707. document.addEventListener('focus', this.showTooltip.bind(this), true);
  2708. document.addEventListener('blur', this.hideTooltip.bind(this), true);
  2709. document.addEventListener('luci-loaded', this.tabs.init.bind(this.tabs));
  2710. document.addEventListener('luci-loaded', this.changes.init.bind(this.changes));
  2711. document.addEventListener('uci-loaded', this.changes.init.bind(this.changes));
  2712. },
  2713. /**
  2714. * Display a modal overlay dialog with the specified contents.
  2715. *
  2716. * The modal overlay dialog covers the current view preventing interaction
  2717. * with the underlying view contents. Only one modal dialog instance can
  2718. * be opened. Invoking showModal() while a modal dialog is already open will
  2719. * replace the open dialog with a new one having the specified contents.
  2720. *
  2721. * Additional CSS class names may be passed to influence the appearence of
  2722. * the dialog. Valid values for the classes depend on the underlying theme.
  2723. *
  2724. * @see LuCI.dom.content
  2725. *
  2726. * @param {string} [title]
  2727. * The title of the dialog. If `null`, no title element will be rendered.
  2728. *
  2729. * @param {*} contents
  2730. * The contents to add to the modal dialog. This should be a DOM node or
  2731. * a document fragment in most cases. The value is passed as-is to the
  2732. * `dom.content()` function - refer to its documentation for applicable
  2733. * values.
  2734. *
  2735. * @param {...string} [classes]
  2736. * A number of extra CSS class names which are set on the modal dialog
  2737. * element.
  2738. *
  2739. * @returns {Node}
  2740. * Returns a DOM Node representing the modal dialog element.
  2741. */
  2742. showModal: function(title, children /* , ... */) {
  2743. var dlg = modalDiv.firstElementChild;
  2744. dlg.setAttribute('class', 'modal');
  2745. for (var i = 2; i < arguments.length; i++)
  2746. dlg.classList.add(arguments[i]);
  2747. dom.content(dlg, dom.create('h4', {}, title));
  2748. dom.append(dlg, children);
  2749. document.body.classList.add('modal-overlay-active');
  2750. modalDiv.scrollTop = 0;
  2751. return dlg;
  2752. },
  2753. /**
  2754. * Close the open modal overlay dialog.
  2755. *
  2756. * This function will close an open modal dialog and restore the normal view
  2757. * behaviour. It has no effect if no modal dialog is currently open.
  2758. *
  2759. * Note that this function is stand-alone, it does not rely on `this` and
  2760. * will not invoke other class functions so it suitable to be used as event
  2761. * handler as-is without the need to bind it first.
  2762. */
  2763. hideModal: function() {
  2764. document.body.classList.remove('modal-overlay-active');
  2765. },
  2766. /** @private */
  2767. showTooltip: function(ev) {
  2768. var target = findParent(ev.target, '[data-tooltip]');
  2769. if (!target)
  2770. return;
  2771. if (tooltipTimeout !== null) {
  2772. window.clearTimeout(tooltipTimeout);
  2773. tooltipTimeout = null;
  2774. }
  2775. var rect = target.getBoundingClientRect(),
  2776. x = rect.left + window.pageXOffset,
  2777. y = rect.top + rect.height + window.pageYOffset;
  2778. tooltipDiv.className = 'cbi-tooltip';
  2779. tooltipDiv.innerHTML = '▲ ';
  2780. tooltipDiv.firstChild.data += target.getAttribute('data-tooltip');
  2781. if (target.hasAttribute('data-tooltip-style'))
  2782. tooltipDiv.classList.add(target.getAttribute('data-tooltip-style'));
  2783. if ((y + tooltipDiv.offsetHeight) > (window.innerHeight + window.pageYOffset)) {
  2784. y -= (tooltipDiv.offsetHeight + target.offsetHeight);
  2785. tooltipDiv.firstChild.data = '▼ ' + tooltipDiv.firstChild.data.substr(2);
  2786. }
  2787. tooltipDiv.style.top = y + 'px';
  2788. tooltipDiv.style.left = x + 'px';
  2789. tooltipDiv.style.opacity = 1;
  2790. tooltipDiv.dispatchEvent(new CustomEvent('tooltip-open', {
  2791. bubbles: true,
  2792. detail: { target: target }
  2793. }));
  2794. },
  2795. /** @private */
  2796. hideTooltip: function(ev) {
  2797. if (ev.target === tooltipDiv || ev.relatedTarget === tooltipDiv ||
  2798. tooltipDiv.contains(ev.target) || tooltipDiv.contains(ev.relatedTarget))
  2799. return;
  2800. if (tooltipTimeout !== null) {
  2801. window.clearTimeout(tooltipTimeout);
  2802. tooltipTimeout = null;
  2803. }
  2804. tooltipDiv.style.opacity = 0;
  2805. tooltipTimeout = window.setTimeout(function() { tooltipDiv.removeAttribute('style'); }, 250);
  2806. tooltipDiv.dispatchEvent(new CustomEvent('tooltip-close', { bubbles: true }));
  2807. },
  2808. /**
  2809. * Add a notification banner at the top of the current view.
  2810. *
  2811. * A notification banner is an alert message usually displayed at the
  2812. * top of the current view, spanning the entire availibe width.
  2813. * Notification banners will stay in place until dismissed by the user.
  2814. * Multiple banners may be shown at the same time.
  2815. *
  2816. * Additional CSS class names may be passed to influence the appearence of
  2817. * the banner. Valid values for the classes depend on the underlying theme.
  2818. *
  2819. * @see LuCI.dom.content
  2820. *
  2821. * @param {string} [title]
  2822. * The title of the notification banner. If `null`, no title element
  2823. * will be rendered.
  2824. *
  2825. * @param {*} contents
  2826. * The contents to add to the notification banner. This should be a DOM
  2827. * node or a document fragment in most cases. The value is passed as-is
  2828. * to the `dom.content()` function - refer to its documentation for
  2829. * applicable values.
  2830. *
  2831. * @param {...string} [classes]
  2832. * A number of extra CSS class names which are set on the notification
  2833. * banner element.
  2834. *
  2835. * @returns {Node}
  2836. * Returns a DOM Node representing the notification banner element.
  2837. */
  2838. addNotification: function(title, children /*, ... */) {
  2839. var mc = document.querySelector('#maincontent') || document.body;
  2840. var msg = E('div', {
  2841. 'class': 'alert-message fade-in',
  2842. 'style': 'display:flex',
  2843. 'transitionend': function(ev) {
  2844. var node = ev.currentTarget;
  2845. if (node.parentNode && node.classList.contains('fade-out'))
  2846. node.parentNode.removeChild(node);
  2847. }
  2848. }, [
  2849. E('div', { 'style': 'flex:10' }),
  2850. E('div', { 'style': 'flex:1 1 auto; display:flex' }, [
  2851. E('button', {
  2852. 'class': 'btn',
  2853. 'style': 'margin-left:auto; margin-top:auto',
  2854. 'click': function(ev) {
  2855. dom.parent(ev.target, '.alert-message').classList.add('fade-out');
  2856. },
  2857. }, [ _('Dismiss') ])
  2858. ])
  2859. ]);
  2860. if (title != null)
  2861. dom.append(msg.firstElementChild, E('h4', {}, title));
  2862. dom.append(msg.firstElementChild, children);
  2863. for (var i = 2; i < arguments.length; i++)
  2864. msg.classList.add(arguments[i]);
  2865. mc.insertBefore(msg, mc.firstElementChild);
  2866. return msg;
  2867. },
  2868. /**
  2869. * Display or update an header area indicator.
  2870. *
  2871. * An indicator is a small label displayed in the header area of the screen
  2872. * providing few amounts of status information such as item counts or state
  2873. * toggle indicators.
  2874. *
  2875. * Multiple indicators may be shown at the same time and indicator labels
  2876. * may be made clickable to display extended information or to initiate
  2877. * further actions.
  2878. *
  2879. * Indicators can either use a default `active` or a less accented `inactive`
  2880. * style which is useful for indicators representing state toggles.
  2881. *
  2882. * @param {string} id
  2883. * The ID of the indicator. If an indicator with the given ID already exists,
  2884. * it is updated with the given label and style.
  2885. *
  2886. * @param {string} label
  2887. * The text to display in the indicator label.
  2888. *
  2889. * @param {function} [handler]
  2890. * A handler function to invoke when the indicator label is clicked/touched
  2891. * by the user. If omitted, the indicator is not clickable/touchable.
  2892. *
  2893. * Note that this parameter only applies to new indicators, when updating
  2894. * existing labels it is ignored.
  2895. *
  2896. * @param {string} [style=active]
  2897. * The indicator style to use. May be either `active` or `inactive`.
  2898. *
  2899. * @returns {boolean}
  2900. * Returns `true` when the indicator has been updated or `false` when no
  2901. * changes were made.
  2902. */
  2903. showIndicator: function(id, label, handler, style) {
  2904. if (indicatorDiv == null) {
  2905. indicatorDiv = document.body.querySelector('#indicators');
  2906. if (indicatorDiv == null)
  2907. return false;
  2908. }
  2909. var handlerFn = (typeof(handler) == 'function') ? handler : null,
  2910. indicatorElem = indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id));
  2911. if (indicatorElem == null) {
  2912. var beforeElem = null;
  2913. for (beforeElem = indicatorDiv.firstElementChild;
  2914. beforeElem != null;
  2915. beforeElem = beforeElem.nextElementSibling)
  2916. if (beforeElem.getAttribute('data-indicator') > id)
  2917. break;
  2918. indicatorElem = indicatorDiv.insertBefore(E('span', {
  2919. 'data-indicator': id,
  2920. 'data-clickable': handlerFn ? true : null,
  2921. 'click': handlerFn
  2922. }, ['']), beforeElem);
  2923. }
  2924. if (label == indicatorElem.firstChild.data && style == indicatorElem.getAttribute('data-style'))
  2925. return false;
  2926. indicatorElem.firstChild.data = label;
  2927. indicatorElem.setAttribute('data-style', (style == 'inactive') ? 'inactive' : 'active');
  2928. return true;
  2929. },
  2930. /**
  2931. * Remove an header area indicator.
  2932. *
  2933. * This function removes the given indicator label from the header indicator
  2934. * area. When the given indicator is not found, this function does nothing.
  2935. *
  2936. * @param {string} id
  2937. * The ID of the indicator to remove.
  2938. *
  2939. * @returns {boolean}
  2940. * Returns `true` when the indicator has been removed or `false` when the
  2941. * requested indicator was not found.
  2942. */
  2943. hideIndicator: function(id) {
  2944. var indicatorElem = indicatorDiv ? indicatorDiv.querySelector('span[data-indicator="%s"]'.format(id)) : null;
  2945. if (indicatorElem == null)
  2946. return false;
  2947. indicatorDiv.removeChild(indicatorElem);
  2948. return true;
  2949. },
  2950. /**
  2951. * Formats a series of label/value pairs into list-like markup.
  2952. *
  2953. * This function transforms a flat array of alternating label and value
  2954. * elements into a list-like markup, using the values in `separators` as
  2955. * separators and appends the resulting nodes to the given parent DOM node.
  2956. *
  2957. * Each label is suffixed with `: ` and wrapped into a `<strong>` tag, the
  2958. * `<strong>` element and the value corresponding to the label are
  2959. * subsequently wrapped into a `<span class="nowrap">` element.
  2960. *
  2961. * The resulting `<span>` element tuples are joined by the given separators
  2962. * to form the final markup which is appened to the given parent DOM node.
  2963. *
  2964. * @param {Node} node
  2965. * The parent DOM node to append the markup to. Any previous child elements
  2966. * will be removed.
  2967. *
  2968. * @param {Array<*>} items
  2969. * An alternating array of labels and values. The label values will be
  2970. * converted to plain strings, the values are used as-is and may be of
  2971. * any type accepted by `LuCI.dom.content()`.
  2972. *
  2973. * @param {*|Array<*>} [separators=[E('br')]]
  2974. * A single value or an array of separator values to separate each
  2975. * label/value pair with. The function will cycle through the separators
  2976. * when joining the pairs. If omitted, the default separator is a sole HTML
  2977. * `<br>` element. Separator values are used as-is and may be of any type
  2978. * accepted by `LuCI.dom.content()`.
  2979. *
  2980. * @returns {Node}
  2981. * Returns the parent DOM node the formatted markup has been added to.
  2982. */
  2983. itemlist: function(node, items, separators) {
  2984. var children = [];
  2985. if (!Array.isArray(separators))
  2986. separators = [ separators || E('br') ];
  2987. for (var i = 0; i < items.length; i += 2) {
  2988. if (items[i+1] !== null && items[i+1] !== undefined) {
  2989. var sep = separators[(i/2) % separators.length],
  2990. cld = [];
  2991. children.push(E('span', { class: 'nowrap' }, [
  2992. items[i] ? E('strong', items[i] + ': ') : '',
  2993. items[i+1]
  2994. ]));
  2995. if ((i+2) < items.length)
  2996. children.push(dom.elem(sep) ? sep.cloneNode(true) : sep);
  2997. }
  2998. }
  2999. dom.content(node, children);
  3000. return node;
  3001. },
  3002. /**
  3003. * @class
  3004. * @memberof LuCI.ui
  3005. * @hideconstructor
  3006. * @classdesc
  3007. *
  3008. * The `tabs` class handles tab menu groups used throughout the view area.
  3009. * It takes care of setting up tab groups, tracking their state and handling
  3010. * related events.
  3011. *
  3012. * This class is automatically instantiated as part of `LuCI.ui`. To use it
  3013. * in views, use `'require ui'` and refer to `ui.tabs`. To import it in
  3014. * external JavaScript, use `L.require("ui").then(...)` and access the
  3015. * `tabs` property of the class instance value.
  3016. */
  3017. tabs: baseclass.singleton(/* @lends LuCI.ui.tabs.prototype */ {
  3018. /** @private */
  3019. init: function() {
  3020. var groups = [], prevGroup = null, currGroup = null;
  3021. document.querySelectorAll('[data-tab]').forEach(function(tab) {
  3022. var parent = tab.parentNode;
  3023. if (dom.matches(tab, 'li') && dom.matches(parent, 'ul.cbi-tabmenu'))
  3024. return;
  3025. if (!parent.hasAttribute('data-tab-group'))
  3026. parent.setAttribute('data-tab-group', groups.length);
  3027. currGroup = +parent.getAttribute('data-tab-group');
  3028. if (currGroup !== prevGroup) {
  3029. prevGroup = currGroup;
  3030. if (!groups[currGroup])
  3031. groups[currGroup] = [];
  3032. }
  3033. groups[currGroup].push(tab);
  3034. });
  3035. for (var i = 0; i < groups.length; i++)
  3036. this.initTabGroup(groups[i]);
  3037. document.addEventListener('dependency-update', this.updateTabs.bind(this));
  3038. this.updateTabs();
  3039. },
  3040. /**
  3041. * Initializes a new tab group from the given tab pane collection.
  3042. *
  3043. * This function cycles through the given tab pane DOM nodes, extracts
  3044. * their tab IDs, titles and active states, renders a corresponding
  3045. * tab menu and prepends it to the tab panes common parent DOM node.
  3046. *
  3047. * The tab menu labels will be set to the value of the `data-tab-title`
  3048. * attribute of each corresponding pane. The last pane with the
  3049. * `data-tab-active` attribute set to `true` will be selected by default.
  3050. *
  3051. * If no pane is marked as active, the first one will be preselected.
  3052. *
  3053. * @instance
  3054. * @memberof LuCI.ui.tabs
  3055. * @param {Array<Node>|NodeList} panes
  3056. * A collection of tab panes to build a tab group menu for. May be a
  3057. * plain array of DOM nodes or a NodeList collection, such as the result
  3058. * of a `querySelectorAll()` call or the `.childNodes` property of a
  3059. * DOM node.
  3060. */
  3061. initTabGroup: function(panes) {
  3062. if (typeof(panes) != 'object' || !('length' in panes) || panes.length === 0)
  3063. return;
  3064. var menu = E('ul', { 'class': 'cbi-tabmenu' }),
  3065. group = panes[0].parentNode,
  3066. groupId = +group.getAttribute('data-tab-group'),
  3067. selected = null;
  3068. if (group.getAttribute('data-initialized') === 'true')
  3069. return;
  3070. for (var i = 0, pane; pane = panes[i]; i++) {
  3071. var name = pane.getAttribute('data-tab'),
  3072. title = pane.getAttribute('data-tab-title'),
  3073. active = pane.getAttribute('data-tab-active') === 'true';
  3074. menu.appendChild(E('li', {
  3075. 'style': this.isEmptyPane(pane) ? 'display:none' : null,
  3076. 'class': active ? 'cbi-tab' : 'cbi-tab-disabled',
  3077. 'data-tab': name
  3078. }, E('a', {
  3079. 'href': '#',
  3080. 'click': this.switchTab.bind(this)
  3081. }, title)));
  3082. if (active)
  3083. selected = i;
  3084. }
  3085. group.parentNode.insertBefore(menu, group);
  3086. group.setAttribute('data-initialized', true);
  3087. if (selected === null) {
  3088. selected = this.getActiveTabId(panes[0]);
  3089. if (selected < 0 || selected >= panes.length || this.isEmptyPane(panes[selected])) {
  3090. for (var i = 0; i < panes.length; i++) {
  3091. if (!this.isEmptyPane(panes[i])) {
  3092. selected = i;
  3093. break;
  3094. }
  3095. }
  3096. }
  3097. menu.childNodes[selected].classList.add('cbi-tab');
  3098. menu.childNodes[selected].classList.remove('cbi-tab-disabled');
  3099. panes[selected].setAttribute('data-tab-active', 'true');
  3100. this.setActiveTabId(panes[selected], selected);
  3101. }
  3102. requestAnimationFrame(L.bind(function(pane) {
  3103. pane.dispatchEvent(new CustomEvent('cbi-tab-active', {
  3104. detail: { tab: pane.getAttribute('data-tab') }
  3105. }));
  3106. }, this, panes[selected]));
  3107. this.updateTabs(group);
  3108. },
  3109. /**
  3110. * Checks whether the given tab pane node is empty.
  3111. *
  3112. * @instance
  3113. * @memberof LuCI.ui.tabs
  3114. * @param {Node} pane
  3115. * The tab pane to check.
  3116. *
  3117. * @returns {boolean}
  3118. * Returns `true` if the pane is empty, else `false`.
  3119. */
  3120. isEmptyPane: function(pane) {
  3121. return dom.isEmpty(pane, function(n) { return n.classList.contains('cbi-tab-descr') });
  3122. },
  3123. /** @private */
  3124. getPathForPane: function(pane) {
  3125. var path = [], node = null;
  3126. for (node = pane ? pane.parentNode : null;
  3127. node != null && node.hasAttribute != null;
  3128. node = node.parentNode)
  3129. {
  3130. if (node.hasAttribute('data-tab'))
  3131. path.unshift(node.getAttribute('data-tab'));
  3132. else if (node.hasAttribute('data-section-id'))
  3133. path.unshift(node.getAttribute('data-section-id'));
  3134. }
  3135. return path.join('/');
  3136. },
  3137. /** @private */
  3138. getActiveTabState: function() {
  3139. var page = document.body.getAttribute('data-page'),
  3140. state = session.getLocalData('tab');
  3141. if (L.isObject(state) && state.page === page && L.isObject(state.paths))
  3142. return state;
  3143. session.setLocalData('tab', null);
  3144. return { page: page, paths: {} };
  3145. },
  3146. /** @private */
  3147. getActiveTabId: function(pane) {
  3148. var path = this.getPathForPane(pane);
  3149. return +this.getActiveTabState().paths[path] || 0;
  3150. },
  3151. /** @private */
  3152. setActiveTabId: function(pane, tabIndex) {
  3153. var path = this.getPathForPane(pane),
  3154. state = this.getActiveTabState();
  3155. state.paths[path] = tabIndex;
  3156. return session.setLocalData('tab', state);
  3157. },
  3158. /** @private */
  3159. updateTabs: function(ev, root) {
  3160. (root || document).querySelectorAll('[data-tab-title]').forEach(L.bind(function(pane) {
  3161. var menu = pane.parentNode.previousElementSibling,
  3162. tab = menu ? menu.querySelector('[data-tab="%s"]'.format(pane.getAttribute('data-tab'))) : null,
  3163. n_errors = pane.querySelectorAll('.cbi-input-invalid').length;
  3164. if (!menu || !tab)
  3165. return;
  3166. if (this.isEmptyPane(pane)) {
  3167. tab.style.display = 'none';
  3168. tab.classList.remove('flash');
  3169. }
  3170. else if (tab.style.display === 'none') {
  3171. tab.style.display = '';
  3172. requestAnimationFrame(function() { tab.classList.add('flash') });
  3173. }
  3174. if (n_errors) {
  3175. tab.setAttribute('data-errors', n_errors);
  3176. tab.setAttribute('data-tooltip', _('%d invalid field(s)').format(n_errors));
  3177. tab.setAttribute('data-tooltip-style', 'error');
  3178. }
  3179. else {
  3180. tab.removeAttribute('data-errors');
  3181. tab.removeAttribute('data-tooltip');
  3182. }
  3183. }, this));
  3184. },
  3185. /** @private */
  3186. switchTab: function(ev) {
  3187. var tab = ev.target.parentNode,
  3188. name = tab.getAttribute('data-tab'),
  3189. menu = tab.parentNode,
  3190. group = menu.nextElementSibling,
  3191. groupId = +group.getAttribute('data-tab-group'),
  3192. index = 0;
  3193. ev.preventDefault();
  3194. if (!tab.classList.contains('cbi-tab-disabled'))
  3195. return;
  3196. menu.querySelectorAll('[data-tab]').forEach(function(tab) {
  3197. tab.classList.remove('cbi-tab');
  3198. tab.classList.remove('cbi-tab-disabled');
  3199. tab.classList.add(
  3200. tab.getAttribute('data-tab') === name ? 'cbi-tab' : 'cbi-tab-disabled');
  3201. });
  3202. group.childNodes.forEach(function(pane) {
  3203. if (dom.matches(pane, '[data-tab]')) {
  3204. if (pane.getAttribute('data-tab') === name) {
  3205. pane.setAttribute('data-tab-active', 'true');
  3206. pane.dispatchEvent(new CustomEvent('cbi-tab-active', { detail: { tab: name } }));
  3207. UI.prototype.tabs.setActiveTabId(pane, index);
  3208. }
  3209. else {
  3210. pane.setAttribute('data-tab-active', 'false');
  3211. }
  3212. index++;
  3213. }
  3214. });
  3215. }
  3216. }),
  3217. /**
  3218. * @typedef {Object} FileUploadReply
  3219. * @memberof LuCI.ui
  3220. * @property {string} name - Name of the uploaded file without directory components
  3221. * @property {number} size - Size of the uploaded file in bytes
  3222. * @property {string} checksum - The MD5 checksum of the received file data
  3223. * @property {string} sha256sum - The SHA256 checksum of the received file data
  3224. */
  3225. /**
  3226. * Display a modal file upload prompt.
  3227. *
  3228. * This function opens a modal dialog prompting the user to select and
  3229. * upload a file to a predefined remote destination path.
  3230. *
  3231. * @param {string} path
  3232. * The remote file path to upload the local file to.
  3233. *
  3234. * @param {Node} [progessStatusNode]
  3235. * An optional DOM text node whose content text is set to the progress
  3236. * percentage value during file upload.
  3237. *
  3238. * @returns {Promise<LuCI.ui.FileUploadReply>}
  3239. * Returns a promise resolving to a file upload status object on success
  3240. * or rejecting with an error in case the upload failed or has been
  3241. * cancelled by the user.
  3242. */
  3243. uploadFile: function(path, progressStatusNode) {
  3244. return new Promise(function(resolveFn, rejectFn) {
  3245. UI.prototype.showModal(_('Uploading file…'), [
  3246. E('p', _('Please select the file to upload.')),
  3247. E('div', { 'style': 'display:flex' }, [
  3248. E('div', { 'class': 'left', 'style': 'flex:1' }, [
  3249. E('input', {
  3250. type: 'file',
  3251. style: 'display:none',
  3252. change: function(ev) {
  3253. var modal = dom.parent(ev.target, '.modal'),
  3254. body = modal.querySelector('p'),
  3255. upload = modal.querySelector('.cbi-button-action.important'),
  3256. file = ev.currentTarget.files[0];
  3257. if (file == null)
  3258. return;
  3259. dom.content(body, [
  3260. E('ul', {}, [
  3261. E('li', {}, [ '%s: %s'.format(_('Name'), file.name.replace(/^.*[\\\/]/, '')) ]),
  3262. E('li', {}, [ '%s: %1024mB'.format(_('Size'), file.size) ])
  3263. ])
  3264. ]);
  3265. upload.disabled = false;
  3266. upload.focus();
  3267. }
  3268. }),
  3269. E('button', {
  3270. 'class': 'btn',
  3271. 'click': function(ev) {
  3272. ev.target.previousElementSibling.click();
  3273. }
  3274. }, [ _('Browse…') ])
  3275. ]),
  3276. E('div', { 'class': 'right', 'style': 'flex:1' }, [
  3277. E('button', {
  3278. 'class': 'btn',
  3279. 'click': function() {
  3280. UI.prototype.hideModal();
  3281. rejectFn(new Error('Upload has been cancelled'));
  3282. }
  3283. }, [ _('Cancel') ]),
  3284. ' ',
  3285. E('button', {
  3286. 'class': 'btn cbi-button-action important',
  3287. 'disabled': true,
  3288. 'click': function(ev) {
  3289. var input = dom.parent(ev.target, '.modal').querySelector('input[type="file"]');
  3290. if (!input.files[0])
  3291. return;
  3292. var progress = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, E('div', { 'style': 'width:0' }));
  3293. UI.prototype.showModal(_('Uploading file…'), [ progress ]);
  3294. var data = new FormData();
  3295. data.append('sessionid', rpc.getSessionID());
  3296. data.append('filename', path);
  3297. data.append('filedata', input.files[0]);
  3298. var filename = input.files[0].name;
  3299. request.post(L.env.cgi_base + '/cgi-upload', data, {
  3300. timeout: 0,
  3301. progress: function(pev) {
  3302. var percent = (pev.loaded / pev.total) * 100;
  3303. if (progressStatusNode)
  3304. progressStatusNode.data = '%.2f%%'.format(percent);
  3305. progress.setAttribute('title', '%.2f%%'.format(percent));
  3306. progress.firstElementChild.style.width = '%.2f%%'.format(percent);
  3307. }
  3308. }).then(function(res) {
  3309. var reply = res.json();
  3310. UI.prototype.hideModal();
  3311. if (L.isObject(reply) && reply.failure) {
  3312. UI.prototype.addNotification(null, E('p', _('Upload request failed: %s').format(reply.message)));
  3313. rejectFn(new Error(reply.failure));
  3314. }
  3315. else {
  3316. reply.name = filename;
  3317. resolveFn(reply);
  3318. }
  3319. }, function(err) {
  3320. UI.prototype.hideModal();
  3321. rejectFn(err);
  3322. });
  3323. }
  3324. }, [ _('Upload') ])
  3325. ])
  3326. ])
  3327. ]);
  3328. });
  3329. },
  3330. /**
  3331. * Perform a device connectivity test.
  3332. *
  3333. * Attempt to fetch a well known ressource from the remote device via HTTP
  3334. * in order to test connectivity. This function is mainly useful to wait
  3335. * for the router to come back online after a reboot or reconfiguration.
  3336. *
  3337. * @param {string} [proto=http]
  3338. * The protocol to use for fetching the resource. May be either `http`
  3339. * (the default) or `https`.
  3340. *
  3341. * @param {string} [host=window.location.host]
  3342. * Override the host address to probe. By default the current host as seen
  3343. * in the address bar is probed.
  3344. *
  3345. * @returns {Promise<Event>}
  3346. * Returns a promise resolving to a `load` event in case the device is
  3347. * reachable or rejecting with an `error` event in case it is not reachable
  3348. * or rejecting with `null` when the connectivity check timed out.
  3349. */
  3350. pingDevice: function(proto, ipaddr) {
  3351. var target = '%s://%s%s?%s'.format(proto || 'http', ipaddr || window.location.host, L.resource('icons/loading.gif'), Math.random());
  3352. return new Promise(function(resolveFn, rejectFn) {
  3353. var img = new Image();
  3354. img.onload = resolveFn;
  3355. img.onerror = rejectFn;
  3356. window.setTimeout(rejectFn, 1000);
  3357. img.src = target;
  3358. });
  3359. },
  3360. /**
  3361. * Wait for device to come back online and reconnect to it.
  3362. *
  3363. * Poll each given hostname or IP address and navigate to it as soon as
  3364. * one of the addresses becomes reachable.
  3365. *
  3366. * @param {...string} [hosts=[window.location.host]]
  3367. * The list of IP addresses and host names to check for reachability.
  3368. * If omitted, the current value of `window.location.host` is used by
  3369. * default.
  3370. */
  3371. awaitReconnect: function(/* ... */) {
  3372. var ipaddrs = arguments.length ? arguments : [ window.location.host ];
  3373. window.setTimeout(L.bind(function() {
  3374. poll.add(L.bind(function() {
  3375. var tasks = [], reachable = false;
  3376. for (var i = 0; i < 2; i++)
  3377. for (var j = 0; j < ipaddrs.length; j++)
  3378. tasks.push(this.pingDevice(i ? 'https' : 'http', ipaddrs[j])
  3379. .then(function(ev) { reachable = ev.target.src.replace(/^(https?:\/\/[^\/]+).*$/, '$1/') }, function() {}));
  3380. return Promise.all(tasks).then(function() {
  3381. if (reachable) {
  3382. poll.stop();
  3383. window.location = reachable;
  3384. }
  3385. });
  3386. }, this));
  3387. }, this), 5000);
  3388. },
  3389. /**
  3390. * @class
  3391. * @memberof LuCI.ui
  3392. * @hideconstructor
  3393. * @classdesc
  3394. *
  3395. * The `changes` class encapsulates logic for visualizing, applying,
  3396. * confirming and reverting staged UCI changesets.
  3397. *
  3398. * This class is automatically instantiated as part of `LuCI.ui`. To use it
  3399. * in views, use `'require ui'` and refer to `ui.changes`. To import it in
  3400. * external JavaScript, use `L.require("ui").then(...)` and access the
  3401. * `changes` property of the class instance value.
  3402. */
  3403. changes: baseclass.singleton(/* @lends LuCI.ui.changes.prototype */ {
  3404. init: function() {
  3405. if (!L.env.sessionid)
  3406. return;
  3407. return uci.changes().then(L.bind(this.renderChangeIndicator, this));
  3408. },
  3409. /**
  3410. * Set the change count indicator.
  3411. *
  3412. * This function updates or hides the UCI change count indicator,
  3413. * depending on the passed change count. When the count is greater
  3414. * than 0, the change indicator is displayed or updated, otherwise it
  3415. * is removed.
  3416. *
  3417. * @instance
  3418. * @memberof LuCI.ui.changes
  3419. * @param {number} numChanges
  3420. * The number of changes to indicate.
  3421. */
  3422. setIndicator: function(n) {
  3423. if (n > 0) {
  3424. UI.prototype.showIndicator('uci-changes',
  3425. '%s: %d'.format(_('Unsaved Changes'), n),
  3426. L.bind(this.displayChanges, this));
  3427. }
  3428. else {
  3429. UI.prototype.hideIndicator('uci-changes');
  3430. }
  3431. },
  3432. /**
  3433. * Update the change count indicator.
  3434. *
  3435. * This function updates the UCI change count indicator from the given
  3436. * UCI changeset structure.
  3437. *
  3438. * @instance
  3439. * @memberof LuCI.ui.changes
  3440. * @param {Object<string, Array<LuCI.uci.ChangeRecord>>} changes
  3441. * The UCI changeset to count.
  3442. */
  3443. renderChangeIndicator: function(changes) {
  3444. var n_changes = 0;
  3445. for (var config in changes)
  3446. if (changes.hasOwnProperty(config))
  3447. n_changes += changes[config].length;
  3448. this.changes = changes;
  3449. this.setIndicator(n_changes);
  3450. },
  3451. /** @private */
  3452. changeTemplates: {
  3453. 'add-3': '<ins>uci add %0 <strong>%3</strong> # =%2</ins>',
  3454. 'set-3': '<ins>uci set %0.<strong>%2</strong>=%3</ins>',
  3455. 'set-4': '<var><ins>uci set %0.%2.%3=<strong>%4</strong></ins></var>',
  3456. 'remove-2': '<del>uci del %0.<strong>%2</strong></del>',
  3457. 'remove-3': '<var><del>uci del %0.%2.<strong>%3</strong></del></var>',
  3458. 'order-3': '<var>uci reorder %0.%2=<strong>%3</strong></var>',
  3459. 'list-add-4': '<var><ins>uci add_list %0.%2.%3=<strong>%4</strong></ins></var>',
  3460. 'list-del-4': '<var><del>uci del_list %0.%2.%3=<strong>%4</strong></del></var>',
  3461. 'rename-3': '<var>uci rename %0.%2=<strong>%3</strong></var>',
  3462. 'rename-4': '<var>uci rename %0.%2.%3=<strong>%4</strong></var>'
  3463. },
  3464. /**
  3465. * Display the current changelog.
  3466. *
  3467. * Open a modal dialog visualizing the currently staged UCI changes
  3468. * and offer options to revert or apply the shown changes.
  3469. *
  3470. * @instance
  3471. * @memberof LuCI.ui.changes
  3472. */
  3473. displayChanges: function() {
  3474. var list = E('div', { 'class': 'uci-change-list' }),
  3475. dlg = UI.prototype.showModal(_('Configuration') + ' / ' + _('Changes'), [
  3476. E('div', { 'class': 'cbi-section' }, [
  3477. E('strong', _('Legend:')),
  3478. E('div', { 'class': 'uci-change-legend' }, [
  3479. E('div', { 'class': 'uci-change-legend-label' }, [
  3480. E('ins', '&#160;'), ' ', _('Section added') ]),
  3481. E('div', { 'class': 'uci-change-legend-label' }, [
  3482. E('del', '&#160;'), ' ', _('Section removed') ]),
  3483. E('div', { 'class': 'uci-change-legend-label' }, [
  3484. E('var', {}, E('ins', '&#160;')), ' ', _('Option changed') ]),
  3485. E('div', { 'class': 'uci-change-legend-label' }, [
  3486. E('var', {}, E('del', '&#160;')), ' ', _('Option removed') ])]),
  3487. E('br'), list,
  3488. E('div', { 'class': 'right' }, [
  3489. E('button', {
  3490. 'class': 'btn',
  3491. 'click': UI.prototype.hideModal
  3492. }, [ _('Close') ]), ' ',
  3493. E('button', {
  3494. 'class': 'cbi-button cbi-button-positive important',
  3495. 'click': L.bind(this.apply, this, true)
  3496. }, [ _('Save & Apply') ]), ' ',
  3497. E('button', {
  3498. 'class': 'cbi-button cbi-button-reset',
  3499. 'click': L.bind(this.revert, this)
  3500. }, [ _('Revert') ])])])
  3501. ]);
  3502. for (var config in this.changes) {
  3503. if (!this.changes.hasOwnProperty(config))
  3504. continue;
  3505. list.appendChild(E('h5', '# /etc/config/%s'.format(config)));
  3506. for (var i = 0, added = null; i < this.changes[config].length; i++) {
  3507. var chg = this.changes[config][i],
  3508. tpl = this.changeTemplates['%s-%d'.format(chg[0], chg.length)];
  3509. list.appendChild(E(tpl.replace(/%([01234])/g, function(m0, m1) {
  3510. switch (+m1) {
  3511. case 0:
  3512. return config;
  3513. case 2:
  3514. if (added != null && chg[1] == added[0])
  3515. return '@' + added[1] + '[-1]';
  3516. else
  3517. return chg[1];
  3518. case 4:
  3519. return "'%h'".format(chg[3].replace(/'/g, "'\"'\"'"));
  3520. default:
  3521. return chg[m1-1];
  3522. }
  3523. })));
  3524. if (chg[0] == 'add')
  3525. added = [ chg[1], chg[2] ];
  3526. }
  3527. }
  3528. list.appendChild(E('br'));
  3529. dlg.classList.add('uci-dialog');
  3530. },
  3531. /** @private */
  3532. displayStatus: function(type, content) {
  3533. if (type) {
  3534. var message = UI.prototype.showModal('', '');
  3535. message.classList.add('alert-message');
  3536. DOMTokenList.prototype.add.apply(message.classList, type.split(/\s+/));
  3537. if (content)
  3538. dom.content(message, content);
  3539. if (!this.was_polling) {
  3540. this.was_polling = request.poll.active();
  3541. request.poll.stop();
  3542. }
  3543. }
  3544. else {
  3545. UI.prototype.hideModal();
  3546. if (this.was_polling)
  3547. request.poll.start();
  3548. }
  3549. },
  3550. /** @private */
  3551. rollback: function(checked) {
  3552. if (checked) {
  3553. this.displayStatus('warning spinning',
  3554. E('p', _('Failed to confirm apply within %ds, waiting for rollback…')
  3555. .format(L.env.apply_rollback)));
  3556. var call = function(r, data, duration) {
  3557. if (r.status === 204) {
  3558. UI.prototype.changes.displayStatus('warning', [
  3559. E('h4', _('Configuration changes have been rolled back!')),
  3560. E('p', _('The device could not be reached within %d seconds after applying the pending changes, which caused the configuration to be rolled back for safety reasons. If you believe that the configuration changes are correct nonetheless, perform an unchecked configuration apply. Alternatively, you can dismiss this warning and edit changes before attempting to apply again, or revert all pending changes to keep the currently working configuration state.').format(L.env.apply_rollback)),
  3561. E('div', { 'class': 'right' }, [
  3562. E('button', {
  3563. 'class': 'btn',
  3564. 'click': L.bind(UI.prototype.changes.displayStatus, UI.prototype.changes, false)
  3565. }, [ _('Dismiss') ]), ' ',
  3566. E('button', {
  3567. 'class': 'btn cbi-button-action important',
  3568. 'click': L.bind(UI.prototype.changes.revert, UI.prototype.changes)
  3569. }, [ _('Revert changes') ]), ' ',
  3570. E('button', {
  3571. 'class': 'btn cbi-button-negative important',
  3572. 'click': L.bind(UI.prototype.changes.apply, UI.prototype.changes, false)
  3573. }, [ _('Apply unchecked') ])
  3574. ])
  3575. ]);
  3576. return;
  3577. }
  3578. var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
  3579. window.setTimeout(function() {
  3580. request.request(L.url('admin/uci/confirm'), {
  3581. method: 'post',
  3582. timeout: L.env.apply_timeout * 1000,
  3583. query: { sid: L.env.sessionid, token: L.env.token }
  3584. }).then(call);
  3585. }, delay);
  3586. };
  3587. call({ status: 0 });
  3588. }
  3589. else {
  3590. this.displayStatus('warning', [
  3591. E('h4', _('Device unreachable!')),
  3592. E('p', _('Could not regain access to the device after applying the configuration changes. You might need to reconnect if you modified network related settings such as the IP address or wireless security credentials.'))
  3593. ]);
  3594. }
  3595. },
  3596. /** @private */
  3597. confirm: function(checked, deadline, override_token) {
  3598. var tt;
  3599. var ts = Date.now();
  3600. this.displayStatus('notice');
  3601. if (override_token)
  3602. this.confirm_auth = { token: override_token };
  3603. var call = function(r, data, duration) {
  3604. if (Date.now() >= deadline) {
  3605. window.clearTimeout(tt);
  3606. UI.prototype.changes.rollback(checked);
  3607. return;
  3608. }
  3609. else if (r && (r.status === 200 || r.status === 204)) {
  3610. document.dispatchEvent(new CustomEvent('uci-applied'));
  3611. UI.prototype.changes.setIndicator(0);
  3612. UI.prototype.changes.displayStatus('notice',
  3613. E('p', _('Configuration changes applied.')));
  3614. window.clearTimeout(tt);
  3615. window.setTimeout(function() {
  3616. //UI.prototype.changes.displayStatus(false);
  3617. window.location = window.location.href.split('#')[0];
  3618. }, L.env.apply_display * 1000);
  3619. return;
  3620. }
  3621. var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
  3622. window.setTimeout(function() {
  3623. request.request(L.url('admin/uci/confirm'), {
  3624. method: 'post',
  3625. timeout: L.env.apply_timeout * 1000,
  3626. query: UI.prototype.changes.confirm_auth
  3627. }).then(call, call);
  3628. }, delay);
  3629. };
  3630. var tick = function() {
  3631. var now = Date.now();
  3632. UI.prototype.changes.displayStatus('notice spinning',
  3633. E('p', _('Applying configuration changes… %ds')
  3634. .format(Math.max(Math.floor((deadline - Date.now()) / 1000), 0))));
  3635. if (now >= deadline)
  3636. return;
  3637. tt = window.setTimeout(tick, 1000 - (now - ts));
  3638. ts = now;
  3639. };
  3640. tick();
  3641. /* wait a few seconds for the settings to become effective */
  3642. window.setTimeout(call, Math.max(L.env.apply_holdoff * 1000 - ((ts + L.env.apply_rollback * 1000) - deadline), 1));
  3643. },
  3644. /**
  3645. * Apply the staged configuration changes.
  3646. *
  3647. * Start applying staged configuration changes and open a modal dialog
  3648. * with a progress indication to prevent interaction with the view
  3649. * during the apply process. The modal dialog will be automatically
  3650. * closed and the current view reloaded once the apply process is
  3651. * complete.
  3652. *
  3653. * @instance
  3654. * @memberof LuCI.ui.changes
  3655. * @param {boolean} [checked=false]
  3656. * Whether to perform a checked (`true`) configuration apply or an
  3657. * unchecked (`false`) one.
  3658. * In case of a checked apply, the configuration changes must be
  3659. * confirmed within a specific time interval, otherwise the device
  3660. * will begin to roll back the changes in order to restore the previous
  3661. * settings.
  3662. */
  3663. apply: function(checked) {
  3664. this.displayStatus('notice spinning',
  3665. E('p', _('Starting configuration apply…')));
  3666. request.request(L.url('admin/uci', checked ? 'apply_rollback' : 'apply_unchecked'), {
  3667. method: 'post',
  3668. query: { sid: L.env.sessionid, token: L.env.token }
  3669. }).then(function(r) {
  3670. if (r.status === (checked ? 200 : 204)) {
  3671. var tok = null; try { tok = r.json(); } catch(e) {}
  3672. if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
  3673. UI.prototype.changes.confirm_auth = tok;
  3674. UI.prototype.changes.confirm(checked, Date.now() + L.env.apply_rollback * 1000);
  3675. }
  3676. else if (checked && r.status === 204) {
  3677. UI.prototype.changes.displayStatus('notice',
  3678. E('p', _('There are no changes to apply')));
  3679. window.setTimeout(function() {
  3680. UI.prototype.changes.displayStatus(false);
  3681. }, L.env.apply_display * 1000);
  3682. }
  3683. else {
  3684. UI.prototype.changes.displayStatus('warning',
  3685. E('p', _('Apply request failed with status <code>%h</code>')
  3686. .format(r.responseText || r.statusText || r.status)));
  3687. window.setTimeout(function() {
  3688. UI.prototype.changes.displayStatus(false);
  3689. }, L.env.apply_display * 1000);
  3690. }
  3691. });
  3692. },
  3693. /**
  3694. * Revert the staged configuration changes.
  3695. *
  3696. * Start reverting staged configuration changes and open a modal dialog
  3697. * with a progress indication to prevent interaction with the view
  3698. * during the revert process. The modal dialog will be automatically
  3699. * closed and the current view reloaded once the revert process is
  3700. * complete.
  3701. *
  3702. * @instance
  3703. * @memberof LuCI.ui.changes
  3704. */
  3705. revert: function() {
  3706. this.displayStatus('notice spinning',
  3707. E('p', _('Reverting configuration…')));
  3708. request.request(L.url('admin/uci/revert'), {
  3709. method: 'post',
  3710. query: { sid: L.env.sessionid, token: L.env.token }
  3711. }).then(function(r) {
  3712. if (r.status === 200) {
  3713. document.dispatchEvent(new CustomEvent('uci-reverted'));
  3714. UI.prototype.changes.setIndicator(0);
  3715. UI.prototype.changes.displayStatus('notice',
  3716. E('p', _('Changes have been reverted.')));
  3717. window.setTimeout(function() {
  3718. //UI.prototype.changes.displayStatus(false);
  3719. window.location = window.location.href.split('#')[0];
  3720. }, L.env.apply_display * 1000);
  3721. }
  3722. else {
  3723. UI.prototype.changes.displayStatus('warning',
  3724. E('p', _('Revert request failed with status <code>%h</code>')
  3725. .format(r.statusText || r.status)));
  3726. window.setTimeout(function() {
  3727. UI.prototype.changes.displayStatus(false);
  3728. }, L.env.apply_display * 1000);
  3729. }
  3730. });
  3731. }
  3732. }),
  3733. /**
  3734. * Add validation constraints to an input element.
  3735. *
  3736. * Compile the given type expression and optional validator function into
  3737. * a validation function and bind it to the specified input element events.
  3738. *
  3739. * @param {Node} field
  3740. * The DOM input element node to bind the validation constraints to.
  3741. *
  3742. * @param {string} type
  3743. * The datatype specification to describe validation constraints.
  3744. * Refer to the `LuCI.validation` class documentation for details.
  3745. *
  3746. * @param {boolean} [optional=false]
  3747. * Specifies whether empty values are allowed (`true`) or not (`false`).
  3748. * If an input element is not marked optional it must not be empty,
  3749. * otherwise it will be marked as invalid.
  3750. *
  3751. * @param {function} [vfunc]
  3752. * Specifies a custom validation function which is invoked after the
  3753. * other validation constraints are applied. The validation must return
  3754. * `true` to accept the passed value. Any other return type is converted
  3755. * to a string and treated as validation error message.
  3756. *
  3757. * @param {...string} [events=blur, keyup]
  3758. * The list of events to bind. Each received event will trigger a field
  3759. * validation. If omitted, the `keyup` and `blur` events are bound by
  3760. * default.
  3761. *
  3762. * @returns {function}
  3763. * Returns the compiled validator function which can be used to manually
  3764. * trigger field validation or to bind it to further events.
  3765. *
  3766. * @see LuCI.validation
  3767. */
  3768. addValidator: function(field, type, optional, vfunc /*, ... */) {
  3769. if (type == null)
  3770. return;
  3771. var events = this.varargs(arguments, 3);
  3772. if (events.length == 0)
  3773. events.push('blur', 'keyup');
  3774. try {
  3775. var cbiValidator = validation.create(field, type, optional, vfunc),
  3776. validatorFn = cbiValidator.validate.bind(cbiValidator);
  3777. for (var i = 0; i < events.length; i++)
  3778. field.addEventListener(events[i], validatorFn);
  3779. validatorFn();
  3780. return validatorFn;
  3781. }
  3782. catch (e) { }
  3783. },
  3784. /**
  3785. * Create a pre-bound event handler function.
  3786. *
  3787. * Generate and bind a function suitable for use in event handlers. The
  3788. * generated function automatically disables the event source element
  3789. * and adds an active indication to it by adding appropriate CSS classes.
  3790. *
  3791. * It will also await any promises returned by the wrapped function and
  3792. * re-enable the source element after the promises ran to completion.
  3793. *
  3794. * @param {*} ctx
  3795. * The `this` context to use for the wrapped function.
  3796. *
  3797. * @param {function|string} fn
  3798. * Specifies the function to wrap. In case of a function value, the
  3799. * function is used as-is. If a string is specified instead, it is looked
  3800. * up in `ctx` to obtain the function to wrap. In both cases the bound
  3801. * function will be invoked with `ctx` as `this` context
  3802. *
  3803. * @param {...*} extra_args
  3804. * Any further parameter as passed as-is to the bound event handler
  3805. * function in the same order as passed to `createHandlerFn()`.
  3806. *
  3807. * @returns {function|null}
  3808. * Returns the pre-bound handler function which is suitable to be passed
  3809. * to `addEventListener()`. Returns `null` if the given `fn` argument is
  3810. * a string which could not be found in `ctx` or if `ctx[fn]` is not a
  3811. * valid function value.
  3812. */
  3813. createHandlerFn: function(ctx, fn /*, ... */) {
  3814. if (typeof(fn) == 'string')
  3815. fn = ctx[fn];
  3816. if (typeof(fn) != 'function')
  3817. return null;
  3818. var arg_offset = arguments.length - 2;
  3819. return Function.prototype.bind.apply(function() {
  3820. var t = arguments[arg_offset].currentTarget;
  3821. t.classList.add('spinning');
  3822. t.disabled = true;
  3823. if (t.blur)
  3824. t.blur();
  3825. Promise.resolve(fn.apply(ctx, arguments)).finally(function() {
  3826. t.classList.remove('spinning');
  3827. t.disabled = false;
  3828. });
  3829. }, this.varargs(arguments, 2, ctx));
  3830. },
  3831. /**
  3832. * Load specified view class path and set it up.
  3833. *
  3834. * Transforms the given view path into a class name, requires it
  3835. * using [LuCI.require()]{@link LuCI#require} and asserts that the
  3836. * resulting class instance is a descendant of
  3837. * [LuCI.view]{@link LuCI.view}.
  3838. *
  3839. * By instantiating the view class, its corresponding contents are
  3840. * rendered and included into the view area. Any runtime errors are
  3841. * catched and rendered using [LuCI.error()]{@link LuCI#error}.
  3842. *
  3843. * @param {string} path
  3844. * The view path to render.
  3845. *
  3846. * @returns {Promise<LuCI.view>}
  3847. * Returns a promise resolving to the loaded view instance.
  3848. */
  3849. instantiateView: function(path) {
  3850. var className = 'view.%s'.format(path.replace(/\//g, '.'));
  3851. return L.require(className).then(function(view) {
  3852. if (!(view instanceof View))
  3853. throw new TypeError('Loaded class %s is not a descendant of View'.format(className));
  3854. return view;
  3855. }).catch(function(err) {
  3856. dom.content(document.querySelector('#view'), null);
  3857. L.error(err);
  3858. });
  3859. },
  3860. menu: UIMenu,
  3861. AbstractElement: UIElement,
  3862. /* Widgets */
  3863. Textfield: UITextfield,
  3864. Textarea: UITextarea,
  3865. Checkbox: UICheckbox,
  3866. Select: UISelect,
  3867. Dropdown: UIDropdown,
  3868. DynamicList: UIDynamicList,
  3869. Combobox: UICombobox,
  3870. ComboButton: UIComboButton,
  3871. Hiddenfield: UIHiddenfield,
  3872. FileUpload: UIFileUpload
  3873. });
  3874. return UI;