^Oj=)Jua3){><=GZf{f_=4_FWx%|G43pf?KH4zFXsdkl@(LsO
z&}3?R+*IzyNW8papznw;I;2n_<|C87v1-VbgZ2W8%iRKa_|h-rtt&rYoSP6mOa)f3
zGyFfo6hddZyEW!gpV0=3K!9+KGkA3L3sR4yo>lxs@X<0$^C_TNK~NCj1Ecli;3e={
z$#2k#rzl}{a~PXfb0OeTVD*Zlsm#nTIncq0G(4ZZYi+oQHZFO%@OWII?CB>A{iG9~
zANN=+-gMu7bM2>SK2-zXGo%|%b@>*_&coyE#svYJeUPE_?>!UC`Nr|IF*W&ohtLYg
z(<2A%WdL+%L7;Q$>1pdPYCHwY`OtD6aZUCkE}`7rNYDUL!|CI4?9<6x;Nhd|obKNJ
zoVX<768o13H3Ia<-%e*^m)z0mx0a+!_ao0sDfQ;8UW2b%D>m{aeW^1FN=*q!nLQXx
z9={~2;>v?SwZo=Q`zz%v(JGhJCJ@VQj>kV?&TNM4$2phc<8PC0nMyZM2SbD7d7p}7
z@L~G|+o?Y<5#@=pPQQRITN}+oac${CLcPFq*1{wV=J0dxp|z$!%n$Tu26rO%(R6q!
zdB0TKF55nS;MWNTJn}{?WVXd?yI(7~P81vuROrk;Zk4%N&FP+pi|o;Ap>5T5-T2nO
zy$z-dAYhS*ysY=wpQ-r^H2f)$Xw)9{1WOZ}DkcTrhMfTaz(4TcMUAyQrLP|5VH1!W
zeKVKHDtdSts3Uy()_CK+0=5*c9ooi|P`9=cWc?29C3f9K5;M>oO*=j3uObtJ>|Z77
zK3c97M6n=Iw0`fqf}}s|^a!3cVwWiwB>>o{(K(3w3nQK<$e476G;r_`Lq
zP}_|1m{3x`ti_4;i&0ylQpDJP)HGhbtfA|)`X0N!l-wgf(DW4ccyfD@ob&h5_7^cQ
zeD;^bB;n|CV363~078Kd7(u*Wq@32lT48w4>&2*g*#${_@zL=754D+|N6tvf64UVv>vk
z$Z>BGtEuKanwl8L{jg^=1+PAE
zbb*jA7}WyX%a5zKb6;iBcTGAZ6aE_i+i3Q=$!M%q
zZ(e?u+{&+@MR%!*#cTdpf&v%*q=U=j%4!;&lZS^?3Pw#c-pg4VxNMg
zw&srYcmw(Qnl5EQbB6p65)P|Q)`;3GPVJ!un!^xP;GWeF`vF2QzAJ|Aa6~iVoblad;G6GR|hZp^B&D4dCuuH8BWh
z-f^U6$d8~P&welG^`gPct&?4v)I8cplgX)ck}u4(5PmX?iTaP
z%~pKivRR@9OQz8=GfCCgF;w$Z{_`evfs^7ga)L%;t|tJIl5nWYc!xl_vc=+miJ@Uam2VCS!}
z2iJ{PmK&zUf1-BrI-
zu%5*C<7+FD_v#CPr0!bkn^{1oDWG*pf`#yX!ElvS<E`wRDlpC;N$Px(vY6LUfb^>
zLGl}>Ufws39^*)l%O9|{E)Lt93%jW+uMMW;E6gi7&ujVblO8*Nkh)<%|1F^Cgkz^S
z-1gqNZqU&C^iK}UmAJ|A`N<0e2aB6jv>Sy$c<7|RUI7SxZ28Czw7Y``Hb7mK6@m*e
zcHD^{JlS6Og0=s@Ka8+L{2Bn@Hng$|CIYmP<`oyi>gNBWZ~qVg_}}?ChBlV`D~aIR
zAiX5@Q(EhbY-Bi*n{a>Ii>i~J6h_q8l@Ky0j2zgf2@w8?35rSBT%jQ$YF5_~)O&td
zv@(H~T>0gdubTL79*8}$^aW?O^SL0DDd%1-L3=L_?WAc`1XiP-QW6x8?dzpcgezbS
zHGI89OSiqp;VwJ0gGC`RsgWul-HB&qnI0KUa+EIRpZm=h+cUqk51ZUP@QJg7yjKo8YFAOO
z6VGl_BIa^R&kcC}ZpbjJAMmdY#TIAw={}C~E(Oq9UHGo!`Xr`CVKh2+$d
zA6Cxc(WDb&DjEK6Uam|?qaJBluZGT)DG+$_FN=QW6`v
z;(GS*U7iar8Z57*czqLs@-7~|_HiU8zEoExnJ`&lk#!N-Zf}jsTbCX-hT%rkEpDae
z)Xd>W6)-NiGpsj{ei5A~bCNc@I{Dz6ppo!Y_gKxb?qrNnmpFo1jj6KwL1;%%W2cp$
z%&h97MWlBxG!bLvv>{}0pTN6vjhUX!x}1bG0*wB!SREru20}p8VQmkV>8+y_-+b9P
zSMv0igQb#6RJh$F^~wJ4m5!o`>Wm`I*6&k`JQkG(>lg|_99>BIDyg%HbtESJgi)tD
zP?fjulY{2@mshSO53X8Ur63zt>Oc<2nds?$?1y{bZf~r6>c{X%JX|a;rE*fzKgpt?
zNr52vdmORDb^nd{i2j=fsapc!amCF|4(gK9Ti8n2Y%)kt8vom>b-}`pY?m2JI
zdE2M|=W3}hScCv?3tW91yTg_
z`?YqtWA!@s@(@&~RnkTFfWaZ5)W6NglnhL0MAloaM6W>UaEbxzpcrwTZYlqe5&iq#K&
zPe{yZo=#~|IeU32hE3a)(!%rfd6QJO7#k}|a(4^O?2U{a(LuDViks!M0*
znOGIf?LBs6Ul%3|3wKZ1=t?LxTCF7mj@B01VDdx|
zzW>mp@ZpBJU{ZPI?SYwr^xutQv6UXf|Um)m@QH-OWF3guZ4NQm)
z$36x8owtQ7{UGY6vRx3%a2A3X4~HD?*Fp=~_pB5!iSkb>8}kE@%zyT&Rky`Gzi3IH
znjNSoTCQ9^dGcjEcGW?!{@u9}AUmk&8s;x038eJ9R3ZZpsCN*F%y&Vy$R#9^-H&ei
zjrc=M!=UHbud#j7bGcld<0=9wFFB}D@h<3phnPMZDj6@E7ya^d@`
zI$A5~Lku|c<*eYVMXBJiZ6b!r1Jy$bT&th27h$EW?@0UjGVYemRPF{u^ABe7XV_t0
z#q)|Ch`cm?clv(650vE%Ld>tZq1Nj?y5-RuKMjU$%C{F~?n8(~g<@Dt8of(rtQE;?
zW@j{D%)){EF@j&Rx!s`G5&mkHo>H_945;~hw!%$9Ay83IuETQ>88?Sg>!#)~7KSuO
z9!id797J&-iL>#UjDOrZ?{iy!NhesOXJ2IR`k?cm>E^I~ZBtGqw})&cs4yinI4vyc
z>-lMZY-{J6=gErABf%IYG@^Ek%MC|w(dTa!_aDZaD2%=7A#t4BRnrMOpPlB{$hKLp=3n4~fCw;YQ9mP6iy
z{wT|MYi)TZJdGDU!(?!Ls_4Yx_}y+V2f4JH`WhkGQa#%_#sS$ZyWqhe$VQ`%+?Xsb
zjMNvB>Qx*ZgB}`iLV_s+)5ZEG*wR7<6JjYNsh6Udmq+!RUDT{?jbzxojMt^C5
zTCR$2OGnL9ISM9{n8GB=TGq-RjS6LV2xcZgm}dkoWiOWUkgc6>7jH2gztM{!i18vp
z{uC#Y7W^yXAwR5`u#%B8o#}^uzO0XP1UN74C;8n|kpbgu1%bZZx#Ic6@Zr4sC4yuJ
z;+=Qu^2Rt#BsU~!n&ugtoE84>Tj{@<+JYXn;8?1ID0h6SN|;U1K0nJmC|*9_vU1@@ZZSOc5u>A}Rg3-X
zc7E?IOaFx;8MAN7USggt1APf3m*?fc&lX}adcxwm0PYfZTb#G%&Og>EJZn6#Tv+z`
zTm)I5rm2`_yE#1vGwz^>k@ox6!NCc9r`M;}J_qdG$s>A6aexvW>W^ay6H>jTxlWQQ
zP=9l15;dEmm~r(>UVfkVt-kPX_#Zr?_fH}bsSZ--AbCB{>_O=@$vyQ#C9n+-f`2yU_^^P^uBaY4LgBhoA>E)uavZO_>Q|G
z)m$OS?z^zxDz{stx%?TG1;DfBy9i_2&
z;0j%%s9`EnpYCz?YqZn9QuxRbMc0#CODWv-{rKS{sDZ58AP)WHOLqR_s3S-o`6-|>
zJgcY1K5@?evS3J~9z$aF6<_xWk|`@^Y1g!sp#GN#@(DX$eQzb~=~G
z&BGSEQoXu%EDsDRbe9t1zq@?yaTm{RG?mB|dj)(VKD^wL2VEr&R$HyclI~eLX|4a%
zoA=2We{G>6an6pb=ta{TnVweiyW)lId4Z{|ef(ym?~BU4yU3hgdfKTO)6()~j{)a#
z=xO@qGq!Hu8}Z`0>f@qf-~Qg&P!|U|Wj~&eAPH40GH;$t`?@(tr~o9|S5aWQ*=vH9
zYFm^8*+!Q3>c!hv;B<0$n6jT+Zh!J{gQgc3z|k`pe&_q*_UZwBnn=KC`^WQ}lPR!m
zCQD(X*ND-Rnk$QC>-D8ZAG=?hB{caCvBclXqK>dLcO+@->t+PcDcNFixw|bi$K%74
zYZphM68XYY;JdW8Lc4|Oqxof^XF2qy(~_RPZ!X}p^h;z`KnzU509)U=$>!Y2*22fQ
zG2{uoAnd&(+ibnDuK%L_X8a(6#fN9wy0m;u)NHU6>iaWr8lr`2-%pa|kU5Z~@!faz
z4}u9DfKEQ60IF_QLZhh1SZ3u7pyEfy$Avq{?ou?KG8PdCFqFRI4s7qUylA{xSr!SW
zP5sF`%c3RB;T}V@3&b~xF**6N?
zQIDS4ZeOS%oYtXLOk|b$balU)z473rDn{kR9G4_x;I?50dm-1n8eyAut#?F7e=l8a
z;KYpD^$h4{9rP@~SE!j54>*GqV}%36$oFdWsqj+SuZO^mkTYtDhB<~$r|gxsbg7dS
zs!mwFCZV|KzYH2fI{I_#QUT$&pyH?Lwv+5%9eI-+RXS|zoNKpebu)}-yWLZq
zFn1#$1CwSueP3r7tGTgN$0%}CRxH+375%Usb3$^V!81;vviRKLVBkeVG(18xs7xj7
z#6cpT1I9>?MCJd(Zu@R-E${jh`Dx6*b}`#7!>ustF#7{*7_{+%WqbP`W?qe}3%J86
zgH@+e$2?AlyT0|`G&Wr&i`xv_xKp>7pRa(tgzXyg*4wFmd1Et<%#1m6wdmxFE-diO
zTKQ|PgciTLu?N8F&W4FEDkCVDy*IgFrxvDRVMV_0!5#v%
zR|i8gqCEhy-8yZfgwZHL$etK6KqlHRF08=A
z|KfvFxa|2hP@qXdFkm9;eLz?S_J5<0#+B5CcyWxJLgjyc$*EM-$iDGtME|C
z%T&$^(TU{XvWB7rfK`vi3%C?b8v+WbA35@o>`#7wRTeUYcTU0gV6Ig-oV9iEufz$$MSwZWW4hA_8-g)}G
zCt~LmqYi1+(k##Gn4}G14aIQV6H2p!Kr+dy49hJ%0XJI2;`t(N-k;ytf`bV`$Cfc+
zWgh&-K1%v5SujHq;H{o63&B$PPsn)ym(8FZ@&w+jrHZV3E8%Es0ZX{4#ugmy8|816
zQVz7{w^}y(!~h3%;9^srso(hxAYJ#}K`Atze2Vd)%(yl;Vt^Q2Kl;W@N#;&z17igD8gKVqlY
znhX&^#xsBjygs14DJLOp#}ULdr!TSiT>l^?>{(6buC4knnsZjFz?|+jd|Hpa{pqG>
z)$R6PU=_!9h=PpkFYljM{-X;W{I&a9!qKE=M)8@~WDB{^mtxzB{garwd>BxPWvJ*
zPAkS!yNY9mJw%}2jbUK^zO(nW(P9Nyk5N^))@^z-A$EUiPx+PyrtMKyp3;K&lXY5B
z!dveNniX7y#O}Xft#D3JozOKaxg0hTEXtmBj=Xc;j_P%BU3LB0p$;FQ?bZl<^v86D
zqs@+w0JQab8Ngo^^N98(ow!E>Y@8H45rGHM1SVg#VcHy!JMP)pc-aM#`)^eCO($#9
zxD5!sG^`M@aY;Y>YO*#iJ-t2)Y71~0a9ym1w)OBzNnXsexR0B=A9?n9^Np>-)rQNF
zO^dV05<{3RA^%J5nhmZa=d9EX9#Ky*gople{PUdI{n=VvhwL##+qFdjd(wuz)pQKt
zP&=kZwf#qZCFNCt?ow?s{}XwAaPT~+AC{-U}I
z9b8DTP9ws)xDJIzdbA{PB7Fw4(pG>y=L_!oFoYw^zR>w5)!IPx;$j$WCc%{3GEX#o
zrSn#0ZzUs%dkMCeqlU_|s+*WGwRJaIajgW+s^oOmx2;bfQzddeesp_l*}ge(11btP
z?|*k~nC^o0qprBTcsTpBsN;G-+59}vtbynDE_MZStK|$3I;#d9-(Wa~=s9`_Sp+rf
z=(F({(TWM$(Ab1|)55`5<@ZsT+c?TPnP+hu2L)~`Oi!A6h}=j{EyeU3^6xb-%bG7<
z!}7WjjBxxbMz*4WN^n27jLOt&J!sNTG0?jW5o)T1UN=HFcj3ng`v>&SRO>1mm-rCq
z2S=NIx;?PrbS6DDw^N_nyzt&`$2
zk@<>3+-af8Vh7DxR8cNuxY5ZE5Coow=k7MI-}2EH*MJgz4gnO3O4t;Ys-ynbA&YqteYb$n*A?(Dwy{l
zIQxt=g>u`WwYvY$BjlTV_ZWJtCgY9Oe#N;QBmJ*dw@0?vXf%v7UYe(n>3-tJWjaVX
zlCSROEmD(I%Q=D|Al7wt8sFwoe!u7Pc)(t1=k+568zGw8N<@pz5*b(W$k{HG5lGW6Mk1t&}yE1#=9lclB-CP){Iv&F&mT
z#n!zKKF0_e^kv{4hz_d-T<`h64)Y9GU54FSJ>opxZ`Okxo&3TADwG<-om)R(*0wgL
zh142LR0q;98ACR2n3;rVp`sQRMRVg+6Kd*S$?LIzN&iAYtvT8wBCQ}9>^EkM?x7+|
zVveodo}NZOWp=omp^d>~*l81Fl;^)9na??C)@wX-=I
z?KFM;lC4U7y~tBHmUq(7Kp>57{P^dV|iyi)Dm3ogZX?u&SN5+05
z8B6~Bq3t#dsxaZ&2V~dvhp!+P14*U$LowfXF5=PxQqv_6TZP7ua%-0M4i;XsNkwxo
z-j>m*En`khAizNXJ7tcpgw=vBxQ-M~aEauU;@;A0y0ZgMVU+B)BOSN5!b)OiUB^8E
zOfCf@CeQ-`EpT(zV&?*%F*Kc`Y45bDg+)6+Z%&fPFKOiLyd5l;CG8-4XtjCWwN>WF
zHiWFD+NKooYHy#YR7&kz8u$IhL&(su{^NKd$L(4A?BGqTcJfQYfW5qj!OP_J%R5*j
zN{AtefZ@LAR6<_i1%M4Wd|UIWFP%m$Ox=aZ>tWk-J&G--)7M_QEEDUs-=xX^VX3_4
zYS>O1ImvE@*Lq~|F_P5BpD7@$Felk4Lr-t&u>;gxByEWk)RE)^u5QytmCJGOiQsR3
zWk44DS++GqrDtV=cD9?HJm%vO7S|$1dYh6mxt6Vzq~aCA;!_LLw80X7OD~T(yOC)3IhcD@tKHP>hb*N&0
z;30$!MjG+NFYixt#Y6CpAmi&dY=uf6!rJDvyxLxXG~Gt7J{^BAw~4;npHb|I+zRdS
zCItI7^n8Gr*37v3$8z(}GSX$ge>;(;D;6SRS2vR$s{{8o(w*_z%N=WC9`tbkR_lmF
zY`dcz7xs_0c59UBsx%TnN7n(C32Dd2bFH^y0I*T!S(?}fOh=r$2E$t4RNo!F!8QWQ
zw%L+t`}4UMvcSk_gMG+*$6C5EQLD(5Qj!xo!%Ep}zDydF&-alJCf7bA+C1RC%5`jv
zQIRQOp=|JUtgUnx7VYKp*4==5B%(DN2OWj=nJ(Yh_xi8_m9WzBsk9bCPrnHmWgs4V
z{GYyWUuedx#2La8F$!`F$nt<$k*dlSEyE{`8_k{i;qLo)T_w`{Ta>BLvE%
zSI-$5N&Zx8GTNEHMaN&bU|;hKiegT#Ner
zZtJdYuVtVMtpZJ~2~n0Uer$`8$AEzK?Y@h9q=Wwz$#AnzFp&K&I-p-tTG?VwwPl~?
z4cDSxG2W2UIt}k~Nvoo7wz2DXWXb}3Nn3>HH)Qe~u^@kJ=y7hDRS!f(wRFks2Z)D=
zh}yYDgKODa-dKhjCfl?*ds$fSr#(tYWgN3Gh#;2(LDG$9;q6Nj>su_=i
zx-2Ln?0Q^tDWnm-PDe4K!~5j)G9^!2<%x@F^wc-gV*tvZ4}>ejuY!VDjZ}FuYLQR)
zYaf*c%2sa9hgKeD@UYHjA9tF<=hSg53yGW!yJ<&D&k;{vV9NYPxq{Frmx+v@ISN?6`55ipeA~m)8{=Zeu1}Jq
z%le3gFCl5T`>SkcEwK}fRA)XE_^L46(4ZmRqusm68ASNhb)doLL>hP_b}f
zn#0R0BqXHNE~bDio;e;lb9oHfkEr^#vZCue6Ed>qUCa5F__!i9Y)4CF5z(m-N|Qb%
z=+#prO+j5Ytu0oz{n|_?Z0H^02kNb)YU0;2`N0|v<
z$+O<^8q%k7#>u@%;QPtbKARIur}E5eR-$c$SyAT-iFtXjkg+$Y3!I_A@BB^q92O}
zLd+bfUKC(Vq^;T~&eQb=p~#Fd%<{#owTs~6cYaP=*UjJON2Lku1*c|^%*h<_
z)Nr=9ZeG855&-iLX}h1Cqyj^PlFE
zE=@?X`C#FCiz+*6Y5--|gM
zDy7fd7&m_f9+T{Uz=`8mwJjoMp?j{BIr@Y^c8H|*qJ+fzHrwJ?^-cRgD};t3+nvf*
zZN~)a
z^@c`Xu}rnAzM-8;>DOk%Bf(xQ%HoO(5jK#FNvIHQs~ibGaw$nWfa8y$Omy)zCqJu;!oQKbFA
z>Zomlr_2k_=sN$wCgTv7B(RF7LqZ1D&uZY&qJkV-1l3!7qz=vMn@^bjK?gsOV}AYO
zbhBi??17u@le*K0X=2*vu6G~F{NK=)o;+yU@rv8=Qu`FdyGgFvwl1B&Jg$11660%S
z9x|ERuEwI@xMMmS&L-{`N5U16AnK~zs>#X}d6`4wfnGj4G&+9!$ld;Wt~rJ!yQ$Qj
z30%el`nK#PvDz3XuU`6~YEEC`hdf(*?El%)bM)7f*ooU&AU96;yYsz7a5M+wlgC1I
z9YokH<3Ef6-3dSn7V#f!nOW3=E}w63ebSk-WP~71;a&GuKZf4VU}94GJ_%e)`f^Q{
z&C|}ZJ-SQSptC`@VadLug7dSgLHJ6Fg>3gKdAs40%*eesQ}U3VyU#2XO7?As#BaNT
z0&%pKE!)ob6eR{neEGr~1ode~aE_;!m*QY{Bk#PYwT~OyiR$=k4mI2KSr=~QQ2}#{
zzik%P-?e4px=86bEx|gbR0|OkVKIA()&O5^#Cn5hMF(YpMgYvF?V*?|
zA=grIEbfl+m&zySe}7fzSO@|$2{qTO1fbzZF}s7?4>AZ2x!lck(^qa)QqbY70WN0)
zeKQc(bEw~&G0$pGFfg9wAmT=N+sWE
z6kR_BFUI(w`Mwov6BHTMF@TC#OH(K?e3mFQLP3C%J_);*S_N+l@VA>Ntl{9HHR5uc
za>InDWL`BS%z=07-hae;s}ts0lsr`EtpCZltURbFqvR6o1m0@*uysiq@o>>2waOdn
z5tFRp(W@L(rw|kZYCY#Rs+~+pp-moe(B#6~YM3L2%}`auu4|YZe~}<3T2^D4W$cyt
zGgUNc%|Kcuk=w2O>p%6M2;8oJ3@X6?HoZ$ESJ)vmjqXThF<0r-}jZ
z4tGA^GkzhWp~Ia(g548ua>qb&M7;m9Mn`#Z|7_!7@CPxgHqGyC;2sW$u;4vGw29a;
zg|DiDh9DZ22cbr&TJMgFP$Zg20qwJ0go?ya)LpNZZv1aIjR)7oqh
zR8B0kq~BUoGY{)^0m-E20P1xv!;~Y_67Bek=&&O1*uL?nQ&9DzF!{XMyUwD8a-QeW
zgI`qekN%IGvhP|<=S>RkannR<71=I5TOTyZpk&~p39~6Ftc9?c?(m1U%Y(dURp+Sy7e;bd#9KRpC*)$^aC^P
z`~}}6q)1Qk_X3JAicFnHMjzbo7LZ=ORuD<@yqgT(ch*?`cI-riHi*R%|EmL)Gbkm2
zicihDKU^c@db&5$Plk<)%ajA=u)n}SS+px}u>U.
+ }}
+{{!
+ @template core_customfield/display_field
+
+ Example context (json):
+ {
+ "hasvalue": 1,
+ "fieldtype" : "text",
+ "fieldname" : "Nick name",
+ "fieldshortname" : "nickname",
+ "fieldvalue" : "Star Lord"
+ }
+}}
+{{#hasvalue}}
+
+ {{{name}}}: {{{value}}}
+
+{{/hasvalue}}
diff --git a/templates/list.mustache b/templates/list.mustache
new file mode 100644
index 0000000..521b268
--- /dev/null
+++ b/templates/list.mustache
@@ -0,0 +1,131 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+ }}
+{{!
+ @template core_customfield/list
+
+ Moodle list template.
+
+ The purpose of this template is to render a list.
+
+ Classes required for JS:
+ * none
+
+ Data attributes required for JS:
+ * data-component
+ * data-area
+ * data-itemid
+
+ Context variables required for this template:
+ * attributes Array of name / value pairs.
+
+ Example context (json):
+ {
+ "component": "core_nonexisting",
+ "area": "course",
+ "itemid": 0,
+ "usescategories": 1,
+ "categories": [
+ { "id": "0",
+ "nameeditable": "Other fields",
+ "addfieldmenu": "Add field",
+ "fields": [
+ { "id": 0, "name": "Field name", "shortname": "shortname", "type": "Text" },
+ { "id": 0, "name": "Another field", "shortname": "checkme", "type": "Checkbox" }
+ ]
+ },
+ { "id": "00",
+ "nameeditable": "Empty category",
+ "addfieldmenu": "Add field",
+ "fields": [] }
+ ],
+ "singleselect" : "select"
+ }
+}}
+
+{{{alert}}}
+
+
+
+
+ {{^categories}}
+ {{{nocategories}}}
+ {{/categories}}
+
+
+ {{#categories}}
+
+
+
+ {{#usescategories}}
+
+ {{/usescategories}}
+
+
+ {{{addfieldmenu}}}
+
+
+
+
+
+
+ | {{#str}} customfield, core_customfield {{/str}} |
+ {{#str}} shortname, core_customfield {{/str}} |
+ {{#str}} type, core_customfield {{/str}} |
+ {{#str}} action, core_customfield {{/str}} |
+
+
+
+ {{#fields}}
+
+ | {{> core/drag_handle}}{{{name}}} |
+ {{{shortname}}} |
+ {{{type}}} |
+
+ {{#pix}}
+ t/edit, core, {{#str}} edit, moodle {{/str}} {{/pix}}
+ {{#pix}}
+ t/delete, core, {{#str}} delete, moodle {{/str}} {{/pix}}
+ |
+
+ {{/fields}}
+ {{^fields}}
+ {{> core_customfield/nofields }}
+ {{/fields}}
+
+
+
+
+ {{/categories}}
+
+
+
+{{#js}}
+ require(['core_customfield/form'], function(s) {
+ s.init();
+ });
+{{/js}}
diff --git a/templates/nofields.mustache b/templates/nofields.mustache
new file mode 100644
index 0000000..033449c
--- /dev/null
+++ b/templates/nofields.mustache
@@ -0,0 +1,39 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+ }}
+{{!
+ @template core_customfield/nofields
+
+ Moodle list template.
+
+ The purpose of this template is to render the nofields tbody.
+
+ Classes required for JS:
+ * none
+
+ Data attributes required for JS:
+ * data-component
+ * data-area
+ * data-itemid
+
+ Context variables required for this template:
+ * attributes Array of name / value pairs.
+
+ Example context (json):
+ {
+ }
+}}
+| {{# str }} therearenofields, core_customfield {{/ str }} |
diff --git a/tests/api_test.php b/tests/api_test.php
new file mode 100644
index 0000000..c3e4e2a
--- /dev/null
+++ b/tests/api_test.php
@@ -0,0 +1,254 @@
+.
+
+namespace core_customfield;
+
+/**
+ * Functional test for class \core_customfield\api
+ *
+ * @package core_customfield
+ * @category test
+ * @copyright 2018 Toni Barbera
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class api_test extends \advanced_testcase {
+
+ /**
+ * Get generator.
+ *
+ * @return core_customfield_generator
+ */
+ protected function get_generator(): \core_customfield_generator {
+ return $this->getDataGenerator()->get_plugin_generator('core_customfield');
+ }
+
+ /**
+ * Help to assert that the given property in an array of object has the expected value
+ *
+ * @param array $expected
+ * @param array $array array of objects with "get($property)" method
+ * @param string $propertyname
+ */
+ protected function assert_property_in_array($expected, $array, $propertyname) {
+ $this->assertEquals($expected, array_values(array_map(function($a) use ($propertyname) {
+ return $a->get($propertyname);
+ }, $array)));
+ }
+
+ /**
+ * Tests for \core_customfield\api::move_category() behaviour.
+ *
+ * This replicates what is happening when categories are moved
+ * in the interface using drag-drop.
+ */
+ public function test_move_category() {
+ $this->resetAfterTest();
+
+ // Create the categories.
+ $params = ['component' => 'core_course', 'area' => 'course', 'itemid' => 0];
+ $id0 = $this->get_generator()->create_category($params)->get('id');
+ $id1 = $this->get_generator()->create_category($params)->get('id');
+ $id2 = $this->get_generator()->create_category($params)->get('id');
+ $id3 = $this->get_generator()->create_category($params)->get('id');
+ $id4 = $this->get_generator()->create_category($params)->get('id');
+ $id5 = $this->get_generator()->create_category($params)->get('id');
+
+ // Check order after re-fetch.
+ $categories = api::get_categories_with_fields($params['component'], $params['area'], $params['itemid']);
+ $this->assertEquals([$id0, $id1, $id2, $id3, $id4, $id5], array_keys($categories));
+ $this->assert_property_in_array([0, 1, 2, 3, 4, 5], $categories, 'sortorder');
+
+ // Move up 1 position.
+ api::move_category(category_controller::create($id3), $id2);
+ $categories = api::get_categories_with_fields($params['component'], $params['area'], $params['itemid']);
+ $this->assertEquals([$id0, $id1, $id3, $id2, $id4, $id5], array_keys($categories));
+ $this->assert_property_in_array([0, 1, 2, 3, 4, 5], $categories, 'sortorder');
+
+ // Move down 1 position.
+ api::move_category(category_controller::create($id2), $id3);
+ $categories = api::get_categories_with_fields($params['component'], $params['area'], $params['itemid']);
+ $this->assertEquals([$id0, $id1, $id2, $id3, $id4, $id5], array_keys($categories));
+ $this->assert_property_in_array([0, 1, 2, 3, 4, 5], $categories, 'sortorder');
+
+ // Move up 2 positions.
+ api::move_category(category_controller::create($id4), $id2);
+ $categories = api::get_categories_with_fields($params['component'], $params['area'], $params['itemid']);
+ $this->assertEquals([$id0, $id1, $id4, $id2, $id3, $id5], array_keys($categories));
+ $this->assert_property_in_array([0, 1, 2, 3, 4, 5], $categories, 'sortorder');
+
+ // Move down 2 positions.
+ api::move_category(category_controller::create($id4), $id5);
+ $categories = api::get_categories_with_fields($params['component'], $params['area'], $params['itemid']);
+ $this->assertEquals([$id0, $id1, $id2, $id3, $id4, $id5], array_keys($categories));
+ $this->assert_property_in_array([0, 1, 2, 3, 4, 5], $categories, 'sortorder');
+
+ // Move to the end of the list.
+ api::move_category(category_controller::create($id2));
+ $categories = api::get_categories_with_fields($params['component'], $params['area'], $params['itemid']);
+ $this->assertEquals([$id0, $id1, $id3, $id4, $id5, $id2], array_keys($categories));
+ $this->assert_property_in_array([0, 1, 2, 3, 4, 5], $categories, 'sortorder');
+ }
+
+ /**
+ * Tests for \core_customfield\api::get_categories_with_fields() behaviour.
+ */
+ public function test_get_categories_with_fields() {
+ $this->resetAfterTest();
+
+ // Create the categories.
+ $options = [
+ 'component' => 'core_course',
+ 'area' => 'course',
+ 'itemid' => 0,
+ 'contextid' => \context_system::instance()->id
+ ];
+ $category0 = $this->get_generator()->create_category(['name' => 'aaaa'] + $options);
+ $category1 = $this->get_generator()->create_category(['name' => 'bbbb'] + $options);
+ $category2 = $this->get_generator()->create_category(['name' => 'cccc'] + $options);
+ $category3 = $this->get_generator()->create_category(['name' => 'dddd'] + $options);
+ $category4 = $this->get_generator()->create_category(['name' => 'eeee'] + $options);
+ $category5 = $this->get_generator()->create_category(['name' => 'ffff'] + $options);
+
+ // Let's test counts.
+ $this->assertCount(6, api::get_categories_with_fields($options['component'], $options['area'], $options['itemid']));
+ api::delete_category($category5);
+ $this->assertCount(5, api::get_categories_with_fields($options['component'], $options['area'], $options['itemid']));
+ api::delete_category($category4);
+ $this->assertCount(4, api::get_categories_with_fields($options['component'], $options['area'], $options['itemid']));
+ api::delete_category($category3);
+ $this->assertCount(3, api::get_categories_with_fields($options['component'], $options['area'], $options['itemid']));
+ api::delete_category($category2);
+ $this->assertCount(2, api::get_categories_with_fields($options['component'], $options['area'], $options['itemid']));
+ api::delete_category($category1);
+ $this->assertCount(1, api::get_categories_with_fields($options['component'], $options['area'], $options['itemid']));
+ api::delete_category($category0);
+ $this->assertCount(0, api::get_categories_with_fields($options['component'], $options['area'], $options['itemid']));
+ }
+
+ /**
+ * Test for functions api::save_category() and rename_category)
+ */
+ public function test_save_category() {
+ $this->resetAfterTest();
+
+ $params = ['component' => 'core_course', 'area' => 'course', 'itemid' => 0, 'name' => 'Cat1',
+ 'contextid' => \context_system::instance()->id];
+ $c1 = category_controller::create(0, (object)$params);
+ api::save_category($c1);
+ $this->assertNotEmpty($c1->get('id'));
+
+ $c1 = category_controller::create($c1->get('id'));
+ $expected = $params + ['sortorder' => 0, 'id' => $c1->get('id'), 'description' => '', 'descriptionformat' => 0];
+ $actual = array_intersect_key((array)$c1->to_record(), $expected); // Ignore timecreated, timemodified.
+ ksort($expected);
+ ksort($actual);
+ $this->assertEquals($expected, $actual);
+
+ // Create new category and check that the sortorder will be 1.
+ $params['name'] = 'Cat2';
+ $c2 = category_controller::create(0, (object)$params);
+ api::save_category($c2);
+ $this->assertNotEmpty($c2->get('id'));
+ $this->assertEquals(1, $c2->get('sortorder'));
+ $c2 = category_controller::create($c2->get('id'));
+ $this->assertEquals(1, $c2->get('sortorder'));
+
+ // Rename a category.
+ $c1->set('name', 'Cat3');
+ $c1->save();
+ $c1 = category_controller::create($c1->get('id'));
+ $this->assertEquals('Cat3', $c1->get('name'));
+ }
+
+ /**
+ * Test for function handler::create_category
+ */
+ public function test_create_category() {
+ $this->resetAfterTest();
+
+ $handler = \core_course\customfield\course_handler::create();
+ $c1id = $handler->create_category();
+ $c1 = $handler->get_categories_with_fields()[$c1id];
+ $this->assertEquals('Other fields', $c1->get('name'));
+ $this->assertEquals($handler->get_component(), $c1->get('component'));
+ $this->assertEquals($handler->get_area(), $c1->get('area'));
+ $this->assertEquals($handler->get_itemid(), $c1->get('itemid'));
+ $this->assertEquals($handler->get_configuration_context()->id, $c1->get('contextid'));
+
+ // Generate more categories and make sure they have different names.
+ $c2id = $handler->create_category();
+ $c3id = $handler->create_category();
+ $c2 = $handler->get_categories_with_fields()[$c2id];
+ $c3 = $handler->get_categories_with_fields()[$c3id];
+ $this->assertEquals('Other fields 1', $c2->get('name'));
+ $this->assertEquals('Other fields 2', $c3->get('name'));
+ }
+
+ /**
+ * Tests for \core_customfield\api::delete_category() behaviour.
+ */
+ public function test_delete_category_with_fields() {
+ $this->resetAfterTest();
+
+ global $DB;
+ // Create two categories with fields and data.
+ $options = [
+ 'component' => 'core_course',
+ 'area' => 'course',
+ 'itemid' => 0,
+ 'contextid' => \context_system::instance()->id
+ ];
+ $lpg = $this->get_generator();
+ $course = $this->getDataGenerator()->create_course();
+ $dataparams = ['instanceid' => $course->id, 'contextid' => \context_course::instance($course->id)->id];
+ $category0 = $lpg->create_category($options);
+ $category1 = $lpg->create_category($options);
+ for ($i = 0; $i < 6; $i++) {
+ $f = $lpg->create_field(['categoryid' => $category0->get('id')]);
+ \core_customfield\data_controller::create(0, (object)$dataparams, $f)->save();
+ $f = $lpg->create_field(['categoryid' => $category1->get('id')]);
+ \core_customfield\data_controller::create(0, (object)$dataparams, $f)->save();
+ }
+
+ // Check that each category have fields and store ids for future checks.
+ list($category0, $category1) = array_values(api::get_categories_with_fields($options['component'],
+ $options['area'], $options['itemid']));
+ $category0fieldsids = array_keys($category0->get_fields());
+ $category1fieldsids = array_keys($category1->get_fields());
+
+ // There are 6 records in field table and 6 records in data table for each category.
+ list($sql, $p) = $DB->get_in_or_equal($category0fieldsids);
+ $this->assertCount(6, $DB->get_records_select(\core_customfield\field::TABLE, 'id '.$sql, $p));
+ $this->assertCount(6, $DB->get_records_select(\core_customfield\data::TABLE, 'fieldid '.$sql, $p));
+
+ list($sql, $p) = $DB->get_in_or_equal($category1fieldsids);
+ $this->assertCount(6, $DB->get_records_select(\core_customfield\field::TABLE, 'id '.$sql, $p));
+ $this->assertCount(6, $DB->get_records_select(\core_customfield\data::TABLE, 'fieldid '.$sql, $p));
+
+ // Delete one category.
+ $this->assertTrue($category0->get_handler()->delete_category($category0));
+
+ // Check that the category fields and data were deleted.
+ list($sql, $p) = $DB->get_in_or_equal($category0fieldsids);
+ $this->assertEmpty($DB->get_records_select(\core_customfield\field::TABLE, 'id '.$sql, $p));
+ $this->assertEmpty($DB->get_records_select(\core_customfield\data::TABLE, 'fieldid '.$sql, $p));
+
+ // Check that fields and data for the other category remain.
+ list($sql, $p) = $DB->get_in_or_equal($category1fieldsids);
+ $this->assertCount(6, $DB->get_records_select(\core_customfield\field::TABLE, 'id '.$sql, $p));
+ $this->assertCount(6, $DB->get_records_select(\core_customfield\data::TABLE, 'fieldid '.$sql, $p));
+ }
+}
diff --git a/tests/behat/edit_categories.feature b/tests/behat/edit_categories.feature
new file mode 100644
index 0000000..b44a661
--- /dev/null
+++ b/tests/behat/edit_categories.feature
@@ -0,0 +1,104 @@
+@core @core_course @core_customfield @javascript
+Feature: Managers can manage categories for course custom fields
+ In order to have additional data on the course
+ As a manager
+ I need to create, edit, remove and sort custom field's categories
+
+ Scenario: Create a category for custom course fields
+ Given I log in as "admin"
+ When I navigate to "Courses > Course custom fields" in site administration
+ And I press "Add a new category"
+ And I wait until the page is ready
+ Then I should see "Other fields" in the "#customfield_catlist" "css_element"
+ And I navigate to "Reports > Logs" in site administration
+ And I press "Get these logs"
+
+ Scenario: Edit a category name for custom course fields
+ Given the following "custom field categories" exist:
+ | name | component | area | itemid |
+ | Category for test | core_course | course | 0 |
+ And I log in as "admin"
+ And I navigate to "Courses > Course custom fields" in site administration
+ And I set the field "Edit category name" in the "//div[contains(@class,'categoryinstance') and contains(.,'Category for test')]" "xpath_element" to "Good fields"
+ Then I should not see "Category for test" in the "#customfield_catlist" "css_element"
+ And "New value for Category for test" "field" should not exist
+ And I should see "Good fields" in the "#customfield_catlist" "css_element"
+ And I navigate to "Reports > Logs" in site administration
+ And I press "Get these logs"
+
+ Scenario: Delete a category for custom course fields
+ Given the following "custom field categories" exist:
+ | name | component | area | itemid |
+ | Category for test | core_course | course | 0 |
+ And the following "custom fields" exist:
+ | name | category | type | shortname |
+ | Field 1 | Category for test | text | f1 |
+ And I log in as "admin"
+ And I navigate to "Courses > Course custom fields" in site administration
+ And I click on "[data-role='deletecategory']" "css_element"
+ And I click on "Yes" "button" in the "Confirm" "dialogue"
+ And I wait until the page is ready
+ And I wait until "Test category" "text" does not exist
+ Then I should not see "Test category" in the "#customfield_catlist" "css_element"
+ And I navigate to "Reports > Logs" in site administration
+ And I press "Get these logs"
+
+ Scenario: Move field in the course custom fields to another category
+ Given the following "custom field categories" exist:
+ | name | component | area | itemid |
+ | Category1 | core_course | course | 0 |
+ | Category2 | core_course | course | 0 |
+ | Category3 | core_course | course | 0 |
+ And the following "custom fields" exist:
+ | name | category | type | shortname |
+ | Field1 | Category1 | text | f1 |
+ | Field2 | Category2 | text | f2 |
+ When I log in as "admin"
+ And I navigate to "Courses > Course custom fields" in site administration
+ Then "Field1" "text" should appear after "Category1" "text"
+ And "Category2" "text" should appear after "Field1" "text"
+ And "Field2" "text" should appear after "Category2" "text"
+ And "Category3" "text" should appear after "Field2" "text"
+ And I press "Move \"Field1\""
+ And I follow "To the top of category Category2"
+ And "Category2" "text" should appear after "Category1" "text"
+ And "Field1" "text" should appear after "Category2" "text"
+ And "Field2" "text" should appear after "Field1" "text"
+ And "Category3" "text" should appear after "Field2" "text"
+ And I navigate to "Courses > Course custom fields" in site administration
+ And "Category2" "text" should appear after "Category1" "text"
+ And "Field1" "text" should appear after "Category2" "text"
+ And "Field2" "text" should appear after "Field1" "text"
+ And "Category3" "text" should appear after "Field2" "text"
+ And I press "Move \"Field1\""
+ And I follow "After field Field2"
+ And "Field1" "text" should appear after "Field2" "text"
+
+ Scenario: Reorder course custom field categories
+ Given the following "custom field categories" exist:
+ | name | component | area | itemid |
+ | Category1 | core_course | course | 0 |
+ | Category2 | core_course | course | 0 |
+ | Category3 | core_course | course | 0 |
+ And the following "custom fields" exist:
+ | name | category | type | shortname |
+ | Field1 | Category1 | text | f1 |
+ When I log in as "admin"
+ And I navigate to "Courses > Course custom fields" in site administration
+ Then "Field1" "text" should appear after "Category1" "text"
+ And "Category2" "text" should appear after "Field1" "text"
+ And "Category3" "text" should appear after "Category2" "text"
+ And I press "Move \"Category2\""
+ And I follow "After \"Category3\""
+ And "Field1" "text" should appear after "Category1" "text"
+ And "Category3" "text" should appear after "Field1" "text"
+ And "Category2" "text" should appear after "Category3" "text"
+ And I navigate to "Courses > Course custom fields" in site administration
+ And "Field1" "text" should appear after "Category1" "text"
+ And "Category3" "text" should appear after "Field1" "text"
+ And "Category2" "text" should appear after "Category3" "text"
+ And I press "Move \"Category2\""
+ And I follow "After \"Category1\""
+ And "Field1" "text" should appear after "Category1" "text"
+ And "Category2" "text" should appear after "Field1" "text"
+ And "Category3" "text" should appear after "Category2" "text"
diff --git a/tests/behat/edit_fields_settings.feature b/tests/behat/edit_fields_settings.feature
new file mode 100644
index 0000000..90c9a32
--- /dev/null
+++ b/tests/behat/edit_fields_settings.feature
@@ -0,0 +1,118 @@
+@core @core_course @core_customfield @javascript
+Feature: Teachers can edit course custom fields
+ In order to have additional data on the course
+ As a teacher
+ I need to edit data for custom fields
+
+ Background:
+ Given the following "custom field categories" exist:
+ | name | component | area | itemid |
+ | Category for test | core_course | course | 0 |
+ And the following "custom fields" exist:
+ | name | category | type | shortname | description | configdata |
+ | Field 1 | Category for test | text | f1 | d1 | |
+ | Field 2 | Category for test | textarea | f2 | d2 | |
+ | Field 3 | Category for test | checkbox | f3 | d3 | |
+ | Field 4 | Category for test | date | f4 | d4 | |
+ | Field 5 | Category for test | select | f5 | d5 | {"options":"a\nb\nc"} |
+ And the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | format |
+ | Course 1 | C1 | topics |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ And the following "blocks" exist:
+ | blockname | contextlevel | reference | pagetypepattern | defaultregion |
+ | private_files | System | 1 | my-index | side-post |
+
+ Scenario: Display custom fields on course edit form
+ When I log in as "teacher1"
+ And I am on "Course 1" course homepage
+ And I navigate to "Settings" in current page administration
+ And I expand all fieldsets
+ Then I should see "Category for test"
+ And I should see "Field 1"
+ And I should see "Field 2"
+ And I should see "Field 3"
+ And I should see "Field 4"
+ And I should see "Field 5"
+ And I log out
+
+ Scenario: Create a course with custom fields from the management interface
+ When I log in as "admin"
+ And I go to the courses management page
+ And I should see the "Categories" management page
+ And I click on category "Category 1" in the management interface
+ And I should see the "Course categories and courses" management page
+ And I click on "Create new course" "link" in the "#course-listing" "css_element"
+ And I set the following fields to these values:
+ | Course full name | Course 2 |
+ | Course short name | C2 |
+ | Field 1 | testcontent1 |
+ | Field 2 | testcontent2 |
+ | Field 3 | 1 |
+ | customfield_f4[enabled] | 1 |
+ | customfield_f4[day] | 1 |
+ | customfield_f4[month] | January |
+ | customfield_f4[year] | 2019 |
+ | Field 5 | b |
+ And I press "Save and display"
+ And I navigate to "Settings" in current page administration
+ And the following fields match these values:
+ | Course full name | Course 2 |
+ | Course short name | C2 |
+ | Field 1 | testcontent1 |
+ | Field 2 | testcontent2 |
+ | Field 3 | 1 |
+ | customfield_f4[day] | 1 |
+ | customfield_f4[month] | January |
+ | customfield_f4[year] | 2019 |
+ | Field 5 | b |
+ And I log out
+
+ @javascript @_file_upload
+ Scenario: Use images in the custom field description
+ When I log in as "admin"
+ And I follow "Manage private files"
+ And I upload "lib/tests/fixtures/gd-logo.png" file to "Files" filemanager
+ And I click on "Save changes" "button"
+ And I navigate to "Courses > Course custom fields" in site administration
+ And I click on "Edit" "link" in the "Field 1" "table_row"
+ And I select the text in the "Description" Atto editor
+ And I click on "Insert or edit image" "button" in the "Description" "form_row"
+ And I click on "Browse repositories..." "button"
+ And I click on "Private files" "link" in the ".fp-repo-area" "css_element"
+ And I click on "gd-logo.png" "link"
+ And I click on "Select this file" "button"
+ And I set the field "Describe this image for someone who cannot see it" to "Example"
+ And I click on "Save image" "button"
+ And I click on "Save changes" "button" in the "Updating Field 1" "dialogue"
+ And I log out
+ And I log in as "teacher1"
+ And I am on "Course 1" course homepage
+ And I navigate to "Settings" in current page administration
+ And I expand all fieldsets
+ Then the image at "//div[contains(@class, 'fitem')][contains(., 'Field 1')]/following-sibling::div[1]//img[contains(@src, 'pluginfile.php') and contains(@src, '/core_customfield/description/') and @alt='Example']" "xpath_element" should be identical to "lib/tests/fixtures/gd-logo.png"
+ And I log out
+
+ @javascript
+ Scenario: Custom field short name must be present and unique
+ When I log in as "admin"
+ And I navigate to "Courses > Course custom fields" in site administration
+ And I click on "Add a new custom field" "link"
+ And I click on "Short text" "link"
+ And I set the following fields to these values:
+ | Name | Test field |
+ And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue"
+ Then I should see "You must supply a value here" in the "Short name" "form_row"
+ And I set the field "Short name" to "short name"
+ And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue"
+ And I should see "The short name can only contain alphanumeric lowercase characters and underscores (_)." in the "Short name" "form_row"
+ And I set the field "Short name" to "f1"
+ And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue"
+ And I should see "Short name already exists" in the "Short name" "form_row"
+ And I click on "Cancel" "button" in the "Adding a new Short text" "dialogue"
+ And I log out
diff --git a/tests/behat/required_field.feature b/tests/behat/required_field.feature
new file mode 100644
index 0000000..a164f11
--- /dev/null
+++ b/tests/behat/required_field.feature
@@ -0,0 +1,58 @@
+@core @core_course @core_customfield @javascript
+Feature: Requiredness The course custom fields can be mandatory or not
+ In order to make users required to fill a custom field
+ As a manager
+ I can change the requiredness of the fields
+
+ Background:
+ Given the following "custom field categories" exist:
+ | name | component | area | itemid |
+ | Category for test | core_course | course | 0 |
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | format |
+ | Course 1 | C1 | topics |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+
+ Scenario: A required course custom field must be filled when editing course settings
+ When I log in as "admin"
+ And I navigate to "Courses > Course custom fields" in site administration
+ And I click on "Add a new custom field" "link"
+ And I click on "Short text" "link"
+ And I set the following fields to these values:
+ | Name | Test field |
+ | Short name | testfield |
+ | Required | Yes |
+ And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue"
+ And I log out
+ And I log in as "teacher1"
+ And I am on "Course 1" course homepage
+ And I navigate to "Settings" in current page administration
+ And I press "Save and display"
+ Then I should see "You must supply a value here"
+ And I set the field "Test field" to "some value"
+ And I press "Save and display"
+ And I should not see "This field is required"
+ And I log out
+
+ Scenario: A course custom field that is not required may not be filled
+ When I log in as "admin"
+ And I navigate to "Courses > Course custom fields" in site administration
+ And I click on "Add a new custom field" "link"
+ And I click on "Short text" "link"
+ And I set the following fields to these values:
+ | Name | Test field |
+ | Short name | testfield |
+ | Required | No |
+ And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue"
+ And I log out
+ And I log in as "teacher1"
+ And I am on "Course 1" course homepage
+ And I navigate to "Settings" in current page administration
+ And I press "Save and display"
+ Then I should see "Course 1"
+ And I should see "Topic 1"
diff --git a/tests/behat/unique_field.feature b/tests/behat/unique_field.feature
new file mode 100644
index 0000000..adb59d8
--- /dev/null
+++ b/tests/behat/unique_field.feature
@@ -0,0 +1,74 @@
+@core @core_course @core_customfield @javascript
+Feature: Uniqueness The course custom fields can be mandatory or not
+ In order to make users required to fill a custom field
+ As a manager
+ I can change the uniqueness of the fields
+
+ Background:
+ Given the following "custom field categories" exist:
+ | name | component | area | itemid |
+ | Category for test | core_course | course | 0 |
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | format |
+ | Course 1 | C1 | topics |
+ | Course 2 | C2 | topics |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ | teacher1 | C2 | editingteacher |
+ When I log in as "admin"
+ And I navigate to "Courses > Course custom fields" in site administration
+ And I click on "Add a new custom field" "link"
+ And I click on "Short text" "link"
+ And I set the following fields to these values:
+ | Name | Test field |
+ | Short name | testfield |
+ | Unique data | Yes |
+ And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue"
+ And I log out
+
+ Scenario: A course custom field with unique data must not allow same data in same field in different courses
+ When I log in as "teacher1"
+ And I am on "Course 1" course homepage
+ And I navigate to "Settings" in current page administration
+ And I set the following fields to these values:
+ | Test field | testcontent |
+ And I press "Save and display"
+ And I am on "Course 2" course homepage
+ And I navigate to "Settings" in current page administration
+ And I set the following fields to these values:
+ | Test field | testcontent |
+ And I press "Save and display"
+ Then I should see "This value is already used"
+
+ Scenario: A course custom field with unique data must not compare with itself
+ When I log in as "teacher1"
+ And I am on "Course 1" course homepage
+ And I navigate to "Settings" in current page administration
+ And I set the following fields to these values:
+ | Test field | testcontent |
+ And I press "Save and display"
+ And I am on "Course 1" course homepage
+ And I navigate to "Settings" in current page administration
+ And I set the following fields to these values:
+ | Test field | testcontent |
+ And I press "Save and display"
+ Then I should not see "This value is already used"
+ And I should see "Topic 1"
+
+ Scenario: A course custom field with unique data must allow empty data
+ When I log in as "teacher1"
+ And I am on "Course 1" course homepage
+ And I navigate to "Settings" in current page administration
+ And I set the following fields to these values:
+ | Test field | |
+ And I press "Save and display"
+ And I am on "Course 2" course homepage
+ And I navigate to "Settings" in current page administration
+ And I set the following fields to these values:
+ | Test field | |
+ And I press "Save and display"
+ Then I should not see "This value is already used"
diff --git a/tests/category_controller_test.php b/tests/category_controller_test.php
new file mode 100644
index 0000000..5179a41
--- /dev/null
+++ b/tests/category_controller_test.php
@@ -0,0 +1,251 @@
+.
+
+namespace core_customfield;
+
+use core_customfield_generator;
+
+/**
+ * Functional test for class \core_customfield\category_controller.
+ *
+ * @package core_customfield
+ * @category test
+ * @copyright 2018 Toni Barbera
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class category_controller_test extends \advanced_testcase {
+
+ /**
+ * Get generator.
+ *
+ * @return core_customfield_generator
+ */
+ protected function get_generator(): core_customfield_generator {
+ return $this->getDataGenerator()->get_plugin_generator('core_customfield');
+ }
+
+ /**
+ * Test for the field_controller::__construct function.
+ */
+ public function test_constructor() {
+ $this->resetAfterTest();
+
+ $c = category_controller::create(0, (object)['component' => 'core_course', 'area' => 'course', 'itemid' => 0]);
+ $handler = $c->get_handler();
+ $this->assertTrue($c instanceof category_controller);
+
+ $cat = $this->get_generator()->create_category();
+ $c = category_controller::create($cat->get('id'));
+ $this->assertTrue($c instanceof category_controller);
+
+ $c = category_controller::create($cat->get('id'), null, $handler);
+ $this->assertTrue($c instanceof category_controller);
+
+ $c = category_controller::create(0, $cat->to_record());
+ $this->assertTrue($c instanceof category_controller);
+
+ $c = category_controller::create(0, $cat->to_record(), $handler);
+ $this->assertTrue($c instanceof category_controller);
+ }
+
+ /**
+ * Test for function \core_customfield\field_controller::create() in case of wrong parameters
+ */
+ public function test_constructor_errors() {
+ global $DB;
+ $this->resetAfterTest();
+
+ $cat = $this->get_generator()->create_category();
+ $catrecord = $cat->to_record();
+
+ // Both id and record give warning.
+ $c = category_controller::create($catrecord->id, $catrecord);
+ $debugging = $this->getDebuggingMessages();
+ $this->assertEquals(1, count($debugging));
+ $this->assertEquals('Too many parameters, either id need to be specified or a record, but not both.',
+ $debugging[0]->message);
+ $this->resetDebugging();
+ $this->assertTrue($c instanceof category_controller);
+
+ // Retrieve non-existing data.
+ try {
+ category_controller::create($catrecord->id + 1);
+ $this->fail('Expected exception');
+ } catch (\moodle_exception $e) {
+ $this->assertEquals('Category not found', $e->getMessage());
+ $this->assertEquals(\moodle_exception::class, get_class($e));
+ }
+
+ // Missing required elements.
+ try {
+ category_controller::create(0, (object)['area' => 'course', 'itemid' => 0]);
+ $this->fail('Expected exception');
+ } catch (\coding_exception $e) {
+ $this->assertEquals('Coding error detected, it must be fixed by a programmer: Not enough parameters ' .
+ 'to initialise category_controller - unknown component', $e->getMessage());
+ $this->assertEquals(\coding_exception::class, get_class($e));
+ }
+
+ // Missing required elements.
+ try {
+ category_controller::create(0, (object)['component' => 'core_course', 'itemid' => 0]);
+ $this->fail('Expected exception');
+ } catch (\coding_exception $e) {
+ $this->assertEquals('Coding error detected, it must be fixed by a programmer: Not enough parameters ' .
+ 'to initialise category_controller - unknown area', $e->getMessage());
+ $this->assertEquals(\coding_exception::class, get_class($e));
+ }
+
+ // Missing required elements.
+ try {
+ category_controller::create(0, (object)['component' => 'core_course', 'area' => 'course']);
+ $this->fail('Expected exception');
+ } catch (\coding_exception $e) {
+ $this->assertEquals('Coding error detected, it must be fixed by a programmer: Not enough parameters ' .
+ 'to initialise category_controller - unknown itemid', $e->getMessage());
+ $this->assertEquals(\coding_exception::class, get_class($e));
+ }
+
+ $handler = \core_course\customfield\course_handler::create();
+ // Missing required elements.
+ try {
+ category_controller::create(0, (object)['component' => 'x', 'area' => 'course', 'itemid' => 0], $handler);
+ $this->fail('Expected exception');
+ } catch (\coding_exception $e) {
+ $this->assertEquals('Coding error detected, it must be fixed by a programmer: Component of the handler ' .
+ 'does not match the one from the record', $e->getMessage());
+ $this->assertEquals(\coding_exception::class, get_class($e));
+ }
+
+ try {
+ category_controller::create(0, (object)['component' => 'core_course', 'area' => 'x', 'itemid' => 0], $handler);
+ $this->fail('Expected exception');
+ } catch (\coding_exception $e) {
+ $this->assertEquals('Coding error detected, it must be fixed by a programmer: Area of the handler ' .
+ 'does not match the one from the record', $e->getMessage());
+ $this->assertEquals(\coding_exception::class, get_class($e));
+ }
+
+ try {
+ category_controller::create(0, (object)['component' => 'core_course', 'area' => 'course', 'itemid' => 1], $handler);
+ $this->fail('Expected exception');
+ } catch (\coding_exception $e) {
+ $this->assertEquals('Coding error detected, it must be fixed by a programmer: Itemid of the ' .
+ 'handler does not match the one from the record', $e->getMessage());
+ $this->assertEquals(\coding_exception::class, get_class($e));
+ }
+
+ try {
+ $user = $this->getDataGenerator()->create_user();
+ category_controller::create(0, (object)['component' => 'core_course', 'area' => 'course', 'itemid' => 0,
+ 'contextid' => \context_user::instance($user->id)->id], $handler);
+ $this->fail('Expected exception');
+ } catch (\coding_exception $e) {
+ $this->assertEquals('Coding error detected, it must be fixed by a programmer: Context of the ' .
+ 'handler does not match the one from the record', $e->getMessage());
+ $this->assertEquals(\coding_exception::class, get_class($e));
+ }
+ }
+
+ /**
+ * Tests for behaviour of:
+ * \core_customfield\category_controller::save()
+ * \core_customfield\category_controller::get()
+ */
+ public function test_create_category() {
+ $this->resetAfterTest();
+
+ // Create the category.
+ $lpg = $this->get_generator();
+ $categorydata = new \stdClass();
+ $categorydata->name = 'Category1';
+ $categorydata->component = 'core_course';
+ $categorydata->area = 'course';
+ $categorydata->itemid = 0;
+ $categorydata->contextid = \context_system::instance()->id;
+ $category = category_controller::create(0, $categorydata);
+ $category->save();
+ $this->assertNotEmpty($category->get('id'));
+
+ // Confirm record exists.
+ $this->assertTrue(\core_customfield\category::record_exists($category->get('id')));
+
+ // Confirm that base data was inserted correctly.
+ $category = category_controller::create($category->get('id'));
+ $this->assertSame($category->get('name'), $categorydata->name);
+ $this->assertSame($category->get('component'), $categorydata->component);
+ $this->assertSame($category->get('area'), $categorydata->area);
+ $this->assertSame((int)$category->get('itemid'), $categorydata->itemid);
+ }
+
+ /**
+ * Tests for \core_customfield\category_controller::set() behaviour.
+ */
+ public function test_rename_category() {
+ $this->resetAfterTest();
+
+ // Create the category.
+ $params = ['component' => 'core_course', 'area' => 'course', 'itemid' => 0, 'name' => 'Cat1',
+ 'contextid' => \context_system::instance()->id];
+ $c1 = category_controller::create(0, (object)$params);
+ $c1->save();
+ $this->assertNotEmpty($c1->get('id'));
+
+ // Checking new name are correct updated.
+ $category = category_controller::create($c1->get('id'));
+ $category->set('name', 'Cat2');
+ $this->assertSame('Cat2', $category->get('name'));
+
+ // Checking new name are correct updated after save.
+ $category->save();
+
+ $category = category_controller::create($c1->get('id'));
+ $this->assertSame('Cat2', $category->get('name'));
+ }
+
+ /**
+ * Tests for \core_customfield\category_controller::delete() behaviour.
+ */
+ public function test_delete_category() {
+ $this->resetAfterTest();
+
+ // Create the category.
+ $lpg = $this->get_generator();
+ $category0 = $lpg->create_category();
+ $id0 = $category0->get('id');
+
+ $category1 = $lpg->create_category();
+ $id1 = $category1->get('id');
+
+ $category2 = $lpg->create_category();
+ $id2 = $category2->get('id');
+
+ // Confirm that exist in the database.
+ $this->assertTrue(\core_customfield\category::record_exists($id0));
+
+ // Delete and confirm that is deleted.
+ $category0->delete();
+ $this->assertFalse(\core_customfield\category::record_exists($id0));
+
+ // Confirm correct order after delete.
+ // Check order after re-fetch.
+ $category1 = category_controller::create($id1);
+ $category2 = category_controller::create($id2);
+
+ $this->assertSame((int) $category1->get('sortorder'), 1);
+ $this->assertSame((int) $category2->get('sortorder'), 2);
+ }
+}
diff --git a/tests/data_controller_test.php b/tests/data_controller_test.php
new file mode 100644
index 0000000..56b86c2
--- /dev/null
+++ b/tests/data_controller_test.php
@@ -0,0 +1,180 @@
+.
+
+namespace core_customfield;
+
+use core_customfield_generator;
+use customfield_checkbox;
+use customfield_date;
+use customfield_select;
+use customfield_text;
+use customfield_textarea;
+
+/**
+ * Functional test for class data_controller.
+ *
+ * @package core_customfield
+ * @category test
+ * @copyright 2018 Toni Barbera
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class data_controller_test extends \advanced_testcase {
+
+ /**
+ * Get generator.
+ *
+ * @return core_customfield_generator
+ */
+ protected function get_generator(): core_customfield_generator {
+ return $this->getDataGenerator()->get_plugin_generator('core_customfield');
+ }
+
+ /**
+ * Test for function data_controller::create()
+ */
+ public function test_constructor() {
+ global $DB;
+ $this->resetAfterTest();
+
+ // Create a course, fields category and fields.
+ $course = $this->getDataGenerator()->create_course();
+ $category0 = $this->get_generator()->create_category(['name' => 'aaaa']);
+
+ // Add fields to this category.
+ $fielddata = new \stdClass();
+ $fielddata->categoryid = $category0->get('id');
+ $fielddata->configdata = "{\"required\":\"0\",\"uniquevalues\":\"0\",\"locked\":\"0\",\"visibility\":\"0\",
+ \"defaultvalue\":\"\",\"displaysize\":0,\"maxlength\":0,\"ispassword\":\"0\",
+ \"link\":\"\",\"linktarget\":\"\"}";
+
+ $fielddata->type = 'checkbox';
+ $field0 = $this->get_generator()->create_field($fielddata);
+ $fielddata->type = 'date';
+ $field1 = $this->get_generator()->create_field($fielddata);
+ $fielddata->type = 'select';
+ $field2 = $this->get_generator()->create_field($fielddata);
+ $fielddata->type = 'text';
+ $field3 = $this->get_generator()->create_field($fielddata);
+ $fielddata->type = 'textarea';
+ $field4 = $this->get_generator()->create_field($fielddata);
+
+ $params = ['instanceid' => $course->id, 'contextid' => \context_course::instance($course->id)->id];
+
+ // Generate new data_controller records for these fields, specifying field controller or fieldid or both.
+ $data0 = data_controller::create(0, (object)$params, $field0);
+ $this->assertInstanceOf(customfield_checkbox\data_controller::class, $data0);
+ $data1 = data_controller::create(0,
+ (object)($params + ['fieldid' => $field1->get('id')]), $field1);
+ $this->assertInstanceOf(customfield_date\data_controller::class, $data1);
+ $data2 = data_controller::create(0,
+ (object)($params + ['fieldid' => $field2->get('id')]));
+ $this->assertInstanceOf(customfield_select\data_controller::class, $data2);
+ $data3 = data_controller::create(0, (object)$params, $field3);
+ $this->assertInstanceOf(customfield_text\data_controller::class, $data3);
+ $data4 = data_controller::create(0, (object)$params, $field4);
+ $this->assertInstanceOf(customfield_textarea\data_controller::class, $data4);
+
+ // Save data so we can have ids.
+ $data0->save();
+ $data1->save();
+ $data2->save();
+ $data3->save();
+ $data4->save();
+
+ // Retrieve data by id.
+ $this->assertInstanceOf(customfield_checkbox\data_controller::class, data_controller::create($data0->get('id')));
+ $this->assertInstanceOf(customfield_date\data_controller::class, data_controller::create($data1->get('id')));
+
+ // Retrieve data by id and field.
+ $this->assertInstanceOf(customfield_select\data_controller::class,
+ data_controller::create($data2->get('id'), null, $field2));
+
+ // Retrieve data by record without field.
+ $datarecord = $DB->get_record(\core_customfield\data::TABLE, ['id' => $data3->get('id')], '*', MUST_EXIST);
+ $this->assertInstanceOf(customfield_text\data_controller::class, data_controller::create(0, $datarecord));
+
+ // Retrieve data by record with field.
+ $datarecord = $DB->get_record(\core_customfield\data::TABLE, ['id' => $data4->get('id')], '*', MUST_EXIST);
+ $this->assertInstanceOf(customfield_textarea\data_controller::class, data_controller::create(0, $datarecord, $field4));
+
+ }
+
+ /**
+ * Test for function \core_customfield\field_controller::create() in case of wrong parameters
+ */
+ public function test_constructor_errors() {
+ global $DB;
+ $this->resetAfterTest();
+
+ // Create a category, field and data.
+ $category = $this->get_generator()->create_category();
+ $field = $this->get_generator()->create_field(['categoryid' => $category->get('id')]);
+ $course = $this->getDataGenerator()->create_course();
+ $data = data_controller::create(0, (object)['instanceid' => $course->id,
+ 'contextid' => \context_course::instance($course->id)->id], $field);
+ $data->save();
+
+ $datarecord = $DB->get_record(\core_customfield\data::TABLE, ['id' => $data->get('id')], '*', MUST_EXIST);
+
+ // Both id and record give warning.
+ $d = data_controller::create($datarecord->id, $datarecord);
+ $debugging = $this->getDebuggingMessages();
+ $this->assertEquals(1, count($debugging));
+ $this->assertEquals('Too many parameters, either id need to be specified or a record, but not both.',
+ $debugging[0]->message);
+ $this->resetDebugging();
+ $this->assertInstanceOf(customfield_text\data_controller::class, $d);
+
+ // Retrieve non-existing data.
+ try {
+ data_controller::create($datarecord->id + 1);
+ $this->fail('Expected exception');
+ } catch (\dml_missing_record_exception $e) {
+ $this->assertStringMatchesFormat('Can\'t find data record in database table customfield_data%a', $e->getMessage());
+ $this->assertEquals(\dml_missing_record_exception::class, get_class($e));
+ }
+
+ // Missing field id.
+ try {
+ data_controller::create(0, (object)['instanceid' => $course->id]);
+ $this->fail('Expected exception');
+ } catch (\coding_exception $e) {
+ $this->assertEquals('Coding error detected, it must be fixed by a programmer: Not enough parameters to ' .
+ 'initialise data_controller - unknown field', $e->getMessage());
+ $this->assertEquals(\coding_exception::class, get_class($e));
+ }
+
+ // Mismatching field id.
+ try {
+ data_controller::create(0, (object)['instanceid' => $course->id, 'fieldid' => $field->get('id') + 1], $field);
+ $this->fail('Expected exception');
+ } catch (\coding_exception $e) {
+ $this->assertEquals('Coding error detected, it must be fixed by a programmer: Field id from the record ' .
+ 'does not match field from the parameter', $e->getMessage());
+ $this->assertEquals(\coding_exception::class, get_class($e));
+ }
+
+ // Nonexisting class.
+ try {
+ $field->set('type', 'invalid');
+ data_controller::create(0, (object)['instanceid' => $course->id], $field);
+ $this->fail('Expected exception');
+ } catch (\moodle_exception $e) {
+ $this->assertEquals('Field type invalid not found', $e->getMessage());
+ $this->assertEquals(\moodle_exception::class, get_class($e));
+ }
+ }
+}
diff --git a/tests/field_controller_test.php b/tests/field_controller_test.php
new file mode 100644
index 0000000..1600310
--- /dev/null
+++ b/tests/field_controller_test.php
@@ -0,0 +1,245 @@
+.
+
+namespace core_customfield;
+
+use core_customfield_generator;
+use customfield_checkbox;
+use customfield_date;
+use customfield_select;
+use customfield_text;
+use customfield_textarea;
+
+/**
+ * Functional test for class \core_customfield\field_controller.
+ *
+ * @package core_customfield
+ * @category test
+ * @copyright 2018 Ruslan Kabalin
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class field_controller_test extends \advanced_testcase {
+
+ /**
+ * Get generator.
+ *
+ * @return core_customfield_generator
+ */
+ protected function get_generator(): core_customfield_generator {
+ return $this->getDataGenerator()->get_plugin_generator('core_customfield');
+ }
+
+ /**
+ * Test for function \core_customfield\field_controller::create()
+ */
+ public function test_constructor() {
+ global $DB;
+ $this->resetAfterTest();
+
+ // Create the category.
+ $category0 = $this->get_generator()->create_category();
+
+ // Initiate objects without id, try with the category object or with category id or with both.
+ $field0 = field_controller::create(0, (object)['type' => 'checkbox'], $category0);
+ $this->assertInstanceOf(customfield_checkbox\field_controller::class, $field0);
+ $field1 = field_controller::create(0, (object)['type' => 'date', 'categoryid' => $category0->get('id')]);
+ $this->assertInstanceOf(customfield_date\field_controller::class, $field1);
+ $field2 = field_controller::create(0, (object)['type' => 'select', 'categoryid' => $category0->get('id')], $category0);
+ $this->assertInstanceOf(customfield_select\field_controller::class, $field2);
+ $field3 = field_controller::create(0, (object)['type' => 'text'], $category0);
+ $this->assertInstanceOf(customfield_text\field_controller::class, $field3);
+ $field4 = field_controller::create(0, (object)['type' => 'textarea'], $category0);
+ $this->assertInstanceOf(customfield_textarea\field_controller::class, $field4);
+
+ // Save fields to the db so we have ids.
+ \core_customfield\api::save_field_configuration($field0, (object)['name' => 'a', 'shortname' => 'a']);
+ \core_customfield\api::save_field_configuration($field1, (object)['name' => 'b', 'shortname' => 'b']);
+ \core_customfield\api::save_field_configuration($field2, (object)['name' => 'c', 'shortname' => 'c']);
+ \core_customfield\api::save_field_configuration($field3, (object)['name' => 'd', 'shortname' => 'd']);
+ \core_customfield\api::save_field_configuration($field4, (object)['name' => 'e', 'shortname' => 'e']);
+
+ // Retrieve fields by id.
+ $this->assertInstanceOf(customfield_checkbox\field_controller::class, field_controller::create($field0->get('id')));
+ $this->assertInstanceOf(customfield_date\field_controller::class, field_controller::create($field1->get('id')));
+
+ // Retrieve field by id and category.
+ $this->assertInstanceOf(customfield_select\field_controller::class,
+ field_controller::create($field2->get('id'), null, $category0));
+
+ // Retrieve fields by record without category.
+ $fieldrecord = $DB->get_record(\core_customfield\field::TABLE, ['id' => $field3->get('id')], '*', MUST_EXIST);
+ $this->assertInstanceOf(customfield_text\field_controller::class, field_controller::create(0, $fieldrecord));
+
+ // Retrieve fields by record with category.
+ $fieldrecord = $DB->get_record(\core_customfield\field::TABLE, ['id' => $field4->get('id')], '*', MUST_EXIST);
+ $this->assertInstanceOf(customfield_textarea\field_controller::class,
+ field_controller::create(0, $fieldrecord, $category0));
+ }
+
+ /**
+ * Test for function \core_customfield\field_controller::create() in case of wrong parameters
+ */
+ public function test_constructor_errors() {
+ global $DB;
+ $this->resetAfterTest();
+
+ // Create a category and a field.
+ $category = $this->get_generator()->create_category();
+ $field = $this->get_generator()->create_field(['categoryid' => $category->get('id')]);
+
+ $fieldrecord = $DB->get_record(\core_customfield\field::TABLE, ['id' => $field->get('id')], '*', MUST_EXIST);
+
+ // Both id and record give warning.
+ $field = field_controller::create($fieldrecord->id, $fieldrecord);
+ $debugging = $this->getDebuggingMessages();
+ $this->assertEquals(1, count($debugging));
+ $this->assertEquals('Too many parameters, either id need to be specified or a record, but not both.',
+ $debugging[0]->message);
+ $this->resetDebugging();
+ $this->assertInstanceOf(customfield_text\field_controller::class, $field);
+
+ // Retrieve non-existing field.
+ try {
+ field_controller::create($fieldrecord->id + 1);
+ $this->fail('Expected exception');
+ } catch (\moodle_exception $e) {
+ $this->assertEquals('Field not found', $e->getMessage());
+ $this->assertEquals(\moodle_exception::class, get_class($e));
+ }
+
+ // Retrieve without id and without type.
+ try {
+ field_controller::create(0, (object)['name' => 'a'], $category);
+ $this->fail('Expected exception');
+ } catch (\coding_exception $e) {
+ $this->assertEquals('Coding error detected, it must be fixed by a programmer: Not enough parameters to ' .
+ 'initialise field_controller - unknown field type', $e->getMessage());
+ $this->assertEquals(\coding_exception::class, get_class($e));
+ }
+
+ // Missing category id.
+ try {
+ field_controller::create(0, (object)['type' => 'text']);
+ $this->fail('Expected exception');
+ } catch (\coding_exception $e) {
+ $this->assertEquals('Coding error detected, it must be fixed by a programmer: Not enough parameters ' .
+ 'to initialise field_controller - unknown category', $e->getMessage());
+ $this->assertEquals(\coding_exception::class, get_class($e));
+ }
+
+ // Mismatching category id.
+ try {
+ field_controller::create(0, (object)['type' => 'text', 'categoryid' => $category->get('id') + 1], $category);
+ $this->fail('Expected exception');
+ } catch (\coding_exception $e) {
+ $this->assertEquals('Coding error detected, it must be fixed by a programmer: Category of the field ' .
+ 'does not match category from the parameter', $e->getMessage());
+ $this->assertEquals(\coding_exception::class, get_class($e));
+ }
+
+ // Non-existing type.
+ try {
+ field_controller::create(0, (object)['type' => 'nonexisting'], $category);
+ $this->fail('Expected exception');
+ } catch (\moodle_exception $e) {
+ $this->assertEquals('Field type nonexisting not found', $e->getMessage());
+ $this->assertEquals(\moodle_exception::class, get_class($e));
+ }
+ }
+
+ /**
+ * Tests for behaviour of:
+ * \core_customfield\field_controller::save()
+ * \core_customfield\field_controller::get()
+ * \core_customfield\field_controller::get_category()
+ */
+ public function test_create_field() {
+ global $DB;
+ $this->resetAfterTest();
+
+ $lpg = $this->get_generator();
+ $category = $lpg->create_category();
+ $fields = $DB->get_records(\core_customfield\field::TABLE, ['categoryid' => $category->get('id')]);
+ $this->assertCount(0, $fields);
+
+ // Create field.
+ $fielddata = new \stdClass();
+ $fielddata->name = 'Field';
+ $fielddata->shortname = 'field';
+ $fielddata->type = 'text';
+ $fielddata->categoryid = $category->get('id');
+ $field = field_controller::create(0, $fielddata);
+ $field->save();
+
+ $fields = $DB->get_records(\core_customfield\field::TABLE, ['categoryid' => $category->get('id')]);
+ $this->assertCount(1, $fields);
+ $this->assertTrue(\core_customfield\field::record_exists($field->get('id')));
+ $this->assertInstanceOf(\customfield_text\field_controller::class, $field);
+ $this->assertSame($field->get('name'), $fielddata->name);
+ $this->assertSame($field->get('type'), $fielddata->type);
+ $this->assertEquals($field->get_category()->get('id'), $category->get('id'));
+ }
+
+ /**
+ * Tests for \core_customfield\field_controller::delete() behaviour.
+ */
+ public function test_delete_field() {
+ global $DB;
+ $this->resetAfterTest();
+
+ $lpg = $this->get_generator();
+ $category = $lpg->create_category();
+ $fields = $DB->get_records(\core_customfield\field::TABLE, ['categoryid' => $category->get('id')]);
+ $this->assertCount(0, $fields);
+
+ // Create field using generator.
+ $field1 = $lpg->create_field(array('categoryid' => $category->get('id')));
+ $field2 = $lpg->create_field(array('categoryid' => $category->get('id')));
+ $fields = $DB->get_records(\core_customfield\field::TABLE, ['categoryid' => $category->get('id')]);
+ $this->assertCount(2, $fields);
+
+ // Delete fields.
+ $this->assertTrue($field1->delete());
+ $this->assertTrue($field2->delete());
+
+ // Check that the fields have been deleted.
+ $fields = $DB->get_records(\core_customfield\field::TABLE, ['categoryid' => $category->get('id')]);
+ $this->assertCount(0, $fields);
+ $this->assertFalse(\core_customfield\field::record_exists($field1->get('id')));
+ $this->assertFalse(\core_customfield\field::record_exists($field2->get('id')));
+ }
+
+ /**
+ * Tests for \core_customfield\field_controller::get_configdata_property() behaviour.
+ */
+ public function test_get_configdata_property() {
+ $this->resetAfterTest();
+
+ $lpg = $this->get_generator();
+ $category = $lpg->create_category();
+ $configdata = ['a' => 'b', 'c' => ['d', 'e']];
+ $field = field_controller::create(0, (object)['type' => 'text',
+ 'configdata' => json_encode($configdata), 'shortname' => 'a', 'name' => 'a'], $category);
+ $field->save();
+
+ // Retrieve field and check configdata.
+ $field = field_controller::create($field->get('id'));
+ $this->assertEquals($configdata, $field->get('configdata'));
+ $this->assertEquals('b', $field->get_configdata_property('a'));
+ $this->assertEquals(['d', 'e'], $field->get_configdata_property('c'));
+ $this->assertEquals(null, $field->get_configdata_property('x'));
+ }
+}
diff --git a/tests/fixtures/test_instance_form.php b/tests/fixtures/test_instance_form.php
new file mode 100644
index 0000000..97fce04
--- /dev/null
+++ b/tests/fixtures/test_instance_form.php
@@ -0,0 +1,79 @@
+.
+
+/**
+ * Class core_customfield_test_instance_form
+ *
+ * @package core_customfield
+ * @copyright 2019 Marina Glancy
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/formslib.php');
+
+/**
+ * Class core_customfield_test_instance_form
+ *
+ * @package core_customfield
+ * @copyright 2019 Marina Glancy
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_customfield_test_instance_form extends moodleform {
+ /** @var \core_customfield\handler */
+ protected $handler;
+
+ /** @var stdClass */
+ protected $instance;
+
+ /**
+ * Form definition
+ */
+ public function definition() {
+ $this->handler = $this->_customdata['handler'];
+ $this->instance = $this->_customdata['instance'];
+
+ $this->_form->addElement('hidden', 'id');
+ $this->_form->setType('id', PARAM_INT);
+
+ $this->handler->instance_form_definition($this->_form, $this->instance->id);
+
+ $this->add_action_buttons();
+
+ $this->handler->instance_form_before_set_data($this->instance);
+ $this->set_data($this->instance);
+ }
+
+ /**
+ * Definition after data
+ */
+ public function definition_after_data() {
+ $this->handler->instance_form_definition_after_data($this->_form, $this->instance->id);
+ }
+
+ /**
+ * Form validation
+ *
+ * @param array $data
+ * @param array $files
+ * @return array
+ */
+ public function validation($data, $files) {
+ return $this->handler->instance_form_validation($data, $files);
+ }
+}
diff --git a/tests/generator/lib.php b/tests/generator/lib.php
new file mode 100644
index 0000000..06cfe47
--- /dev/null
+++ b/tests/generator/lib.php
@@ -0,0 +1,164 @@
+.
+
+/**
+ * Customfield data generator.
+ *
+ * @package core_customfield
+ * @category test
+ * @copyright 2018 Ruslan Kabalin
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_customfield\category_controller;
+use \core_customfield\field_controller;
+use \core_customfield\api;
+
+/**
+ * Customfield data generator class.
+ *
+ * @package core_customfield
+ * @category test
+ * @copyright 2018 Ruslan Kabalin
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_customfield_generator extends component_generator_base {
+
+ /** @var int Number of created categories. */
+ protected $categorycount = 0;
+
+ /** @var int Number of created fields. */
+ protected $fieldcount = 0;
+
+ /**
+ * Create a new category.
+ *
+ * @param array|stdClass $record
+ * @return category_controller
+ */
+ public function create_category($record = null) {
+ $this->categorycount++;
+ $i = $this->categorycount;
+ $record = (object) $record;
+
+ if (!isset($record->name)) {
+ $record->name = "Category $i";
+ }
+ if (!isset($record->component)) {
+ $record->component = 'core_course';
+ }
+ if (!isset($record->area)) {
+ $record->area = 'course';
+ }
+ if (!isset($record->itemid)) {
+ $record->itemid = 0;
+ }
+
+ $handler = \core_customfield\handler::get_handler($record->component, $record->area, $record->itemid);
+ $categoryid = $handler->create_category($record->name);
+ return $handler->get_categories_with_fields()[$categoryid];
+ }
+
+ /**
+ * Create a new field.
+ *
+ * @param array|stdClass $record
+ * @return field_controller
+ */
+ public function create_field($record) : field_controller {
+ $this->fieldcount++;
+ $i = $this->fieldcount;
+ $record = (object) $record;
+
+ if (empty($record->categoryid)) {
+ throw new coding_exception('The categoryid value is required.');
+ }
+ $category = category_controller::create($record->categoryid);
+ $handler = $category->get_handler();
+
+ if (!isset($record->name)) {
+ $record->name = "Field $i";
+ }
+ if (!isset($record->shortname)) {
+ $record->shortname = "fld$i";
+ }
+ if (!isset($record->description)) {
+ $record->description = "Field $i description";
+ }
+ if (!isset($record->descriptionformat)) {
+ $record->descriptionformat = FORMAT_HTML;
+ }
+ if (!isset($record->type)) {
+ $record->type = 'text';
+ }
+ if (!isset($record->sortorder)) {
+ $record->sortorder = 0;
+ }
+
+ if (empty($record->configdata)) {
+ $configdata = [];
+ } else if (is_array($record->configdata)) {
+ $configdata = $record->configdata;
+ } else {
+ $configdata = @json_decode($record->configdata, true);
+ $configdata = $configdata ?? [];
+ }
+ $configdata += [
+ 'required' => 0,
+ 'uniquevalues' => 0,
+ 'locked' => 0,
+ 'visibility' => 2,
+ 'defaultvalue' => '',
+ 'displaysize' => 0,
+ 'maxlength' => 0,
+ 'ispassword' => 0,
+ 'link' => '',
+ 'linktarget' => '',
+ 'checkbydefault' => 0,
+ 'startyear' => 2000,
+ 'endyear' => 3000,
+ 'includetime' => 1,
+ ];
+ $record->configdata = json_encode($configdata);
+
+ $field = field_controller::create(0, (object)['type' => $record->type], $category);
+ $handler->save_field_configuration($field, $record);
+ return $handler->get_categories_with_fields()[$field->get('categoryid')]->get_fields()[$field->get('id')];
+ }
+
+ /**
+ * Adds instance data for one field
+ *
+ * @param field_controller $field
+ * @param int $instanceid
+ * @param mixed $value
+ * @return \core_customfield\data_controller
+ */
+ public function add_instance_data(field_controller $field, int $instanceid, $value) : \core_customfield\data_controller {
+ $data = \core_customfield\data_controller::create(0, (object)['instanceid' => $instanceid], $field);
+ $data->set('contextid', $data->get_context()->id);
+
+ $rc = new ReflectionClass(get_class($data));
+ $rcm = $rc->getMethod('get_form_element_name');
+ $rcm->setAccessible(true);
+ $formelementname = $rcm->invokeArgs($data, []);
+ $record = (object)[$formelementname => $value];
+ $data->instance_form_save($record);
+ return $data;
+ }
+}
diff --git a/tests/generator_test.php b/tests/generator_test.php
new file mode 100644
index 0000000..20f17b0
--- /dev/null
+++ b/tests/generator_test.php
@@ -0,0 +1,105 @@
+.
+
+namespace core_customfield;
+
+/**
+ * core_customfield test data generator testcase.
+ *
+ * @package core_customfield
+ * @category test
+ * @copyright 2018 Ruslan Kabalin
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class generator_test extends \advanced_testcase {
+
+ /**
+ * Get generator
+ * @return core_customfield_generator
+ */
+ protected function get_generator(): \core_customfield_generator {
+ return $this->getDataGenerator()->get_plugin_generator('core_customfield');
+ }
+
+ /**
+ * Test creating category
+ */
+ public function test_create_category() {
+ $this->resetAfterTest(true);
+
+ $lpg = $this->get_generator();
+ $category = $lpg->create_category();
+
+ $this->assertInstanceOf('\core_customfield\category_controller', $category);
+ $this->assertTrue(\core_customfield\category::record_exists($category->get('id')));
+ }
+
+ /**
+ * Test creating field
+ */
+ public function test_create_field() {
+ $this->resetAfterTest(true);
+
+ $lpg = $this->get_generator();
+ $category = $lpg->create_category();
+ $field = $lpg->create_field(['categoryid' => $category->get('id')]);
+
+ $this->assertInstanceOf('\core_customfield\field_controller', $field);
+ $this->assertTrue(\core_customfield\field::record_exists($field->get('id')));
+
+ $category = \core_customfield\category_controller::create($category->get('id'));
+ $category = \core_customfield\api::get_categories_with_fields($category->get('component'),
+ $category->get('area'), $category->get('itemid'))[$category->get('id')];
+ $this->assertCount(1, $category->get_fields());
+ }
+
+ /**
+ * Test for function add_instance_data()
+ */
+ public function test_add_instance_data() {
+ $this->resetAfterTest(true);
+
+ $lpg = $this->get_generator();
+ $c1 = $lpg->create_category();
+ $course1 = $this->getDataGenerator()->create_course();
+
+ $f11 = $this->get_generator()->create_field(['categoryid' => $c1->get('id'), 'type' => 'checkbox']);
+ $f12 = $this->get_generator()->create_field(['categoryid' => $c1->get('id'), 'type' => 'date']);
+ $f13 = $this->get_generator()->create_field(['categoryid' => $c1->get('id'),
+ 'type' => 'select', 'configdata' => ['options' => "a\nb\nc"]]);
+ $f14 = $this->get_generator()->create_field(['categoryid' => $c1->get('id'), 'type' => 'text']);
+ $f15 = $this->get_generator()->create_field(['categoryid' => $c1->get('id'), 'type' => 'textarea']);
+
+ $this->get_generator()->add_instance_data($f11, $course1->id, 1);
+ $this->get_generator()->add_instance_data($f12, $course1->id, 1546300800);
+ $this->get_generator()->add_instance_data($f13, $course1->id, 2);
+ $this->get_generator()->add_instance_data($f14, $course1->id, 'Hello');
+ $this->get_generator()->add_instance_data($f15, $course1->id, ['text' => 'Hi there
', 'format' => FORMAT_HTML]);
+
+ $handler = $c1->get_handler();
+ list($data1, $data2, $data3, $data4, $data5) = array_values($handler->get_instance_data($course1->id));
+ $this->assertNotEmpty($data1->get('id'));
+ $this->assertEquals(1, $data1->get_value());
+ $this->assertNotEmpty($data2->get('id'));
+ $this->assertEquals(1546300800, $data2->get_value());
+ $this->assertNotEmpty($data3->get('id'));
+ $this->assertEquals(2, $data3->get_value());
+ $this->assertNotEmpty($data4->get('id'));
+ $this->assertEquals('Hello', $data4->get_value());
+ $this->assertNotEmpty($data5->get('id'));
+ $this->assertEquals('Hi there
', $data5->get_value());
+ }
+}
diff --git a/tests/privacy/provider_test.php b/tests/privacy/provider_test.php
new file mode 100644
index 0000000..8ee9c41
--- /dev/null
+++ b/tests/privacy/provider_test.php
@@ -0,0 +1,290 @@
+.
+
+/**
+ * Class provider_test
+ *
+ * @package core_customfield
+ * @copyright 2019 Marina Glancy
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_customfield\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_privacy\tests\provider_testcase;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\writer;
+use core_customfield\privacy\provider;
+
+/**
+ * Class provider_test
+ *
+ * @package core_customfield
+ * @copyright 2019 Marina Glancy
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider_test extends provider_testcase {
+
+ /**
+ * Generate data.
+ *
+ * @return array
+ */
+ protected function generate_test_data(): array {
+ $this->resetAfterTest();
+
+ $generator = $this->getDataGenerator()->get_plugin_generator('core_customfield');
+ $cfcats[1] = $generator->create_category();
+ $cfcats[2] = $generator->create_category();
+ $cffields[11] = $generator->create_field(
+ ['categoryid' => $cfcats[1]->get('id'), 'type' => 'checkbox']);
+ $cffields[12] = $generator->create_field(
+ ['categoryid' => $cfcats[1]->get('id'), 'type' => 'date']);
+ $cffields[13] = $generator->create_field(
+ ['categoryid' => $cfcats[1]->get('id'),
+ 'type' => 'select', 'configdata' => ['options' => "a\nb\nc"]]);
+ $cffields[14] = $generator->create_field(
+ ['categoryid' => $cfcats[1]->get('id'), 'type' => 'text']);
+ $cffields[15] = $generator->create_field(
+ ['categoryid' => $cfcats[1]->get('id'), 'type' => 'textarea']);
+ $cffields[21] = $generator->create_field(
+ ['categoryid' => $cfcats[2]->get('id')]);
+ $cffields[22] = $generator->create_field(
+ ['categoryid' => $cfcats[2]->get('id')]);
+
+ $courses[1] = $this->getDataGenerator()->create_course();
+ $courses[2] = $this->getDataGenerator()->create_course();
+ $courses[3] = $this->getDataGenerator()->create_course();
+
+ $generator->add_instance_data($cffields[11], $courses[1]->id, 1);
+ $generator->add_instance_data($cffields[12], $courses[1]->id, 1546300800);
+ $generator->add_instance_data($cffields[13], $courses[1]->id, 2);
+ $generator->add_instance_data($cffields[14], $courses[1]->id, 'Hello1');
+ $generator->add_instance_data($cffields[15], $courses[1]->id,
+ ['text' => 'Hi there
', 'format' => FORMAT_HTML]);
+
+ $generator->add_instance_data($cffields[21], $courses[1]->id, 'hihi1');
+
+ $generator->add_instance_data($cffields[14], $courses[2]->id, 'Hello2');
+
+ $generator->add_instance_data($cffields[21], $courses[2]->id, 'hihi2');
+
+ $user = $this->getDataGenerator()->create_user();
+ $this->setUser($user);
+
+ return [
+ 'user' => $user,
+ 'cfcats' => $cfcats,
+ 'cffields' => $cffields,
+ 'courses' => $courses,
+ ];
+ }
+
+ /**
+ * Test for provider::get_metadata()
+ */
+ public function test_get_metadata() {
+ $collection = new \core_privacy\local\metadata\collection('core_customfield');
+ $collection = provider::get_metadata($collection);
+ $this->assertNotEmpty($collection);
+ }
+
+ /**
+ * Test for provider::get_customfields_data_contexts
+ */
+ public function test_get_customfields_data_contexts() {
+ global $DB;
+ [
+ 'cffields' => $cffields,
+ 'cfcats' => $cfcats,
+ 'courses' => $courses,
+ ] = $this->generate_test_data();
+
+ list($sql, $params) = $DB->get_in_or_equal([$courses[1]->id, $courses[2]->id], SQL_PARAMS_NAMED);
+ $r = provider::get_customfields_data_contexts('core_course', 'course', '=0',
+ $sql, $params);
+ $this->assertEqualsCanonicalizing([\context_course::instance($courses[1]->id)->id,
+ \context_course::instance($courses[2]->id)->id],
+ $r->get_contextids());
+ }
+
+ /**
+ * Test for provider::get_customfields_configuration_contexts()
+ */
+ public function test_get_customfields_configuration_contexts() {
+ $this->generate_test_data();
+
+ $r = provider::get_customfields_configuration_contexts('core_course', 'course');
+ $this->assertEquals([\context_system::instance()->id], $r->get_contextids());
+ }
+
+ /**
+ * Test for provider::export_customfields_data()
+ */
+ public function test_export_customfields_data() {
+ global $USER, $DB;
+ $this->resetAfterTest();
+ [
+ 'cffields' => $cffields,
+ 'cfcats' => $cfcats,
+ 'courses' => $courses,
+ ] = $this->generate_test_data();
+
+ // Hack one of the fields so it has an invalid field type.
+ $invalidfieldid = $cffields[21]->get('id');
+ $DB->update_record('customfield_field', ['id' => $invalidfieldid, 'type' => 'invalid']);
+
+ $context = \context_course::instance($courses[1]->id);
+ $contextlist = new approved_contextlist($USER, 'core_customfield', [$context->id]);
+ provider::export_customfields_data($contextlist, 'core_course', 'course', '=0', '=:i', ['i' => $courses[1]->id]);
+ /** @var core_privacy\tests\request\content_writer $writer */
+ $writer = writer::with_context($context);
+
+ // Make sure that all and only data for the course1 was exported.
+ // There is no way to fetch all data from writer as array so we need to fetch one-by-one for each data id.
+ $invaldfieldischecked = false;
+ foreach ($DB->get_records('customfield_data', []) as $dbrecord) {
+ $data = $writer->get_data(['Custom fields data', $dbrecord->id]);
+ if ($dbrecord->instanceid == $courses[1]->id) {
+ $this->assertEquals($dbrecord->fieldid, $data->fieldid);
+ $this->assertNotEmpty($data->fieldtype);
+ $this->assertNotEmpty($data->fieldshortname);
+ $this->assertNotEmpty($data->fieldname);
+ $invaldfieldischecked = $invaldfieldischecked ?: ($data->fieldid == $invalidfieldid);
+ } else {
+ $this->assertEmpty($data);
+ }
+ }
+
+ // Make sure field with was checked in this test.
+ $this->assertTrue($invaldfieldischecked);
+ }
+
+ /**
+ * Test for provider::delete_customfields_data()
+ */
+ public function test_delete_customfields_data() {
+ global $USER, $DB;
+ $this->resetAfterTest();
+ [
+ 'cffields' => $cffields,
+ 'cfcats' => $cfcats,
+ 'courses' => $courses,
+ ] = $this->generate_test_data();
+
+ $approvedcontexts = new approved_contextlist($USER, 'core_course', [\context_course::instance($courses[1]->id)->id]);
+ provider::delete_customfields_data($approvedcontexts, 'core_course', 'course');
+ $this->assertEmpty($DB->get_records('customfield_data', ['instanceid' => $courses[1]->id]));
+ $this->assertNotEmpty($DB->get_records('customfield_data', ['instanceid' => $courses[2]->id]));
+ }
+
+ /**
+ * Test for provider::delete_customfields_configuration()
+ */
+ public function test_delete_customfields_configuration() {
+ global $USER, $DB;
+ $this->resetAfterTest();
+ [
+ 'cffields' => $cffields,
+ 'cfcats' => $cfcats,
+ 'courses' => $courses,
+ ] = $this->generate_test_data();
+
+ // Remember the list of fields in the category 2 before we delete it.
+ $catid1 = $cfcats[1]->get('id');
+ $catid2 = $cfcats[2]->get('id');
+ $fids2 = $DB->get_fieldset_select('customfield_field', 'id', 'categoryid=?', [$catid2]);
+ $this->assertNotEmpty($fids2);
+ list($fsql, $fparams) = $DB->get_in_or_equal($fids2, SQL_PARAMS_NAMED);
+ $this->assertNotEmpty($DB->get_records_select('customfield_data', 'fieldid ' . $fsql, $fparams));
+
+ // A little hack here, modify customfields configuration so they have different itemids.
+ $DB->update_record('customfield_category', ['id' => $catid2, 'itemid' => 1]);
+ $contextlist = new approved_contextlist($USER, 'core_course', [\context_system::instance()->id]);
+ provider::delete_customfields_configuration($contextlist, 'core_course', 'course', '=:i', ['i' => 1]);
+
+ // Make sure everything for category $catid2 is gone but present for $catid1.
+ $this->assertEmpty($DB->get_records('customfield_category', ['id' => $catid2]));
+ $this->assertEmpty($DB->get_records_select('customfield_field', 'id ' . $fsql, $fparams));
+ $this->assertEmpty($DB->get_records_select('customfield_data', 'fieldid ' . $fsql, $fparams));
+
+ $this->assertNotEmpty($DB->get_records('customfield_category', ['id' => $catid1]));
+ $fids1 = $DB->get_fieldset_select('customfield_field', 'id', 'categoryid=?', [$catid1]);
+ list($fsql1, $fparams1) = $DB->get_in_or_equal($fids1, SQL_PARAMS_NAMED);
+ $this->assertNotEmpty($DB->get_records_select('customfield_field', 'id ' . $fsql1, $fparams1));
+ $this->assertNotEmpty($DB->get_records_select('customfield_data', 'fieldid ' . $fsql1, $fparams1));
+ }
+
+ /**
+ * Test for provider::delete_customfields_configuration_for_context()
+ */
+ public function test_delete_customfields_configuration_for_context() {
+ global $USER, $DB;
+ $this->resetAfterTest();
+ [
+ 'cffields' => $cffields,
+ 'cfcats' => $cfcats,
+ 'courses' => $courses,
+ ] = $this->generate_test_data();
+
+ // Remember the list of fields in the category 2 before we delete it.
+ $catid1 = $cfcats[1]->get('id');
+ $catid2 = $cfcats[2]->get('id');
+ $fids2 = $DB->get_fieldset_select('customfield_field', 'id', 'categoryid=?', [$catid2]);
+ $this->assertNotEmpty($fids2);
+ list($fsql, $fparams) = $DB->get_in_or_equal($fids2, SQL_PARAMS_NAMED);
+ $this->assertNotEmpty($DB->get_records_select('customfield_data', 'fieldid ' . $fsql, $fparams));
+
+ // A little hack here, modify customfields configuration so they have different contexts.
+ $context = \context_user::instance($USER->id);
+ $DB->update_record('customfield_category', ['id' => $catid2, 'contextid' => $context->id]);
+ provider::delete_customfields_configuration_for_context('core_course', 'course', $context);
+
+ // Make sure everything for category $catid2 is gone but present for $catid1.
+ $this->assertEmpty($DB->get_records('customfield_category', ['id' => $catid2]));
+ $this->assertEmpty($DB->get_records_select('customfield_field', 'id ' . $fsql, $fparams));
+ $this->assertEmpty($DB->get_records_select('customfield_data', 'fieldid ' . $fsql, $fparams));
+
+ $this->assertNotEmpty($DB->get_records('customfield_category', ['id' => $catid1]));
+ $fids1 = $DB->get_fieldset_select('customfield_field', 'id', 'categoryid=?', [$catid1]);
+ list($fsql1, $fparams1) = $DB->get_in_or_equal($fids1, SQL_PARAMS_NAMED);
+ $this->assertNotEmpty($DB->get_records_select('customfield_field', 'id ' . $fsql1, $fparams1));
+ $this->assertNotEmpty($DB->get_records_select('customfield_data', 'fieldid ' . $fsql1, $fparams1));
+ }
+
+ /**
+ * Test for provider::delete_customfields_data_for_context()
+ */
+ public function test_delete_customfields_data_for_context() {
+ global $DB;
+ $this->resetAfterTest();
+ [
+ 'cffields' => $cffields,
+ 'cfcats' => $cfcats,
+ 'courses' => $courses,
+ ] = $this->generate_test_data();
+
+ provider::delete_customfields_data_for_context('core_course', 'course',
+ \context_course::instance($courses[1]->id));
+ $fids2 = $DB->get_fieldset_select('customfield_field', 'id', '1=1', []);
+ list($fsql, $fparams) = $DB->get_in_or_equal($fids2, SQL_PARAMS_NAMED);
+ $fparams['course1'] = $courses[1]->id;
+ $fparams['course2'] = $courses[2]->id;
+ $this->assertEmpty($DB->get_records_select('customfield_data', 'instanceid = :course1 AND fieldid ' . $fsql, $fparams));
+ $this->assertNotEmpty($DB->get_records_select('customfield_data', 'instanceid = :course2 AND fieldid ' . $fsql, $fparams));
+ }
+}
diff --git a/upgrade.txt b/upgrade.txt
new file mode 100644
index 0000000..8e61c85
--- /dev/null
+++ b/upgrade.txt
@@ -0,0 +1,13 @@
+This files describes API changes in /customfield/*,
+Information provided here is intended especially for developers.
+
+=== 3.11 ===
+* Methods \core_customfield\handler::get_field_config_form() and \core_customfield\handler::setup_edit_page() are no
+ longer used. Components that define custom fields areas do not need to implement them. Field edit form opens in
+ the modal now.
+
+=== 4.0 ===
+The way the method customfield_multiselect\data_controller::get_value() was returning an array was causing multiple issues with core AJAX functionality returning course data.
+Examples of somewhere this error was occuring is when trying to duplicate a course via 'manage courses and categories', or when trying to fetch a list of courses when adding a meta link enrolment method.
+Issues also occured when using Edwiser Bridge.
+The solution was to return a string of values using get_value, but preparing the data as an array when using the course edit form.
From 12f7e354db380ba593174c410231742b13ed128b Mon Sep 17 00:00:00 2001
From: teruselearning <49330603+teruselearning@users.noreply.github.com>
Date: Thu, 9 Feb 2023 14:32:06 +0700
Subject: [PATCH 2/2] addition of tasks to keep track of field changes
---
field/multiselect/classes/data_controller.php | 37 ++++++------
.../classes/task/multiselect_field_change.php | 51 ++++++++++++++++
.../classes/task/multiselect_sync.php | 60 +++++++++++++++++++
field/multiselect/db/install.xml | 19 ++++++
field/multiselect/db/tasks.php | 25 ++++++++
field/multiselect/db/upgrade.php | 38 ++++++++++++
.../lang/en/customfield_multiselect.php | 2 +
field/multiselect/version.php | 2 +-
8 files changed, 215 insertions(+), 19 deletions(-)
create mode 100644 field/multiselect/classes/task/multiselect_field_change.php
create mode 100644 field/multiselect/classes/task/multiselect_sync.php
create mode 100644 field/multiselect/db/install.xml
create mode 100644 field/multiselect/db/tasks.php
create mode 100644 field/multiselect/db/upgrade.php
diff --git a/field/multiselect/classes/data_controller.php b/field/multiselect/classes/data_controller.php
index 6e7baae..7ee5275 100644
--- a/field/multiselect/classes/data_controller.php
+++ b/field/multiselect/classes/data_controller.php
@@ -86,7 +86,7 @@ public function get_default_value_string() {
$defaultvaluesarray[] = intval($index);
}
}
- return implode(",", $defaultvalues);
+ return implode(",", $defaultvaluesarray);
}
/**
@@ -218,22 +218,23 @@ protected function is_empty($value): bool {
* @return mixed|null value or null if empty
*/
public function export_value() {
- $values = $this->get_value(); // This is a an array of indexes.
-
- if ($this->is_empty($values)) {
- return null;
- }
-
- $commasepoptionvalues = "";
- $options = field_controller::get_options_array($this->get_field());
- foreach ($values as $val) {
- if (!empty($options[$val])) {
- $commasepoptionvalues .= (empty($commasepoptionvalues) ? '' : ', ') .
- format_string($options[$val], true,
- ['context' => $this->get_field()->get_handler()->get_configuration_context()]);
- }
- }
- return $commasepoptionvalues;
+ // $values = $this->get_value(); // This is a an array of indexes.
+
+ // if ($this->is_empty($values)) {
+ // return null;
+ // }
+
+ // $commasepoptionvalues = "";
+ // $options = field_controller::get_options_array($this->get_field());
+ // foreach ($values as $val) {
+ // if (!empty($options[$val])) {
+ // $commasepoptionvalues .= (empty($commasepoptionvalues) ? '' : ', ') .
+ // format_string($options[$val], true,
+ // ['context' => $this->get_field()->get_handler()->get_configuration_context()]);
+ // }
+ // }
+ // return $commasepoptionvalues;
+ // }
+ return '';
}
-
}
diff --git a/field/multiselect/classes/task/multiselect_field_change.php b/field/multiselect/classes/task/multiselect_field_change.php
new file mode 100644
index 0000000..cf2823a
--- /dev/null
+++ b/field/multiselect/classes/task/multiselect_field_change.php
@@ -0,0 +1,51 @@
+dirroot . '/user/lib.php';
+
+ //Firstly get all cusotmfields with multiselect
+ $all_multiselect_fields = $DB->get_records('customfield_field',array('type'=>'multiselect'));
+
+ foreach($all_multiselect_fields as $field){
+ //Get all courses with data for this field
+ $five_mins_ago = time() - 300;
+ if($field->timemodified >= $five_mins_ago){
+ $field_with_data = $DB->get_records('customfield_data',array('fieldid'=>$field->id));
+
+ foreach($field_with_data as $data){
+ //Check if this has already been added
+ if($entry = $DB->get_record('customfield_multiselect',array('fieldid'=>$field->id, 'courseid'=>$data->instanceid))){
+ $courseid = $data->instanceid;
+ $value = $entry->data;
+ $values = explode(",", $value);
+
+ $configdata = $field->configdata;
+ $config_op = json_decode($configdata);
+ $field_options = $config_op->options;
+ $options = explode("\r\n", $field_options);
+
+ $valuesaarr = array();
+
+ foreach($values as $multioption){
+ $key = array_search($multioption, $options);
+ array_push($valuesaarr, $key);
+ }
+ $data->value = implode(",",$valuesaarr);
+ $DB->update_record('customfield_data', $data);
+ }
+ }
+
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/field/multiselect/classes/task/multiselect_sync.php b/field/multiselect/classes/task/multiselect_sync.php
new file mode 100644
index 0000000..33c4842
--- /dev/null
+++ b/field/multiselect/classes/task/multiselect_sync.php
@@ -0,0 +1,60 @@
+dirroot . '/user/lib.php';
+
+ $five_mins_ago = time() - 300;
+ $sql="SELECT * FROM {course} WHERE timemodified > ".$five_mins_ago;
+ $courses = $DB->get_records_sql($sql);
+ print_r($courses);
+ if(!empty($courses)){
+ foreach($courses as $course){
+ $DB->delete_records('customfield_multiselect', array('courseid' => $course->id));
+ }
+ }
+
+
+
+
+ //Firstly get all cusotmfields with multiselect
+ $all_multiselect_fields = $DB->get_records('customfield_field',array('type'=>'multiselect'));
+
+ foreach($all_multiselect_fields as $field){
+ //Get all courses with data for this field
+ $field_with_data = $DB->get_records('customfield_data',array('fieldid'=>$field->id));
+ foreach($field_with_data as $data){
+ //Check if this has already been added
+ if(!$DB->get_record('customfield_multiselect',array('fieldid'=>$field->id, 'courseid'=>$data->instanceid))){
+ $courseid = $data->instanceid;
+ $value = $data->value;
+ $values = explode(",", $value);
+ $configdata = $field->configdata;
+ $config_op = json_decode($configdata);
+ $field_options = $config_op->options;
+ $options = explode("\r\n", $field_options);
+
+ $valuesaarr = array();
+
+ foreach($values as $multioption){
+ array_push($valuesaarr, $options[$multioption]);
+ }
+ $data = new \stdclass();
+ $data->courseid = $courseid;
+ $data->fieldid = $field->id;
+ $data->data = implode(",",$valuesaarr);
+ $DB->insert_record('customfield_multiselect', $data);
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/field/multiselect/db/install.xml b/field/multiselect/db/install.xml
new file mode 100644
index 0000000..638967c
--- /dev/null
+++ b/field/multiselect/db/install.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/field/multiselect/db/tasks.php b/field/multiselect/db/tasks.php
new file mode 100644
index 0000000..5282df9
--- /dev/null
+++ b/field/multiselect/db/tasks.php
@@ -0,0 +1,25 @@
+ 'customfield_multiselect\task\multiselect_sync',
+ 'blocking' => 0,
+ 'minute' => '*',
+ 'hour' => '*',
+ 'day' => '*',
+ 'month' => '*',
+ 'dayofweek' => '*'
+ ),
+ array(
+ 'classname' => 'customfield_multiselect\task\multiselect_field_change',
+ 'blocking' => 0,
+ 'minute' => '/5',
+ 'hour' => '*',
+ 'day' => '*',
+ 'month' => '*',
+ 'dayofweek' => '*'
+ ),
+);
diff --git a/field/multiselect/db/upgrade.php b/field/multiselect/db/upgrade.php
new file mode 100644
index 0000000..7b15300
--- /dev/null
+++ b/field/multiselect/db/upgrade.php
@@ -0,0 +1,38 @@
+get_manager();
+
+ if ($oldversion < 2023020803) {
+
+ // Define table customfield_multiselect to be created.
+ $table = new xmldb_table('customfield_multiselect');
+
+ // Adding fields to table customfield_multiselect.
+ $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+ $table->add_field('courseid', XMLDB_TYPE_INTEGER, '5', null, null, null, null);
+ $table->add_field('fieldid', XMLDB_TYPE_INTEGER, '5', null, null, null, null);
+ $table->add_field('data', XMLDB_TYPE_CHAR, '1333', null, XMLDB_NOTNULL, null, null);
+
+ // Adding keys to table customfield_multiselect.
+ $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+
+ // Conditionally launch create table for customfield_multiselect.
+ if (!$dbman->table_exists($table)) {
+ $dbman->create_table($table);
+ }
+
+ // Multiselect savepoint reached.
+ upgrade_plugin_savepoint(true, 2023020803, 'customfield', 'multiselect');
+ }
+
+
+
+ return $result;
+}
+
diff --git a/field/multiselect/lang/en/customfield_multiselect.php b/field/multiselect/lang/en/customfield_multiselect.php
index b6ff26e..e2f669f 100644
--- a/field/multiselect/lang/en/customfield_multiselect.php
+++ b/field/multiselect/lang/en/customfield_multiselect.php
@@ -35,3 +35,5 @@
$string['pluginname'] = 'Multiselect menu';
$string['privacy:metadata'] = 'The Multiselect menu field type plugin doesn\'t store any personal data; it uses tables defined in core.';
$string['specificsettings'] = 'Multiselect menu field settings';
+$string['task'] = 'Create literal values';
+$string['field_task'] = 'Monitor field changes';
diff --git a/field/multiselect/version.php b/field/multiselect/version.php
index 4f35c17..db9e0ed 100644
--- a/field/multiselect/version.php
+++ b/field/multiselect/version.php
@@ -26,7 +26,7 @@
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'customfield_multiselect';
-$plugin->version = 2019052000;
+$plugin->version = 2023020805;
$plugin->requires = 2019051100;
$plugin->release = '1.0.0';
$plugin->maturity = MATURITY_BETA;
\ No newline at end of file