Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • issue/project_browser-3278972
  • issue/project_browser-3240319
  • project/project_browser
  • issue/project_browser-3223747
  • issue/project_browser-3238996
  • issue/project_browser-3279510
  • issue/project_browser-3281209
  • issue/project_browser-3282592
  • issue/project_browser-3282691
  • issue/project_browser-3282681
  • issue/project_browser-3283861
  • issue/project_browser-3279918
  • issue/project_browser-3280627
  • issue/project_browser-3281216
  • issue/project_browser-3281692
  • issue/project_browser-3281891
  • issue/project_browser-3282201
  • issue/project_browser-3282707
  • issue/project_browser-3282732
  • issue/project_browser-3279927
  • issue/project_browser-3280190
  • issue/project_browser-3268626
  • issue/project_browser-3280706
  • issue/project_browser-3281388
  • issue/project_browser-3282777
  • issue/project_browser-3278333
  • issue/project_browser-3280167
  • issue/project_browser-3281252
  • issue/project_browser-3280006
  • issue/project_browser-3281686
  • issue/project_browser-3282698
  • issue/project_browser-3282688
  • issue/project_browser-3283826
  • issue/project_browser-3279676
  • issue/project_browser-3280176
  • issue/project_browser-3281327
  • issue/project_browser-3281624
  • issue/project_browser-3281217
  • issue/project_browser-3281187
  • issue/project_browser-3281200
  • issue/project_browser-3282591
  • issue/project_browser-3282648
  • issue/project_browser-3282705
  • issue/project_browser-3282589
  • issue/project_browser-3282692
  • issue/project_browser-3283683
  • issue/project_browser-3283825
  • issue/project_browser-3283875
  • issue/project_browser-3282709
  • issue/project_browser-3281418
  • issue/project_browser-3281620
  • issue/project_browser-3282499
  • issue/project_browser-3282778
  • issue/project_browser-3282683
  • issue/project_browser-3282700
  • issue/project_browser-3240318
  • issue/project_browser-3240128
  • issue/project_browser-3245770
  • issue/project_browser-3248299
  • issue/project_browser-3240321
  • issue/project_browser-3249483
  • issue/project_browser-3241358
  • issue/project_browser-3238572
  • issue/project_browser-3249958
  • issue/project_browser-3240320
  • issue/project_browser-3250132
  • issue/project_browser-3239477
  • issue/project_browser-3250356
  • issue/project_browser-3250413
  • issue/project_browser-3250454
  • issue/project_browser-3277551
  • issue/project_browser-3258248
  • issue/project_browser-3258247
  • issue/project_browser-3259324
  • issue/project_browser-3259657
  • issue/project_browser-3245948
  • issue/project_browser-3264474
  • issue/project_browser-3267656
  • issue/project_browser-3267658
  • issue/project_browser-3267661
  • issue/project_browser-3268636
  • issue/project_browser-3224709
  • issue/project_browser-3273634
  • issue/project_browser-3274010
  • issue/project_browser-3274277
  • issue/project_browser-3252674
  • issue/project_browser-3274701
  • issue/project_browser-3274520
  • issue/project_browser-3274906
  • issue/project_browser-3275413
  • issue/project_browser-3275536
  • issue/project_browser-3243019
  • issue/project_browser-3274577
  • issue/project_browser-3274335
  • issue/project_browser-3270210
  • issue/project_browser-3277052
  • issue/project_browser-3224896
  • issue/project_browser-3274897
  • issue/project_browser-3277253
  • issue/project_browser-3277260
  • issue/project_browser-3277264
  • issue/project_browser-3277764
  • issue/project_browser-3277808
  • issue/project_browser-3277811
  • issue/project_browser-3277832
  • issue/project_browser-3277224
  • issue/project_browser-3278352
  • issue/project_browser-3277255
  • issue/project_browser-3279663
  • issue/project_browser-3279983
  • issue/project_browser-3280721
  • issue/project_browser-3280765
  • issue/project_browser-3283771
  • issue/project_browser-3284094
  • issue/project_browser-3284233
  • issue/project_browser-3284092
  • issue/project_browser-3284312
  • issue/project_browser-3284321
  • issue/project_browser-3284315
  • issue/project_browser-3284333
  • issue/project_browser-3284325
  • issue/project_browser-3284327
  • issue/project_browser-3284463
  • issue/project_browser-3284330
  • issue/project_browser-3284347
  • issue/project_browser-3284332
  • issue/project_browser-3284631
  • issue/project_browser-3284945
  • issue/project_browser-3284880
  • issue/project_browser-3284341
  • issue/project_browser-3284309
  • issue/project_browser-3281218
  • issue/project_browser-3285889
  • issue/project_browser-3285874
  • issue/project_browser-3285880
  • issue/project_browser-3285878
  • issue/project_browser-3285870
  • issue/project_browser-3285443
  • issue/project_browser-3285879
  • issue/project_browser-3269587
  • issue/project_browser-3291995
  • issue/project_browser-3292019
  • issue/project_browser-3292395
  • issue/project_browser-3292562
  • issue/project_browser-3289165
  • issue/project_browser-3292996
  • issue/project_browser-3293014
  • issue/project_browser-3252678
  • issue/project_browser-3277214
  • issue/project_browser-3293691
  • issue/project_browser-3293694
  • issue/project_browser-3267678
  • issue/project_browser-3267685
  • issue/project_browser-3293909
  • issue/project_browser-3293937
  • issue/project_browser-3293898
  • issue/project_browser-3293905
  • issue/project_browser-3282163
  • issue/project_browser-3293899
  • issue/project_browser-3294763
  • issue/project_browser-3294754
  • issue/project_browser-3247332
  • issue/project_browser-3295320
  • issue/project_browser-3294608
  • issue/project_browser-3287419
  • issue/project_browser-3294576
  • issue/project_browser-3293424
  • issue/project_browser-3296264
  • issue/project_browser-3296324
  • issue/project_browser-3296494
  • issue/project_browser-3296250
  • issue/project_browser-3304245
  • issue/project_browser-3304569
  • issue/project_browser-3298525
  • issue/project_browser-3298575
  • issue/project_browser-3293929
  • issue/project_browser-3298755
  • issue/project_browser-3249550
  • issue/project_browser-3249553
  • issue/project_browser-3282842
  • issue/project_browser-3300093
  • issue/project_browser-3299285
  • issue/project_browser-3300447
  • issue/project_browser-3300262
  • issue/project_browser-3303920
  • issue/project_browser-3305515
  • issue/project_browser-3300735
  • issue/project_browser-3299029
  • issue/project_browser-3300520
  • issue/project_browser-3301498
  • issue/project_browser-3301577
  • issue/project_browser-3300397
  • issue/project_browser-3301989
  • issue/project_browser-3302094
  • issue/project_browser-3241731
  • issue/project_browser-3305551
  • issue/project_browser-3303328
  • issue/project_browser-3305983
  • issue/project_browser-3306162
  • issue/project_browser-3310703
  • issue/project_browser-3310725
  • issue/project_browser-3310876
  • issue/project_browser-3310884
  • issue/project_browser-3306717
  • issue/project_browser-3307512
  • issue/project_browser-3307674
  • issue/project_browser-3307917
  • issue/project_browser-3306714
  • issue/project_browser-3308068
  • issue/project_browser-3303496
  • issue/project_browser-3306722
  • issue/project_browser-3308522
  • issue/project_browser-3308525
  • issue/project_browser-3310864
  • issue/project_browser-3310877
  • issue/project_browser-3310898
  • issue/project_browser-3311370
  • issue/project_browser-3293977
  • issue/project_browser-3310893
  • issue/project_browser-3310874
  • issue/project_browser-3271591
  • issue/project_browser-3300090
  • issue/project_browser-3311763
  • issue/project_browser-3304183
  • issue/project_browser-3303522
  • issue/project_browser-3311980
  • issue/project_browser-3311992
  • issue/project_browser-3311978
  • issue/project_browser-3303520
  • issue/project_browser-3312289
  • issue/project_browser-3312537
  • issue/project_browser-3310882
  • issue/project_browser-3282338
  • issue/project_browser-3314210
  • issue/project_browser-3310896
  • issue/project_browser-3315866
  • issue/project_browser-3315853
  • issue/project_browser-3314520
  • issue/project_browser-3303373
  • issue/project_browser-3314728
  • issue/project_browser-3313292
  • issue/project_browser-3300350
  • issue/project_browser-3315867
  • issue/project_browser-3315860
  • issue/project_browser-3315854
  • issue/project_browser-3315858
  • issue/project_browser-3315859
  • issue/project_browser-3310906
  • issue/project_browser-3310903
  • issue/project_browser-3316454
  • issue/project_browser-3316740
  • issue/project_browser-3316744
  • issue/project_browser-3316820
  • issue/project_browser-3316852
  • issue/project_browser-3318442
  • issue/project_browser-3318732
  • issue/project_browser-3318789
  • issue/project_browser-3318905
  • issue/project_browser-3327323
  • issue/project_browser-3325409
  • issue/project_browser-3326049
  • issue/project_browser-3318794
  • issue/project_browser-3316357
  • issue/project_browser-3320295
  • issue/project_browser-3319785
  • issue/project_browser-3316998
  • issue/project_browser-3326245
  • issue/project_browser-3327400
  • issue/project_browser-3327684
  • issue/project_browser-3328136
  • issue/project_browser-3323354
  • issue/project_browser-3325630
  • issue/project_browser-3325658
  • issue/project_browser-3316162
  • issue/project_browser-3318750
  • issue/project_browser-3323531
  • issue/project_browser-3324905
  • issue/project_browser-3322594
  • issue/project_browser-3321703
  • issue/project_browser-3318791
  • issue/project_browser-3277506
  • issue/project_browser-3322164
  • issue/project_browser-3321698
  • issue/project_browser-3317264
  • issue/project_browser-3325657
  • issue/project_browser-3315862
  • issue/project_browser-3329644
  • issue/project_browser-3328124
  • issue/project_browser-3329861
  • issue/project_browser-3330071
  • issue/project_browser-3330102
  • issue/project_browser-3329889
  • issue/project_browser-3330128
  • issue/project_browser-3330129
  • issue/project_browser-3330122
  • issue/project_browser-3330124
  • issue/project_browser-3330887
  • issue/project_browser-3330123
  • issue/project_browser-3330116
  • issue/project_browser-3330130
  • issue/project_browser-3330133
  • issue/project_browser-3332968
  • issue/project_browser-3333456
  • issue/project_browser-3334585
  • issue/project_browser-3304184
  • issue/project_browser-3338357
  • issue/project_browser-3342535
  • issue/project_browser-3340133
  • issue/project_browser-3338356
  • issue/project_browser-3330235
  • issue/project_browser-3341230
  • issue/project_browser-3342128
  • issue/project_browser-3342218
  • issue/project_browser-3345607
  • issue/project_browser-3348249
  • issue/project_browser-3351987
  • issue/project_browser-3348781
  • issue/project_browser-3348872
  • issue/project_browser-3351137
  • issue/project_browser-3353355
  • issue/project_browser-3353483
  • issue/project_browser-3354450
  • issue/project_browser-3356614
  • issue/project_browser-3356685
  • issue/project_browser-3303190
  • issue/project_browser-3359678
  • issue/project_browser-3361123
  • issue/project_browser-3311503
  • issue/project_browser-3364776
  • issue/project_browser-3365345
  • issue/project_browser-3365350
  • issue/project_browser-3318726
  • issue/project_browser-3309273
  • issue/project_browser-3303381
  • issue/project_browser-3360062
  • issue/project_browser-3365404
  • issue/project_browser-3359980
  • issue/project_browser-3323860
  • issue/project_browser-3365180
  • issue/project_browser-3249549
  • issue/project_browser-3365678
  • issue/project_browser-3370284
  • issue/project_browser-3404483
  • issue/project_browser-3365435
  • issue/project_browser-3402908
  • issue/project_browser-3372796
  • issue/project_browser-3357036
  • issue/project_browser-3375619
  • issue/project_browser-3376206
  • issue/project_browser-3376201
  • issue/project_browser-3376202
  • issue/project_browser-3376203
  • issue/project_browser-3376208
  • issue/project_browser-3357003
  • issue/project_browser-3376291
  • issue/project_browser-3382457
  • issue/project_browser-3348761
  • issue/project_browser-3376160
  • issue/project_browser-3348960
  • issue/project_browser-3389246
  • issue/project_browser-3389250
  • issue/project_browser-3310908
  • issue/project_browser-3394166
  • issue/project_browser-3394570
  • issue/project_browser-3394904
  • issue/project_browser-3395472
  • issue/project_browser-3395480
  • issue/project_browser-3360033
  • issue/project_browser-3395478
  • issue/project_browser-3360063
  • issue/project_browser-3365392
  • issue/project_browser-3396245
  • issue/project_browser-3437724
  • issue/project_browser-3343805
  • issue/project_browser-3409134
  • issue/project_browser-3436249
  • issue/project_browser-3444662
  • issue/project_browser-3410505
  • issue/project_browser-3410797
  • issue/project_browser-3410947
  • issue/project_browser-3419177
  • issue/project_browser-3413564
  • issue/project_browser-3413763
  • issue/project_browser-3415079
  • issue/project_browser-3413567
  • issue/project_browser-3312354
  • issue/project_browser-3418454
  • issue/project_browser-3419925
  • issue/project_browser-3413498
  • issue/project_browser-3420501
  • issue/project_browser-3420552
  • issue/project_browser-3421211
  • issue/project_browser-3421699
  • issue/project_browser-3423695
  • issue/project_browser-3423697
  • issue/project_browser-3423915
  • issue/project_browser-3423429
  • issue/project_browser-3423442
  • issue/project_browser-3425989
  • issue/project_browser-3426658
  • issue/project_browser-3365103
  • issue/project_browser-3318817
  • issue/project_browser-3426767
  • issue/project_browser-3443929
  • issue/project_browser-3443930
  • issue/project_browser-3444658
  • issue/project_browser-3446591
  • issue/project_browser-3445765
  • issue/project_browser-3426094
  • issue/project_browser-3443943
  • issue/project_browser-3446092
  • issue/project_browser-3348954
  • issue/project_browser-3446604
  • issue/project_browser-3446414
  • issue/project_browser-3312056
  • issue/project_browser-3446109
  • issue/project_browser-3446257
  • issue/project_browser-3446416
  • issue/project_browser-3445811
  • issue/project_browser-3437721
  • issue/project_browser-3436529
  • issue/project_browser-3446322
  • issue/project_browser-3446528
  • issue/project_browser-3441425
  • issue/project_browser-3427491
  • issue/project_browser-3447377
  • issue/project_browser-3447420
  • issue/project_browser-3447764
  • issue/project_browser-3448808
  • issue/project_browser-3450499
  • issue/project_browser-3451142
  • issue/project_browser-3451659
  • issue/project_browser-3446161
  • issue/project_browser-3451863
  • issue/project_browser-3452431
  • issue/project_browser-3452504
  • issue/project_browser-3452541
  • issue/project_browser-3452737
  • issue/project_browser-3452787
  • issue/project_browser-3452784
  • issue/project_browser-3453039
  • issue/project_browser-3453101
  • issue/project_browser-3453437
  • issue/project_browser-3453766
  • issue/project_browser-3453775
  • issue/project_browser-3453794
  • issue/project_browser-3453808
  • issue/project_browser-3453997
  • issue/project_browser-3454248
  • issue/project_browser-3453738
  • issue/project_browser-3454294
  • issue/project_browser-3455152
  • issue/project_browser-3455284
  • issue/project_browser-3455215
  • issue/project_browser-3455220
  • issue/project_browser-3455477
  • issue/project_browser-3455485
  • issue/project_browser-3455210
  • issue/project_browser-3455715
  • issue/project_browser-3455917
  • issue/project_browser-3365434
  • issue/project_browser-3457003
  • issue/project_browser-3457054
  • issue/project_browser-3457376
  • issue/project_browser-3457682
  • issue/project_browser-3458287
  • issue/project_browser-3371084
  • issue/project_browser-3458765
  • issue/project_browser-3457972
  • issue/project_browser-3452435
  • issue/project_browser-3459662
  • issue/project_browser-3460033
  • issue/project_browser-3454873
  • issue/project_browser-3459288
  • issue/project_browser-3460938
  • issue/project_browser-3461670
  • issue/project_browser-3461518
  • issue/project_browser-3461892
  • issue/project_browser-3461879
  • issue/project_browser-3462173
  • issue/project_browser-3461340
  • issue/project_browser-3463833
  • issue/project_browser-3464077
  • issue/project_browser-3464450
  • issue/project_browser-3461037
  • issue/project_browser-3464794
  • issue/project_browser-3465046
  • issue/project_browser-3450629
  • issue/project_browser-3293907
  • issue/project_browser-3466307
  • issue/project_browser-3458908
  • issue/project_browser-3465232
  • issue/project_browser-3467911
  • issue/project_browser-3458844
  • issue/project_browser-3456978
  • issue/project_browser-3471232
  • issue/project_browser-3472206
  • issue/project_browser-3472230
  • issue/project_browser-3469490
  • issue/project_browser-3472231
  • issue/project_browser-3470540
  • issue/project_browser-3475056
  • issue/project_browser-3475852
  • issue/project_browser-3476400
  • issue/project_browser-3472334
  • issue/project_browser-3476717
  • issue/project_browser-3476812
  • issue/project_browser-3476887
  • issue/project_browser-3477288
  • issue/project_browser-3476818
  • issue/project_browser-3477314
  • issue/project_browser-3477343
  • issue/project_browser-3477232
  • issue/project_browser-3477231
  • issue/project_browser-3478295
  • issue/project_browser-3478832
  • issue/project_browser-3477335
  • issue/project_browser-3478994
  • issue/project_browser-3478437
  • issue/project_browser-3479219
  • issue/project_browser-3477238
  • issue/project_browser-3480064
  • issue/project_browser-3469552
  • issue/project_browser-3479907
  • issue/project_browser-3481084
  • issue/project_browser-3458840
  • issue/project_browser-3483725
  • issue/project_browser-3495318
  • issue/project_browser-3495328
  • issue/project_browser-3496059
  • issue/project_browser-3495317
  • issue/project_browser-3485108
  • issue/project_browser-3476807
  • issue/project_browser-3485386
  • issue/project_browser-3486091
  • issue/project_browser-3485747
  • issue/project_browser-3487842
  • issue/project_browser-3487834
  • issue/project_browser-3487868
  • issue/project_browser-3426179
  • issue/project_browser-3457378
  • issue/project_browser-3462307
  • issue/project_browser-3487835
  • issue/project_browser-3488078
  • issue/project_browser-3488141
  • issue/project_browser-3479924
  • issue/project_browser-3487960
  • issue/project_browser-3486291
  • issue/project_browser-3469860
  • issue/project_browser-3480098
  • issue/project_browser-3489028
  • issue/project_browser-3446603
  • issue/project_browser-3489573
  • issue/project_browser-3489665
  • issue/project_browser-3481652
  • issue/project_browser-3489810
  • issue/project_browser-3489827
  • issue/project_browser-3477950
  • issue/project_browser-3490176
  • issue/project_browser-3489972
  • issue/project_browser-3489126
  • issue/project_browser-3484474
  • issue/project_browser-3491232
  • issue/project_browser-3489422
  • issue/project_browser-3492153
  • issue/project_browser-3483412
  • issue/project_browser-3492108
  • issue/project_browser-3492209
  • issue/project_browser-3488156
  • issue/project_browser-3493332
  • issue/project_browser-3489729
  • issue/project_browser-3493778
  • issue/project_browser-3489054
  • issue/project_browser-3494672
  • issue/project_browser-3494858
  • issue/project_browser-3457966
  • issue/project_browser-3494506
  • issue/project_browser-3494655
  • issue/project_browser-3494978
  • issue/project_browser-3495112
  • issue/project_browser-3494818
  • issue/project_browser-3495168
  • issue/project_browser-3495211
  • issue/project_browser-3495330
  • issue/project_browser-3494819
  • issue/project_browser-3492262
  • issue/project_browser-3497778
  • issue/project_browser-3496236
  • issue/project_browser-3498040
  • issue/project_browser-3498231
  • issue/project_browser-3494512
  • issue/project_browser-3498267
  • issue/project_browser-3493916
  • issue/project_browser-3498560
  • issue/project_browser-3498835
  • issue/project_browser-3498901
  • issue/project_browser-3499390
  • issue/project_browser-3499406
  • issue/project_browser-3500024
  • issue/project_browser-3501036
  • issue/project_browser-3501194
  • issue/project_browser-3498564
  • issue/project_browser-3501590
  • issue/project_browser-3498562
  • issue/project_browser-3499411
  • issue/project_browser-3501766
  • issue/project_browser-3501455
  • issue/project_browser-3501453
  • issue/project_browser-3502161
  • issue/project_browser-3498570
  • issue/project_browser-3502465
  • issue/project_browser-3502399
  • issue/project_browser-3502612
  • issue/project_browser-3502645
  • issue/project_browser-3502666
  • issue/project_browser-3502643
  • issue/project_browser-3502604
  • issue/project_browser-3502850
  • issue/project_browser-3502918
  • issue/project_browser-3487845
  • issue/project_browser-3502935
  • issue/project_browser-3502986
  • issue/project_browser-3503148
  • issue/project_browser-3503140
  • issue/project_browser-3503197
  • issue/project_browser-3502854
  • issue/project_browser-3503137
  • issue/project_browser-3502603
  • issue/project_browser-3497909
  • issue/project_browser-3504656
  • issue/project_browser-3504781
  • issue/project_browser-3504960
  • issue/project_browser-3504997
  • issue/project_browser-3504665
  • issue/project_browser-3505152
  • issue/project_browser-3494824
  • issue/project_browser-3502734
  • issue/project_browser-3505188
  • issue/project_browser-3505249
  • issue/project_browser-3505285
  • issue/project_browser-3505450
  • issue/project_browser-3505607
  • issue/project_browser-3505608
  • issue/project_browser-3505592
  • issue/project_browser-3505700
  • issue/project_browser-3322601
  • issue/project_browser-3505837
  • issue/project_browser-3506252
  • issue/project_browser-3506178
  • issue/project_browser-3453724
  • issue/project_browser-3506517
  • issue/project_browser-3506511
  • issue/project_browser-3505159
  • issue/project_browser-3506547
  • issue/project_browser-3506557
  • issue/project_browser-3506377
  • issue/project_browser-3506454
  • issue/project_browser-3504655
  • issue/project_browser-3507468
  • issue/project_browser-3507536
  • issue/project_browser-3507922
  • issue/project_browser-3508090
  • issue/project_browser-3508182
  • issue/project_browser-3508334
  • issue/project_browser-3508547
  • issue/project_browser-3508350
  • issue/project_browser-3508631
  • issue/project_browser-3508786
  • issue/project_browser-3508956
  • issue/project_browser-3509170
  • issue/project_browser-3509222
  • issue/project_browser-3509184
  • issue/project_browser-3509242
  • issue/project_browser-3506513
  • issue/project_browser-3508906
  • issue/project_browser-3508971
  • issue/project_browser-3508969
  • issue/project_browser-3509375
  • issue/project_browser-3509380
  • issue/project_browser-3509449
  • issue/project_browser-3509499
  • issue/project_browser-3510420
  • issue/project_browser-3509730
  • issue/project_browser-3509217
  • issue/project_browser-3509733
  • issue/project_browser-3510642
  • issue/project_browser-3510506
  • issue/project_browser-3510796
  • issue/project_browser-3511079
  • issue/project_browser-3509162
  • issue/project_browser-3509674
  • issue/project_browser-3511104
  • issue/project_browser-3511069
  • issue/project_browser-3511080
  • issue/project_browser-3506565
  • issue/project_browser-3511246
  • issue/project_browser-3511417
  • issue/project_browser-3511994
  • issue/project_browser-3511927
  • issue/project_browser-3511942
  • issue/project_browser-3512609
  • issue/project_browser-3512610
  • issue/project_browser-3512781
  • issue/project_browser-3512785
  • issue/project_browser-3512856
  • issue/project_browser-3509738
  • issue/project_browser-3504664
  • issue/project_browser-3515746
  • issue/project_browser-3451012
  • issue/project_browser-3512831
  • issue/project_browser-3513763
  • issue/project_browser-3500926
  • issue/project_browser-3394832
713 results
Show changes
Showing
with 1851 additions and 1330 deletions
......@@ -13,7 +13,8 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
* Validates that packages to be installed are not already installed.
*
* @internal
* Tagged services are internal.
* This is an internal part of Project Browser and may be changed or removed
* at any time. It should not be used by external code.
*/
final class PackageNotInstalledValidator implements EventSubscriberInterface {
......
......@@ -3,41 +3,30 @@
namespace Drupal\project_browser\Controller;
use Drupal\Core\Controller\ControllerBase;
// cspell:ignore ctools
use Drupal\project_browser\Plugin\ProjectBrowserSourceInterface;
/**
* Defines a controller to provide the Project Browser UI.
*
* @internal
* Controller classes are internal.
* This is an internal part of Project Browser and may be changed or removed
* at any time. It should not be used by external code.
*/
class BrowserController extends ControllerBase {
final class BrowserController extends ControllerBase {
/**
* Builds the browse page and the individual module page.
*
* For routes without any module name, default browse page is rendered with
* all the available modules.
* For example, 'https//drupal-site/admin/modules/browse'.
* And for module specific paths, the respective detailed module page is
* rendered. For example, 'https//drupal-site/admin/modules/browse/ctools'
* will display the details for ctools.
* Builds the browse page for a particular source.
*
* @param string|null $source
* If viewing a specific project, the ID of its source plugin.
* @param string|null $id
* If viewing a specific project, the project's local ID (as known to the
* source plugin).
* @param \Drupal\project_browser\Plugin\ProjectBrowserSourceInterface $source
* The source plugin to query for projects.
*
* @return array
* A render array.
*/
public function browse(?string $source, ?string $id): array {
public function browse(ProjectBrowserSourceInterface $source): array {
return [
'#type' => 'project_browser',
'#source' => $source,
'#id' => $id,
];
}
......
......@@ -3,47 +3,32 @@
namespace Drupal\project_browser\Controller;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Utility\DeprecationHelper;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Url;
use Drupal\package_manager\Exception\StageException;
use Drupal\project_browser\ActivatorInterface;
use Drupal\package_manager\StatusCheckTrait;
use Drupal\project_browser\ComposerInstaller\Installer;
use Drupal\project_browser\EnabledSourceHandler;
use Drupal\project_browser\InstallState;
use Drupal\project_browser\ProjectBrowser\Project;
use Drupal\system\SystemManager;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Defines a controller to install projects via UI.
*
* @internal
* This is an internal part of Project Browser and may be changed or removed
* at any time. It should not be used by external code.
*/
class InstallerController extends ControllerBase {
final class InstallerController extends ControllerBase {
/**
* No require or install in progress for a given module.
*
* @var int
*/
protected const STATUS_IDLE = 0;
/**
* A staging install in progress for a given module.
*
* @var int
*/
protected const STATUS_REQUIRING_PROJECT = 1;
/**
* A core install in progress for a given project.
*
* @var int
*/
protected const STATUS_INSTALLING_PROJECT = 2;
use StatusCheckTrait;
/**
* The endpoint successfully returned the expected data.
......@@ -57,28 +42,31 @@ class InstallerController extends ControllerBase {
private readonly EnabledSourceHandler $enabledSourceHandler,
private readonly TimeInterface $time,
private readonly LoggerInterface $logger,
private readonly ActivatorInterface $activator,
private readonly InstallState $installState,
private readonly EventDispatcherInterface $eventDispatcher,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
public static function create(ContainerInterface $container): static {
$installer = $container->get(Installer::class);
assert($installer instanceof Installer);
return new static(
$container->get(Installer::class),
$installer,
$container->get(EnabledSourceHandler::class),
$container->get(TimeInterface::class),
$container->get('logger.channel.project_browser'),
$container->get(ActivatorInterface::class),
$container->get(InstallState::class),
$container->get(EventDispatcherInterface::class),
);
}
/**
* Checks if UI install is enabled on the site.
*/
public function access() :AccessResult {
public function access(): AccessResult {
$ui_install = $this->config('project_browser.admin_settings')->get('allow_ui_install');
return AccessResult::allowedIf((bool) $ui_install);
}
......@@ -106,32 +94,10 @@ class InstallerController extends ControllerBase {
}
}
/**
* Returns the status of the project in the temp store.
*
* @param \Drupal\project_browser\ProjectBrowser\Project $project
* A project whose status to report.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Information about the project's require/install status.
*/
public function inProgress(Project $project): JsonResponse {
$project_state = $this->installState->getStatus($project);
$return = ['status' => self::STATUS_IDLE];
if ($project_state !== NULL) {
$return['status'] = ($project_state === 'requiring' || $project_state === 'applying')
? self::STATUS_REQUIRING_PROJECT
: self::STATUS_INSTALLING_PROJECT;
$return['phase'] = $project_state;
}
return new JsonResponse($return);
}
/**
* Provides a JSON response for a given error.
*
* @param \Exception $e
* @param \Throwable $e
* The error that occurred.
* @param string $phase
* The phase the error occurred in.
......@@ -139,7 +105,7 @@ class InstallerController extends ControllerBase {
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Provides an error message to be displayed by the Project Browser UI.
*/
private function errorResponse(\Exception $e, string $phase = ''): JsonResponse {
private function errorResponse(\Throwable $e, string $phase = ''): JsonResponse {
$exception_type_short = (new \ReflectionClass($e))->getShortName();
$exception_message = $e->getMessage();
$response_body = ['message' => "$exception_type_short: $exception_message"];
......@@ -210,7 +176,7 @@ class InstallerController extends ControllerBase {
// accessed after. This final check ensures a destroy is not attempted
// during apply.
if ($this->installer->isApplying()) {
throw new StageException('Another project is being added. Try again in a few minutes.');
throw new StageException($this->installer, 'Another project is being added. Try again in a few minutes.');
}
// Adding the TRUE parameter to destroy is dangerous, but we provide it
......@@ -231,7 +197,11 @@ class InstallerController extends ControllerBase {
}
$this->installState->deleteAll();
$this->messenger()->addStatus($this->t('Operation complete, you can add a new project again.'));
return $this->redirect('project_browser.browse');
$redirect = Url::fromUserInput($this->getRedirectDestination()->get())
->setAbsolute()
->toString();
return new RedirectResponse($redirect);
}
/**
......@@ -250,65 +220,98 @@ class InstallerController extends ControllerBase {
];
$generated_url->applyTo($url_with_csrf_token_placeholder);
$renderer = \Drupal::service('renderer');
$output = DeprecationHelper::backwardsCompatibleCall(
currentVersion: \Drupal::VERSION,
deprecatedVersion: '10.3',
currentCallable: fn() => $renderer->renderInIsolation($url_with_csrf_token_placeholder),
deprecatedCallable: fn() => $renderer->renderPlain($url_with_csrf_token_placeholder),
);
return (string) $output;
return (string) \Drupal::service('renderer')
->renderInIsolation($url_with_csrf_token_placeholder);
}
/**
* Begins requiring by creating a stage.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The current request.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/
public function begin(): JsonResponse {
public function begin(Request $request): JsonResponse {
$stage_available = $this->installer->isAvailable();
if (!$stage_available) {
$updated_time = $this->installState->getFirstUpdatedTime();
// The sandbox is being used by something that isn't Project Browser (e.g.
// Automatic Updates), so there's nothing we can do.
if (!$this->installer->lockCameFromProjectBrowserInstaller()) {
return $this->lockedResponse($this->t('The process for adding projects was locked by something else outside of Project Browser. Projects can be added again once the process is unlocked. Try again in a few minutes.'), '');
return $this->lockedResponse($this->t('The process for adding projects was locked by something else outside of Project Browser. Projects can be added again once the process is unlocked. Try again in a few minutes.'));
}
// If we got here, the sandbox is locked by us, so prepare a link that
// allows the user to unlock it if possible.
$unlock_url = self::getUrlWithReplacedCsrfTokenPlaceholder(
Url::fromRoute('project_browser.install.unlock'),
);
$unlock_url .= '&destination=' . $request->query->get('redirect');
// We had locked the sandbox, but never actually ended up requiring any
// projects into it, so allow the user to unlock it right now.
$updated_time = $this->installState->getFirstUpdatedTime();
if (empty($updated_time)) {
$unlock_url = self::getUrlWithReplacedCsrfTokenPlaceholder(
Url::fromRoute('project_browser.install.unlock')
);
$message = t('The process for adding projects is locked, but that lock has expired. Use [+ unlock link] to unlock the process and try to add the project again.');
$message = $this->t('The process for adding projects is locked, but that lock has expired. Use [+ unlock link] to unlock the process and try to add the project again.');
return $this->lockedResponse($message, $unlock_url);
}
$time_since_updated = $this->time->getRequestTime() - $updated_time;
$hours = (int) gmdate("H", $time_since_updated);
$minutes = (int) gmdate("i", $time_since_updated);
$minutes = $time_since_updated > 60 ? $minutes : 'less than 1';
if ($this->installer->isApplying()) {
$message = empty(floor($hours)) ?
$this->t('The process for adding the project was locked @minutes minutes ago. It should not be unlocked while changes are being applied to the site.', ['@minutes' => $minutes]) :
$this->t('The process for adding the project was locked @hours hours, @minutes minutes ago. It should not be unlocked
while changes are being applied to the site.', ['@hours' => $hours, '@minutes' => $minutes]);
return $this->lockedResponse($message, '');
// Figure out how long it's been since we locked the sandbox. In a test
// environment, allow the current request time to be nudged around.
$request_time = $this->time->getRequestTime();
if (drupal_valid_test_ua()) {
$request_time += $this->state()->get('InstallerController time offset', 0);
}
elseif ($hours === 0 && ($minutes < 7 || $minutes === 'less than 1')) {
$message = $this->t('The process for adding the project that was locked @minutes minutes ago might still be in progress. Consider waiting a few more minutes before using [+unlock link].', ['@minutes' => $minutes]);
$seconds_since_updated = $request_time - $updated_time;
$hours_since_updated = (int) floor($seconds_since_updated / 3600);
$minutes_since_updated = (int) floor(($seconds_since_updated % 3600) / 60);
if ($hours_since_updated) {
$locked_since = $this->formatPlural($hours_since_updated, 'an hour ago', '@count hours ago');
}
else {
$message = empty($hours) ?
$this->t('The process for adding the project was locked @minutes minutes ago. Use [+ unlock link] to unlock the process.', ['@minutes' => $minutes]) :
$this->t('The process for adding the project was locked @hours hours, @minutes minutes ago. Use [+ unlock link] to unlock the process.',
['@hours' => $hours, '@minutes' => $minutes]);
$locked_since = $minutes_since_updated
? $this->formatPlural($minutes_since_updated, 'a minute ago', '@count minutes ago')
: $this->t('less than a minute ago');
}
$unlock_url = self::getUrlWithReplacedCsrfTokenPlaceholder(
Url::fromRoute('project_browser.install.unlock')
);
// If we're still applying changes, and it's been less than an hour, don't
// offer the unlock link.
if ($this->installer->isApplying() && $hours_since_updated === 0) {
$message = $this->t('The process for adding the project was locked @since. It should not be unlocked while changes are being applied to the site.', [
'@since' => $locked_since,
]);
return $this->lockedResponse($message);
}
// If the sandbox has been locked for at least 7 minutes, offer the
// unlock link.
elseif ($minutes_since_updated > 7) {
$message = $this->t('The process for adding the project was locked @since. Use [+ unlock link] to unlock the process.', [
'@since' => $locked_since,
]);
}
// In all other cases, allow the user to unlock the sandbox, but ask them
// to have some patience.
else {
$message = $this->t('The process for adding the project that was locked @since might still be in progress. Consider waiting a few more minutes before using [+unlock link].', [
'@since' => $locked_since,
]);
}
return $this->lockedResponse($message, $unlock_url);
}
// Ensure the environment is ready to use Package Manager.
['errors' => $errors, 'warnings' => $warnings] = $this->validatePackageManager();
if ($warnings) {
$this->logger->warning(implode("\n", $warnings));
}
if ($errors) {
$error_message = '<ul><li>' . implode('</li><li>', $errors) . '</li></ul>';
return $this->errorResponse(new StageException($this->installer, $error_message));
}
try {
$stage_id = $this->installer->create();
}
......@@ -333,18 +336,9 @@ class InstallerController extends ControllerBase {
*/
public function require(Request $request, string $stage_id): JsonResponse {
$package_names = [];
foreach ($request->toArray() as $project) {
$project = $this->enabledSourceHandler->getStoredProject($project);
if ($project->source === 'project_browser_test_mock') {
$source = $this->enabledSourceHandler->getCurrentSources()[$project->source] ?? NULL;
if ($source === NULL) {
return new JsonResponse(['message' => "Cannot download $project->id from any available source"], 500);
}
if (!$source->isProjectSafe($project)) {
return new JsonResponse(['message' => "$project->machineName is not safe to add because its security coverage has been revoked"], 500);
}
}
$this->installState->setState($project, 'requiring');
foreach ($request->toArray() as $project_id) {
$project = $this->enabledSourceHandler->getStoredProject($project_id);
$this->installState->setState($project_id, 'requiring');
$package_names[] = $project->packageName;
}
try {
......@@ -368,7 +362,7 @@ class InstallerController extends ControllerBase {
*/
public function apply(string $stage_id): JsonResponse {
foreach (array_keys($this->installState->toArray()) as $project_id) {
$this->installState->setState($this->enabledSourceHandler->getStoredProject($project_id), 'applying');
$this->installState->setState($project_id, 'applying');
}
try {
$this->installer->claim($stage_id)->apply();
......@@ -423,30 +417,29 @@ class InstallerController extends ControllerBase {
}
/**
* Installs an already downloaded module.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
* Checks if the environment meets Package Manager install requirements.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
* @return array[]
* An array with two sub-elements:
* - errors: The validation messages with an "error" severity.
* - warnings: All other validation messages, which are probably warnings.
*/
public function activate(Request $request): JsonResponse {
foreach ($request->toArray() as $project) {
$project = $this->enabledSourceHandler->getStoredProject($project);
$this->installState->setState($project, 'activating');
try {
$this->activator->activate($project);
$this->installState->setState($project, 'installed');
}
catch (\Throwable $e) {
return $this->errorResponse($e, 'project install');
}
finally {
$this->installState->deleteAll();
private function validatePackageManager(): array {
$results = [
'errors' => [],
'warnings' => [],
];
foreach ($this->runStatusCheck($this->installer, $this->eventDispatcher) as $result) {
$group = $result->severity === SystemManager::REQUIREMENT_ERROR
? 'errors'
: 'warnings';
if ($result->summary) {
$results[$group][] = $result->summary;
}
$results[$group] = array_merge($results[$group], $result->messages);
}
return new JsonResponse(['status' => 0]);
return $results;
}
}
......@@ -2,61 +2,137 @@
namespace Drupal\project_browser\Controller;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\MessageCommand;
use Drupal\Core\Ajax\ScrollTopCommand;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Extension\ModuleInstallerInterface;
use Drupal\Core\Form\FormState;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Url;
use Drupal\Core\Utility\Error;
use Drupal\project_browser\ActivationManager;
use Drupal\project_browser\EnabledSourceHandler;
use Drupal\project_browser\InstallState;
use Drupal\project_browser\ProjectBrowser\Normalizer;
use Drupal\project_browser\RefreshProjectsCommand;
use Drupal\system\Form\ModulesUninstallForm;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* Controller for the proxy layer.
*
* @internal
* This is an internal part of Project Browser and may be changed or removed
* at any time. It should not be used by external code.
*/
class ProjectBrowserEndpointController extends ControllerBase {
final class ProjectBrowserEndpointController extends ControllerBase {
/**
* Constructor for endpoint controller.
*
* @param \Drupal\project_browser\EnabledSourceHandler $enabledSource
* The enabled project browser source.
*/
public function __construct(
private readonly EnabledSourceHandler $enabledSource,
private readonly NormalizerInterface $normalizer,
private readonly ModuleInstallerInterface $moduleInstaller,
private readonly ModuleExtensionList $moduleList,
private readonly ActivationManager $activationManager,
private readonly InstallState $installState,
private readonly LoggerInterface $logger,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
public static function create(ContainerInterface $container): static {
return new static(
$container->get(EnabledSourceHandler::class),
$container->get(Normalizer::class),
$container->get(ModuleInstallerInterface::class),
$container->get(ModuleExtensionList::class),
$container->get(ActivationManager::class),
$container->get(InstallState::class),
$container->get('logger.channel.project_browser'),
);
}
/**
* Responds to GET requests.
*
* Returns a list of bundles for specified entity.
* Returns a list of projects that match a query.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Typically a project listing.
* A list of projects.
*
* @see \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage
*/
public function getAllProjects(Request $request) {
$id = $request->query->get('id');
if ($id) {
return new JsonResponse($this->enabledSource->getStoredProject($id));
}
public function getAllProjects(Request $request): JsonResponse {
$current_sources = $this->enabledSource->getCurrentSources();
if (!$current_sources) {
$query = $this->buildQuery($request);
if (!$current_sources || empty($query['source'])) {
return new JsonResponse([], Response::HTTP_ACCEPTED);
}
$query = $this->buildQuery($request);
return new JsonResponse($this->enabledSource->getProjects($query));
$results = $this->enabledSource->getProjects($query['source'], $query);
return new JsonResponse($this->normalizer->normalize($results));
}
/**
* Prepares to uninstall a module.
*
* @param string $name
* The machine name of the module to uninstall.
* @param \Symfony\Component\HttpFoundation\Request $request
* The current request.
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* A redirection to the uninstall confirmation page.
*/
public function uninstall(string $name, Request $request): RedirectResponse {
$return_to = $request->query->get('return_to', Url::fromRoute('<front>')->toString());
assert(is_string($return_to));
// Ensure this module CAN be uninstalled. If it can't, redirect back to the
// return URL with the messages set as errors.
$validation_errors = $this->moduleInstaller->validateUninstall([$name]);
// Check if the module is required by any installed modules, and flag an
// error if so.
$required_by = array_intersect_key(
$this->moduleList->get($name)->required_by,
$this->moduleList->getAllInstalledInfo(),
);
if ($required_by) {
$required_by = array_map($this->moduleList->getName(...), array_keys($required_by));
natcasesort($required_by);
$validation_errors['project_browser'] = [
$this->t('@name cannot be uninstalled because the following module(s) depend on it and must be uninstalled first: @list', [
'@name' => $this->moduleList->getName($name),
'@list' => implode(', ', $required_by),
]),
];
}
if ($validation_errors) {
array_walk_recursive($validation_errors, function ($error): void {
$this->messenger()->addError($error);
});
return new RedirectResponse($return_to);
}
$form_state = new FormState();
$form_state->setValue('uninstall', [$name => $name]);
$this->formBuilder()->submitForm(ModulesUninstallForm::class, $form_state);
return $this->redirect('system.modules_uninstall_confirm', options: [
'query' => [
'destination' => $return_to,
],
]);
}
/**
......@@ -114,25 +190,59 @@ class ProjectBrowserEndpointController extends ControllerBase {
if ($displayed_source) {
$query['source'] = $displayed_source;
}
// Done to cache results.
$tabwise_categories = $request->query->get('tabwise_categories');
if ($tabwise_categories) {
$query['tabwise_categories'] = $tabwise_categories;
}
return $query;
}
/**
* Returns a list of categories.
* Installs an already downloaded project.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* A response that can be used by the client-side AJAX system.
*/
public function getAllCategories() {
$current_sources = $this->enabledSource->getCurrentSources();
if (!$current_sources) {
return new JsonResponse([], Response::HTTP_ACCEPTED);
public function activate(Request $request): AjaxResponse {
$response = new AjaxResponse();
$activated_projects = [];
$projects = $request->query->get('projects') ?? [];
if ($projects) {
assert(is_string($projects));
$projects = explode(',', $projects);
}
return new JsonResponse($this->enabledSource->getCategories());
assert(is_array($projects));
foreach ($projects as $project_id) {
$this->installState->setState($project_id, 'activating');
// $project_id is fully qualified and has the form `SOURCE_ID/LOCAL_ID`.
[$source_id] = explode('/', $project_id, 2);
try {
$project = $this->enabledSource->getStoredProject($project_id);
$commands = $this->activationManager->activate($project);
foreach ($commands ?? [] as $command) {
$response->addCommand($command);
}
$this->installState->setState($project_id, 'installed');
$activated_projects[] = $this->normalizer->normalize($project, context: ['source' => $source_id]);
}
catch (\Throwable $e) {
$message = $e->getMessage();
$response->addCommand(new MessageCommand(
$message,
options: [
'type' => MessengerInterface::TYPE_ERROR,
'id' => 'activation_error:' . $project_id,
],
));
$response->addCommand(new ScrollTopCommand('[data-drupal-messages]'));
Error::logException($this->logger, $e);
}
}
$this->installState->deleteAll();
return $response->addCommand(new RefreshProjectsCommand($activated_projects));
}
}
<?php
declare(strict_types=1);
namespace Drupal\project_browser;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* The development statuses available to the project browser.
*/
enum DevelopmentStatus: string {
case Active = '1';
case All = '0';
/**
* Represents this enum as a set of options.
*
* @return array<string, \Drupal\Core\StringTranslation\TranslatableMarkup>
* The cases in this enum. The keys are the backing values, and the values
* are the translatable labels.
*/
public static function asOptions(): array {
$options = [];
foreach (self::cases() as $case) {
$options[$case->value] = $case->label();
}
return $options;
}
/**
* Returns a translatable label for the current case.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup
* A translatable label.
*/
public function label(): TranslatableMarkup {
return match ($this) {
self::Active => t('Show projects under active development'),
self::All => t('Show all'),
};
}
}
<?php
namespace Drupal\project_browser\Drush\Commands;
use Drupal\project_browser\EnabledSourceHandler;
use Drush\Attributes\Command;
use Drush\Attributes\Usage;
use Drush\Commands\AutowireTrait;
use Drush\Commands\DrushCommands;
/**
* Contains Drush commands for Project Browser.
*
* @internal
* This is an internal part of Project Browser and may be changed or removed
* at any time. It should not be used by external code.
*/
final class ProjectBrowserCommands extends DrushCommands {
use AutowireTrait;
public function __construct(
private readonly EnabledSourceHandler $enabledSourceHandler,
) {
parent::__construct();
}
/**
* Clears stored project data.
*/
#[Command(name: 'project-browser:storage-clear', aliases: ['pb-sc'])]
#[Usage(name: 'project-browser:storage-clear', description: 'Clear stored Project Browser data')]
public function storageClear(): void {
$this->enabledSourceHandler->clearStorage();
$this->logger()?->success(dt('Stored data from Project Browser sources have been cleared.'));
}
}
......@@ -2,54 +2,90 @@
namespace Drupal\project_browser\Element;
use Drupal\Component\Utility\DeprecationHelper;
use Drupal\Component\Assertion\Inspector;
use Drupal\Component\Uuid\UuidInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Path\CurrentPathStack;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Render\Attribute\RenderElement;
use Drupal\Core\Render\Element;
use Drupal\Core\Render\Element\ElementInterface;
use Drupal\Core\Render\Element\RenderElementBase;
use Drupal\project_browser\DevelopmentStatus;
use Drupal\project_browser\EnabledSourceHandler;
use Drupal\project_browser\InstallReadiness;
use Drupal\project_browser\MaintenanceStatus;
use Drupal\project_browser\Plugin\ProjectBrowserSourceInterface;
use Drupal\project_browser\SecurityStatus;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a render element for the Project Browser.
* Provides a render element to display a project browser.
*
* @RenderElement("project_browser")
* Properties:
* - #source: An instance of a Project Browser source plugin that implements
* \Drupal\project_browser\Plugin\ProjectBrowserSourceInterface.
* - #id: (optional) An internal identifier for this Project Browser instance.
* This is never displayed to the user and will be randomly generated if not
* provided. There is usually no reason to specify it explicitly. It is not a
* CSS ID, and cannot be used for styling.
* - #sort_options: (optional) The sort options to offer to the user. If given,
* must be an associative array whose keys exist in the sort options returned
* from the source plugin's ::getSortOptions() method. The values are the
* human-readable names for the sort options, and will be shown to the user.
* The human-readable names can be any arbitrary string. This property
* defaults to the sort options as defined by the source plugin.
* - #sort_by: (optional) The default sort criterion. Must be one of the keys of
* the array returned by the source plugin's ::getSortOptions() method. If not
* specified, defaults to the first defined sort criterion.
* - #paginate: (optional) Whether or not to enable pagination. Boolean,
* defaults to TRUE. If pagination is disabled, only the first page of
* projects will be displayed, and the user will not be able to advance to a
* different page or change the page size.
* - #page_sizes: (optional) An array of options to present the user for them to
* choose how many projects to display on each page, if pagination is enabled.
* Must be an array of numbers that are greater than zero. Does not need to be
* in any particular order. Defaults to 12, 24, 36, and 48.
* - #filters: (optional) Associative array of filters where keys are filter
* machine names, and values are their default values. If provided, only
* these filters will be displayed, and their default values will be
* set accordingly.
*
* Usage example:
*
* @code
* $source = \Drupal::service(ProjectBrowserSourceManager::class)
* ->createInstance('drupalorg_jsonapi');
*
* $build['projects'] = [
* '#type' => 'project_browser',
* '#source' => $source,
* '#sort_options' => [
* 'a_z' => t('Alphabetical'),
* 'popularity' => t('Most liked'),
* ],
* '#sort_by' => 'a_z',
* '#paginate' => FALSE,
* '#page_sizes' => [5, 10, 25],
* '#filters' => [
* 'development_status' => TRUE,
* 'categories' => [123, 456],
* ],
* ];
* @endcode
*
* @internal
* This is an internal part of Project Browser and may be changed or removed
* at any time. It should not be used by external code.
*/
#[RenderElement('project_browser')]
final class ProjectBrowser implements ElementInterface, ContainerFactoryPluginInterface {
final class ProjectBrowser extends RenderElementBase implements ContainerFactoryPluginInterface {
use DependencySerializationTrait;
public function __construct(
private readonly string $pluginId,
private readonly mixed $pluginDefinition,
private readonly EnabledSourceHandler $enabledSourceHandler,
private readonly ?InstallReadiness $installReadiness,
private readonly ModuleHandlerInterface $moduleHandler,
private readonly ConfigFactoryInterface $configFactory,
) {}
/**
* {@inheritdoc}
*/
public function getPluginId(): string {
return $this->pluginId;
}
/**
* {@inheritdoc}
*/
public function getPluginDefinition(): mixed {
return $this->pluginDefinition;
private readonly UuidInterface $uuid,
private readonly CurrentPathStack $currentPath,
mixed ...$arguments,
) {
parent::__construct(...$arguments);
}
/**
......@@ -57,12 +93,13 @@ final class ProjectBrowser implements ElementInterface, ContainerFactoryPluginIn
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
return new static(
$plugin_id,
$plugin_definition,
$container->get(EnabledSourceHandler::class),
$container->get(InstallReadiness::class, ContainerInterface::NULL_ON_INVALID_REFERENCE),
$container->get(ModuleHandlerInterface::class),
$container->get(ConfigFactoryInterface::class),
$container->get(UuidInterface::class),
$container->get(CurrentPathStack::class),
$configuration,
$plugin_id,
$plugin_definition,
);
}
......@@ -74,7 +111,7 @@ final class ProjectBrowser implements ElementInterface, ContainerFactoryPluginIn
'#theme' => 'project_browser_main_app',
'#attached' => [
'library' => [
'project_browser/svelte',
'project_browser/app',
],
'drupalSettings' => [
'project_browser' => [],
......@@ -83,6 +120,7 @@ final class ProjectBrowser implements ElementInterface, ContainerFactoryPluginIn
'#pre_render' => [
[$this, 'attachProjectBrowserSettings'],
],
'#page_sizes' => [12, 24, 36, 48],
];
}
......@@ -96,102 +134,81 @@ final class ProjectBrowser implements ElementInterface, ContainerFactoryPluginIn
* The render element array.
*/
public function attachProjectBrowserSettings(array $element): array {
$element['#attached']['drupalSettings']['project_browser'] = $this->getDrupalSettings(
$element['#source'] ?? NULL,
$element['#id'] ?? NULL
);
return $element;
}
assert($element['#source'] instanceof ProjectBrowserSourceInterface);
$source = $element['#source'];
$element['#id'] ??= $this->uuid->generate();
$sort_options = $source->getSortOptions();
// If the element specifies sort options, ensure that they are all defined
// by the source plugin.
if (isset($element['#sort_options'])) {
$unknown_sort_options = array_diff_key($element['#sort_options'], $sort_options);
if ($unknown_sort_options) {
throw new \InvalidArgumentException("Unknown sort option(s): " . implode(', ', array_keys($unknown_sort_options)));
}
/**
* Gets the Drupal settings for the Project Browser.
*
* @param string|null $source
* If viewing a specific project, the ID of its source plugin.
* @param string|null $id
* If viewing a specific project, the project's local ID (as known to the
* source plugin).
*
* @return array
* An array of Drupal settings.
*/
private function getDrupalSettings(?string $source, ?string $id): array {
$current_sources = $this->enabledSourceHandler->getCurrentSources();
if ($source) {
$current_sources = [
$source => $current_sources[$source],
];
$sort_options = $element['#sort_options'];
if (empty($sort_options)) {
throw new \InvalidArgumentException('At least one sort option must be defined.');
}
}
$package_manager = [
'available' => (bool) $this->configFactory->get('project_browser.admin_settings')->get('allow_ui_install'),
'errors' => [],
'warnings' => [],
'status_checked' => FALSE,
];
if (empty($source) || empty($id)) {
if ($package_manager['available']) {
$package_manager = array_merge($package_manager, $this->installReadiness->validatePackageManager());
$package_manager['status_checked'] = TRUE;
$sort_by = $element['#sort_by'] ?? key($sort_options);
assert(
array_key_exists($sort_by, $sort_options),
new \InvalidArgumentException("'$sort_by' is not a valid sort criterion."),
);
$page_sizes = array_map('intval', $element['#page_sizes']);
assert(
count($page_sizes) > 0 &&
Inspector::assertAll(fn (int $value): bool => $value > 0, $page_sizes),
new \InvalidArgumentException('#page_sizes must be an array of integers greater than zero.'),
);
// This sort will re-key the array.
sort($page_sizes);
// If the element overrides the filters, ensure all of them are defined by
// the source plugin, and allow the element to override the default values.
$filters = $source->getFilterDefinitions();
if (isset($element['#filters'])) {
assert(is_array($element['#filters']));
$invalid_filters = array_diff_key($element['#filters'], $filters);
if ($invalid_filters) {
throw new \InvalidArgumentException('Unknown filter(s): ' . implode(', ', array_keys($invalid_filters)));
}
$filters = array_intersect_key($filters, $element['#filters']);
foreach ($element['#filters'] as $name => $default_value) {
$filters[$name]->setValue($default_value);
}
}
return [
'active_plugins' => array_map(
fn (ProjectBrowserSourceInterface $source) => $source->getPluginDefinition()['label'],
$current_sources,
),
'module_path' => $this->moduleHandler->getModule('project_browser')->getPath(),
'special_ids' => $this->getSpecialIds(),
'sort_options' => array_map(
fn (ProjectBrowserSourceInterface $source) => array_values($source->getSortOptions()),
$current_sources,
),
'maintenance_options' => MaintenanceStatus::asOptions(),
'security_options' => SecurityStatus::asOptions(),
'development_options' => DevelopmentStatus::asOptions(),
'default_plugin_id' => reset($current_sources)->getPluginId(),
'current_sources_keys' => array_keys($current_sources),
'package_manager' => $package_manager,
'filters' => array_map(
fn (ProjectBrowserSourceInterface $source) => $source->getFilterDefinitions(),
$current_sources,
),
];
}
$global_settings = $this->configFactory->get('project_browser.admin_settings');
/**
* Return special IDs for some vocabularies.
*
* @return array
* List of special IDs per vocabulary.
*/
private static function getSpecialIds(): array {
$maintained = MaintenanceStatus::Maintained;
$covered = SecurityStatus::Covered;
return [
'maintenance_status' => [
'id' => $maintained->value,
'name' => $maintained->label(),
],
'security_coverage' => [
'id' => $covered->value,
'name' => $covered->label(),
$element['#attached']['drupalSettings']['project_browser'] = [
'module_path' => $this->moduleHandler->getModule('project_browser')->getPath(),
'default_plugin_id' => $source->getPluginId(),
'package_manager' => $global_settings->get('allow_ui_install') && $this->moduleHandler->moduleExists('package_manager'),
'max_selections' => $global_settings->get('max_selections') ?? NULL,
'current_path' => '/' . $this->currentPath->getPath(),
'instances' => [
$element['#id'] => [
'source' => $source->getPluginId(),
'name' => $source->getPluginDefinition()['label'],
// Cast these to objects so that they will still be encoded as objects
// even if they are empty arrays.
'filters' => (object) $filters,
'sorts' => (object) $sort_options,
'sortBy' => $sort_by,
'paginate' => $element['#paginate'] ?? TRUE,
'pageSizes' => $page_sizes,
],
],
'all_values' => MaintenanceStatus::All->value,
];
}
/**
* {@inheritdoc}
*/
public static function setAttributes(&$element, $class = []): void {
DeprecationHelper::backwardsCompatibleCall(
\Drupal::VERSION,
'10.3',
static fn () => RenderElementBase::setAttributes($element, $class),
static fn () => Element::setAttributes($element, $class)
);
return $element;
}
}
......@@ -2,13 +2,16 @@
namespace Drupal\project_browser;
use Composer\InstalledVersions;
use Drupal\Component\Plugin\Discovery\CachedDiscoveryInterface;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Block\BlockManagerInterface;
use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
use Drupal\Core\Config\ConfigCrudEvent;
use Drupal\Core\Config\ConfigEvents;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface;
use Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface;
use Drupal\project_browser\Plugin\ProjectBrowserSourceInterface;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
use Drupal\project_browser\Plugin\ProjectBrowserSourceManager;
use Drupal\project_browser\ProjectBrowser\Project;
use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage;
......@@ -17,26 +20,35 @@ use Psr\Log\LoggerAwareTrait;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Defines enabled source.
* Handles retrieving projects from enabled sources.
*
* @internal
* This is an internal part of Project Browser and may be changed or removed
* at any time. It should not be used by external code.
*/
class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInterface {
final class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInterface {
use LoggerAwareTrait;
/**
* The key-value storage.
*
* @var \Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface
*/
private readonly KeyValueStoreExpirableInterface $keyValue;
public function __construct(
private readonly ConfigFactoryInterface $configFactory,
private readonly ProjectBrowserSourceManager $pluginManager,
private readonly ActivatorInterface $activator,
KeyValueExpirableFactoryInterface $keyValueFactory,
) {
$this->keyValue = $keyValueFactory->get('project_browser');
private readonly KeyValueFactoryInterface $keyValueFactory,
private readonly BlockManagerInterface $blockManager,
private readonly CacheTagsInvalidatorInterface $cacheTagsInvalidator,
) {}
/**
* Returns a key-value store for a particular source plugin.
*
* @param string $source_id
* The ID of a source plugin.
*
* @return \Drupal\Core\KeyValueStore\KeyValueStoreInterface
* A key-value store for the specified source plugin.
*/
private function keyValue(string $source_id): KeyValueStoreInterface {
return $this->keyValueFactory->get("project_browser:$source_id");
}
/**
......@@ -55,8 +67,23 @@ class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInter
* The event object.
*/
public function onConfigSave(ConfigCrudEvent $event): void {
if ($event->getConfig()->getName() === 'project_browser.admin_settings' && $event->isChanged('enabled_sources')) {
$this->keyValue->deleteAll();
$config = $event->getConfig();
if ($config->getName() === 'project_browser.admin_settings' && $event->isChanged('enabled_sources')) {
// Ensure that the cached source and block plugin definitions stay in sync
// with the enabled sources.
$this->pluginManager->clearCachedDefinitions();
assert($this->blockManager instanceof CachedDiscoveryInterface);
$this->blockManager->clearCachedDefinitions();
// Invalidate any cached, rendered blocks.
// @see \Drupal\project_browser\Plugin\Block\ProjectBrowserBlock::build()
$this->cacheTagsInvalidator->invalidateTags(['project_browser_block']);
// Clear stored data for the sources that have been disabled.
$disabled_sources = array_diff(
$config->getOriginal('enabled_sources') ?? [],
$config->get('enabled_sources'),
);
array_walk($disabled_sources, $this->clearStorage(...));
}
}
......@@ -85,146 +112,128 @@ class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInter
}
/**
* Returns projects that match a particular query, from all enabled sources.
* Returns projects that match a particular query, from specified source.
*
* @param string $source_id
* The ID of the source plugin to query projects from.
* @param array $query
* (optional) The query to pass to the enabled sources.
* (optional) The query to pass to the specified source.
*
* @return \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage[]
* The results of the query, keyed by source plugin ID.
* @return \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage
* The result of the query.
*/
public function getProjects(array $query = []): array {
public function getProjects(string $source_id, array $query = []): ProjectsResultsPage {
// Cache only exact query, down to the page number.
$cache_key = 'query:' . md5(Json::encode($query));
$stored = $this->keyValue->get($cache_key);
if (is_array($stored)) {
$projects = [];
// We store query results as a set of arguments to ProjectsResultsPage,
// although the list of projects is a list of project IDs, all of which
// we expect to be in the data store.
foreach ($stored as $source_id => $arguments) {
$arguments[1] = array_map($this->getStoredProject(...), $arguments[1]);
$arguments[] = $source_id;
$projects[$source_id] = new ProjectsResultsPage(...$arguments);
}
$cache_key = $this->getQueryCacheKey($query);
$storage = $this->keyValue($source_id);
$results = $storage->get($cache_key);
// If $results is an array, it's a set of arguments to ProjectsResultsPage,
// with a list of project IDs that we expect to be in the data store.
if (is_array($results)) {
$results[1] = $storage->getMultiple($results[1]);
$results[1] = array_values($results[1]);
return new ProjectsResultsPage(...$results);
}
else {
$projects = $this->doQuery($query);
$stored = [];
foreach ($projects as $source_id => $results) {
foreach ($results->list as $project) {
// Prefix the local project ID with the source plugin ID, so we can
// look it up unambiguously.
$project->id = $source_id . '/' . $project->id;
$this->keyValue->setIfNotExists($project->id, $project);
// Add activation data to the project. This is volatile and should not
// be changed.
$this->getActivationData($project);
}
// Store each source's results for this query as a set of arguments to
// ProjectsResultsPage.
$stored[$source_id] = [
$results->totalResults,
array_column($results->list, 'id'),
$results->pluginLabel,
];
}
$this->keyValue->set($cache_key, $stored);
$results = $this->doQuery($source_id, $query);
// Sanity check: ensure the source has actually claimed ownership of these
// results (this can help prevent bugs in sources that decorate others).
assert($results->pluginId === $source_id);
// Cache all the projects individually so they can be loaded by
// ::getStoredProject().
foreach ($results->list as $project) {
$storage->setIfNotExists($project->id, $project);
}
return $projects;
// If there were no query errors, store the results as a set of arguments
// to ProjectsResultsPage.
if (empty($results->error)) {
$storage->set($cache_key, [
$results->totalResults,
array_column($results->list, 'id'),
$results->pluginLabel,
$source_id,
$results->error,
]);
}
return $results;
}
/**
* Queries all enabled sources.
* Generates a cache key for a specific query.
*
* @param array $query
* (optional) The query to pass to the enabled sources.
*
* @return \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage[]
* The results of the query, keyed by source plugin ID.
* The query.
*
* @see \Drupal\project_browser\Plugin\ProjectBrowserSourceInterface::getProjects()
* @return string
* A cache key for the given query.
*/
private function doQuery(array $query = []): array {
$displayed_source = $query['source'] ?? '';
$page = $query['page'] ?? 0;
$query['categories'] ??= '';
$tabwise_categories = Json::decode($query['tabwise_categories'] ?? '[]');
unset($query['tabwise_categories']);
$projects = [];
foreach ($this->getCurrentSources() as $source_name => $source) {
if ($displayed_source && $displayed_source !== $source_name) {
// If the source is not the one currently displayed in the UI, request
// page 0.
$query['page'] = 0;
}
else {
$query['page'] = $page;
}
// Get tab-wise results based on category filter.
$query['categories'] = implode(", ", $tabwise_categories[$source_name] ?? []);
$projects[$source_name] = $source->getProjects($query);
}
return $projects;
private function getQueryCacheKey(array $query): string {
// Include a quick hash of the top-level `composer.lock` file in the hash,
// so that sources which base their queries on the state of the local site
// will be refreshed when the local site changes.
['install_path' => $project_root] = InstalledVersions::getRootPackage();
$lock_file = $project_root . DIRECTORY_SEPARATOR . 'composer.lock';
$lock_file_hash = file_exists($lock_file)
? hash_file('xxh64', $lock_file)
: '';
return 'query:' . md5(Json::encode($query) . $lock_file_hash);
}
/**
* Returns the available categories across all enabled sources.
* Queries the specified source.
*
* @return array[]
* The available categories, keyed by source plugin ID.
* @param string $source_id
* The ID of the source plugin to query projects from.
* @param array $query
* (optional) The query to pass to the specified source.
*
* @return \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage
* The results of the query.
*
* @see \Drupal\project_browser\Plugin\ProjectBrowserSourceInterface::getProjects()
*/
public function getCategories(): array {
$cache_key = 'categories';
$categories = $this->keyValue->get($cache_key);
if ($categories === NULL) {
$categories = array_map(
fn (ProjectBrowserSourceInterface $source) => $source->getCategories(),
$this->getCurrentSources(),
);
$this->keyValue->set($cache_key, $categories);
}
return $categories;
private function doQuery(string $source_id, array $query = []): ProjectsResultsPage {
$query['categories'] ??= '';
$enabled_sources = $this->getCurrentSources();
assert(array_key_exists($source_id, $enabled_sources));
return $enabled_sources[$source_id]->getProjects($query);
}
/**
* Looks up a previously stored project by its ID.
*
* @param string $id
* The project ID. See ::getProjects() for where this is set.
* The fully qualified project ID, in the form `SOURCE_ID/LOCAL_ID`.
*
* @return \Drupal\project_browser\ProjectBrowser\Project
* The project object, with activation status and commands added.
* The project object.
*
* @throws \RuntimeException
* Thrown if the project is not found in the non-volatile data store.
*/
public function getStoredProject(string $id): Project {
$project = $this->keyValue->get($id) ?? throw new \RuntimeException("Project '$id' was not found in non-volatile storage.");
$this->getActivationData($project);
return $project;
[$source_id, $local_id] = explode('/', $id, 2);
return $this->keyValue($source_id)->get($local_id) ?? throw new \RuntimeException("Project '$id' was not found in non-volatile storage.");
}
/**
* Adds activation data to a project object.
* Clears the key-value store so it can be re-fetched.
*
* @param \Drupal\project_browser\ProjectBrowser\Project $project
* The project object.
* @param string|null $source_id
* (optional) The ID of the source for which data should be cleared. If
* NULL, stored data is cleared for all enabled sources. Defaults to NULL.
*/
private function getActivationData(Project $project): void {
// The project's activator is the source of truth about the status of
// the project with respect to the current site.
$project->status = $this->activator->getStatus($project);
// The activator is responsible for generating the instructions.
$project->commands = $this->activator->getInstructions($project);
// Give the front-end the ID of the source plugin that exposed this project.
[$project->source] = explode('/', $project->id, 2);
public function clearStorage(?string $source_id = NULL): void {
if ($source_id) {
$this->keyValue($source_id)->deleteAll();
}
else {
foreach ($this->getCurrentSources() as $source) {
$this->clearStorage($source->getPluginId());
}
}
}
}
<?php
namespace Drupal\project_browser\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\project_browser\EnabledSourceHandler;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Clear caches for this site.
*
* @internal
* This is an internal part of Project Browser and may be changed or removed
* at any time. It should not be used by external code.
*/
final class ActionsForm extends FormBase {
public function __construct(
private readonly EnabledSourceHandler $enabledSourceHandler,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): static {
return new static(
$container->get(EnabledSourceHandler::class),
);
}
/**
* {@inheritdoc}
*/
public function getFormId(): string {
return 'project_browser_actions_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state): array {
$form['clear_storage'] = [
'#type' => 'fieldset',
'#title' => $this->t('Clear storage'),
'description' => [
'#prefix' => '<div class="form-item__description">',
'#markup' => $this->t('Project Browser stores results from sources in non-volatile storage. You can clear that storage here to force refreshing data from the source.'),
'#suffix' => '</div>',
],
'clear' => [
'#type' => 'submit',
'#value' => $this->t('Clear storage'),
],
];
return $form;
}
/**
* Clears the caches.
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {
$this->enabledSourceHandler->clearStorage();
$this->messenger()->addStatus($this->t('Storage cleared.'));
}
}
<?php
declare(strict_types=1);
namespace Drupal\project_browser\Form;
use Drupal\Core\Batch\BatchBuilder;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Recipe\Recipe;
use Drupal\Core\Recipe\RecipeConfigurator;
use Drupal\Core\Recipe\RecipeInputFormTrait;
use Drupal\Core\Recipe\RecipeRunner;
/**
* Collects input for a recipe, then applies it.
*
* @internal
* This is an internal part of Project Browser and may be changed or removed
* at any time. It should not be used by external code.
*/
final class RecipeForm extends FormBase {
use RecipeInputFormTrait;
/**
* Returns the recipe path stored in the current request.
*
* This expects that the query string will contain a `recipe` key, which has
* the path to a locally installed recipe.
*
* @return \Drupal\Core\Recipe\Recipe
* The recipe stored in the current request.
*/
private function getRecipe(): Recipe {
// Clear the static recipe cache to prevent a bug.
// @todo Remove this when https://drupal.org/i/3495305 is fixed.
$reflector = new \ReflectionProperty(RecipeConfigurator::class, 'cache');
$reflector->setValue(NULL, []);
$path = $this->getRequest()->get('recipe');
assert(is_dir($path));
return Recipe::createFromDirectory($path);
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state): array {
$recipe = $this->getRecipe();
$form += $this->buildRecipeInputForm($recipe);
$form['#title'] = $this->t('Applying %recipe', [
'%recipe' => $recipe->name,
]);
$form['apply'] = [
'#type' => 'submit',
'#value' => $this->t('Continue'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state): void {
parent::validateForm($form, $form_state);
$this->validateRecipeInput($this->getRecipe(), $form, $form_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {
$recipe = $this->getRecipe();
$this->setRecipeInput($recipe, $form_state);
$batch = (new BatchBuilder())
->setTitle(
$this->t('Applying %recipe', ['%recipe' => $recipe->name]),
);
foreach (RecipeRunner::toBatchOperations($recipe) as [$callback, $arguments]) {
$batch->addOperation($callback, $arguments);
}
// Redirect back to Project Browser when the batch job is done.
$form_state->setRedirect('project_browser.browse', [
'source' => 'recipes',
]);
batch_set($batch->toArray());
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'project_browser_apply_recipe_form';
}
}
......@@ -2,40 +2,33 @@
namespace Drupal\project_browser\Form;
use Drupal\Component\Plugin\Discovery\CachedDiscoveryInterface;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\TypedConfigManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Menu\LocalTaskManagerInterface;
use Drupal\project_browser\Plugin\ProjectBrowserSourceManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Settings form for Project Browser.
*
* @internal
* This is an internal part of Project Browser and may be changed or removed
* at any time. It should not be used by external code.
*/
class SettingsForm extends ConfigFormBase {
final class SettingsForm extends ConfigFormBase {
/**
* Constructor for settings form.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory interface.
* @param \Drupal\Core\Config\TypedConfigManagerInterface $typed_config_manager
* The typed config manager.
* @param \Drupal\project_browser\Plugin\ProjectBrowserSourceManager $manager
* The module source manger.
* @param \Drupal\Core\Cache\CacheBackendInterface $cacheBin
* The back end cache.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
* The module handler.
*/
public function __construct(
ConfigFactoryInterface $config_factory,
TypedConfigManagerInterface $typed_config_manager,
private readonly ProjectBrowserSourceManager $manager,
private readonly CacheBackendInterface $cacheBin,
private readonly ModuleHandlerInterface $moduleHandler,
private readonly LocalTaskManagerInterface&CachedDiscoveryInterface $localTaskManager,
) {
parent::__construct($config_factory, $typed_config_manager);
}
......@@ -43,13 +36,14 @@ class SettingsForm extends ConfigFormBase {
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
public static function create(ContainerInterface $container): static {
return new static(
$container->get(ConfigFactoryInterface::class),
$container->get(TypedConfigManagerInterface::class),
$container->get(ProjectBrowserSourceManager::class),
$container->get('cache.project_browser'),
$container->get(ModuleHandlerInterface::class),
$container->get(LocalTaskManagerInterface::class),
);
}
......@@ -73,7 +67,7 @@ class SettingsForm extends ConfigFormBase {
* @return array
* The table header.
*/
protected function getTableHeader() {
private function getTableHeader(): array {
return [
$this->t('Source'),
$this->t('Description'),
......@@ -119,14 +113,16 @@ class SettingsForm extends ConfigFormBase {
'#attributes' => [
'id' => 'project_browser',
],
'#tabledrag' => [],
];
$options = [
'enabled' => $this->t('Enabled'),
'disabled' => $this->t('Disabled'),
];
if (count($source_plugins) > 1) {
$form['#attached']['library'][] = 'project_browser/tabledrag';
$form['#attached']['library'][] = 'project_browser/internal.tabledrag';
foreach ($options as $status => $title) {
assert(is_array($table['#tabledrag']));
$table['#tabledrag'][] = [
'action' => 'match',
'relationship' => 'sibling',
......@@ -145,11 +141,11 @@ class SettingsForm extends ConfigFormBase {
'class' => ['status-title', 'status-title-' . $status],
'no_striping' => TRUE,
],
];
$table['status-' . $status]['title'] = [
'#plain_text' => $title,
'#wrapper_attributes' => [
'colspan' => 4,
'title' => [
'#plain_text' => $title,
'#wrapper_attributes' => [
'colspan' => 4,
],
],
];
......@@ -214,7 +210,7 @@ class SettingsForm extends ConfigFormBase {
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
public function validateForm(array &$form, FormStateInterface $form_state): void {
$all_plugins = $form_state->getValue('enabled_sources');
if (!array_key_exists('enabled', array_count_values(array_column($all_plugins, 'status')))) {
$form_state->setErrorByName('enabled_sources', $this->t('At least one source plugin must be enabled.'));
......@@ -224,15 +220,16 @@ class SettingsForm extends ConfigFormBase {
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
public function submitForm(array &$form, FormStateInterface $form_state): void {
$settings = $this->config('project_browser.admin_settings');
$all_plugins = $form_state->getValue('enabled_sources');
$enabled_plugins = array_filter($all_plugins, fn($source) => $source['status'] === 'enabled');
$enabled_plugins = array_filter($all_plugins, fn($source): bool => $source['status'] === 'enabled');
$settings
->set('enabled_sources', array_keys($enabled_plugins))
->set('allow_ui_install', $form_state->getValue('allow_ui_install'))
->save();
$this->cacheBin->deleteAll();
$this->localTaskManager->clearCachedDefinitions();
parent::submitForm($form, $form_state);
}
......
<?php
namespace Drupal\project_browser;
use Drupal\package_manager\StatusCheckTrait;
use Drupal\project_browser\ComposerInstaller\Installer;
use Drupal\system\SystemManager;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Defines Installer service.
*/
class InstallReadiness {
use StatusCheckTrait;
public function __construct(
private readonly Installer $installer,
private readonly EventDispatcherInterface $eventDispatcher,
) {}
/**
* Checks if the environment meets Package Manager install requirements.
*
* @return array[]
* errors - an array of messages with severity 2
* messages - all other messages below severity 2 (warnings)
*/
public function validatePackageManager() {
$errors = [];
$warnings = [];
foreach ($this->runStatusCheck($this->installer, $this->eventDispatcher) as $result) {
$messages = $result->messages;
$summary = $result->summary;
if ($summary) {
array_unshift($messages, $summary);
}
$text = implode("\n", $messages);
if ($result->severity === SystemManager::REQUIREMENT_ERROR) {
$errors[] = $text;
}
else {
$warnings[] = $text;
}
}
return [
'errors' => $errors,
'warnings' => $warnings,
];
}
/**
* Checks if the installer is available.
*
* @return bool
* If the installer is currently available.
*/
public function installerAvailable() {
return $this->installer->isAvailable();
}
}
......@@ -8,7 +8,11 @@ use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
use Drupal\project_browser\ProjectBrowser\Project;
/**
* Defines a service to manage the installation state of projects.
* Defines a service to track the installation state of projects.
*
* @internal
* This is an internal part of Project Browser and may be changed or removed
* at any time. It should not be used by external code.
*/
final class InstallState {
......@@ -43,11 +47,9 @@ final class InstallState {
* Example return value:
* [
* 'project_id1' => [
* 'source' => 'source_plugin_id1',
* 'status' => 'requiring',
* ],
* 'project_id2' => [
* 'source' => 'source_plugin_id2',
* 'status' => 'installing',
* ],
* '__timestamp' => 1732086755,
......@@ -64,20 +66,20 @@ final class InstallState {
/**
* Sets project state and initializes a timestamp if not set.
*
* @param \Drupal\project_browser\ProjectBrowser\Project $project
* The project object containing the ID and source of the project.
* @param string $project_id
* The fully qualified ID of the project, in the form `SOURCE_ID/LOCAL_ID`.
* @param string|null $status
* The installation status to set for the project, or NULL if no status.
* The status can be any arbitrary string, depending on the context
* or use case.
*/
public function setState(Project $project, ?string $status): void {
public function setState(string $project_id, ?string $status): void {
$this->keyValue->setIfNotExists('__timestamp', $this->time->getRequestTime());
if (is_string($status)) {
$this->keyValue->set($project->id, ['source' => $project->source, 'status' => $status]);
$this->keyValue->set($project_id, ['status' => $status]);
}
else {
$this->keyValue->delete($project->id);
$this->keyValue->delete($project_id);
}
}
......
<?php
declare(strict_types=1);
namespace Drupal\project_browser;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* The maintenance statuses available to the project browser.
*/
enum MaintenanceStatus: string {
case Maintained = '1';
case All = '0';
/**
* Represents this enum as a set of options.
*
* @return array<string, \Drupal\Core\StringTranslation\TranslatableMarkup>
* The cases in this enum. The keys are the backing values, and the values
* are the translatable labels.
*/
public static function asOptions(): array {
$options = [];
foreach (self::cases() as $case) {
$options[$case->value] = $case->label();
}
return $options;
}
/**
* Returns a translatable label for the current case.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup
* A translatable label.
*/
public function label(): TranslatableMarkup {
return match ($this) {
self::Maintained => t('Show actively maintained projects'),
self::All => t('Show all'),
};
}
}
This diff is collapsed.
<?php
declare(strict_types=1);
namespace Drupal\project_browser\Plugin\Derivative;
use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Drupal\project_browser\EnabledSourceHandler;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Exposes a block plugin for every enabled source.
*
* @internal
* This is an internal part of Project Browser and may be changed or removed
* at any time. It should not be used by external code.
*/
final class BlockDeriver extends DeriverBase implements ContainerDeriverInterface {
public function __construct(
private readonly EnabledSourceHandler $enabledSources,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, $base_plugin_id): static {
return new static(
$container->get(EnabledSourceHandler::class),
);
}
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition): array {
foreach ($this->enabledSources->getCurrentSources() as $id => $source) {
['label' => $label] = $source->getPluginDefinition();
$this->derivatives[$id] = ['admin_label' => $label] + $base_plugin_definition;
}
return parent::getDerivativeDefinitions($base_plugin_definition);
}
}
<?php
declare(strict_types=1);
namespace Drupal\project_browser\Plugin\Derivative;
use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\Component\Plugin\PluginBase;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\project_browser\EnabledSourceHandler;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Exposes local tasks for all enabled source plugins.
*
* @internal
* This is an internal part of Project Browser and may be changed or removed
* at any time. It should not be used by external code.
*/
final class LocalTaskDeriver extends DeriverBase implements ContainerDeriverInterface {
use StringTranslationTrait;
public function __construct(
private readonly EnabledSourceHandler $enabledSources,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, $base_plugin_id): static {
return new static(
$container->get(EnabledSourceHandler::class),
);
}
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition) {
$i = 5;
foreach ($this->enabledSources->getCurrentSources() as $source) {
$source_definition = $source->getPluginDefinition();
if (isset($source_definition['local_task'])) {
$local_task = $base_plugin_definition + $source_definition['local_task'];
// If no title was provided for the local task, fall back to the
// source's administrative label.
$local_task += [
'title' => $source_definition['label'],
'weight' => $i++,
];
$source_id = $source->getPluginId();
$local_task['route_parameters'] = [
'source' => $source_id,
];
$derivative_id = str_replace(PluginBase::DERIVATIVE_SEPARATOR, '__', $source_id);
$this->derivatives[$derivative_id] = $local_task;
}
}
return parent::getDerivativeDefinitions($base_plugin_definition);
}
}
This diff is collapsed.