diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-11-18 13:16:36 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-11-18 13:16:36 +0000 |
commit | 311b0269b4eb9839fa63f80c8d7a58f32b8138a0 (patch) | |
tree | 07e7870bca8aed6d61fdcc810731c50d2c40af47 /app | |
parent | 27909cef6c4170ed9205afa7426b8d3de47cbb0c (diff) | |
download | gitlab-ce-311b0269b4eb9839fa63f80c8d7a58f32b8138a0.tar.gz |
Add latest changes from gitlab-org/gitlab@14-5-stable-eev14.5.0-rc42
Diffstat (limited to 'app')
3231 files changed, 18337 insertions, 8009 deletions
diff --git a/app/assets/images/auth_buttons/dingtalk_64.png b/app/assets/images/auth_buttons/dingtalk_64.png Binary files differnew file mode 100644 index 00000000000..77b3fa752bc --- /dev/null +++ b/app/assets/images/auth_buttons/dingtalk_64.png diff --git a/app/assets/images/emoji.png b/app/assets/images/emoji.png Binary files differindex 723c2c3f4c8..bc5041a165b 100644 --- a/app/assets/images/emoji.png +++ b/app/assets/images/emoji.png diff --git a/app/assets/images/emoji/100.png b/app/assets/images/emoji/100.png Binary files differdeleted file mode 100644 index 6903ff0304a..00000000000 --- a/app/assets/images/emoji/100.png +++ /dev/null diff --git a/app/assets/images/emoji/1234.png b/app/assets/images/emoji/1234.png Binary files differdeleted file mode 100644 index 248dc7e55b6..00000000000 --- a/app/assets/images/emoji/1234.png +++ /dev/null diff --git a/app/assets/images/emoji/8ball.png b/app/assets/images/emoji/8ball.png Binary files differdeleted file mode 100644 index 38ca662eded..00000000000 --- a/app/assets/images/emoji/8ball.png +++ /dev/null diff --git a/app/assets/images/emoji/a.png b/app/assets/images/emoji/a.png Binary files differdeleted file mode 100644 index 8603ff05a17..00000000000 --- a/app/assets/images/emoji/a.png +++ /dev/null diff --git a/app/assets/images/emoji/ab.png b/app/assets/images/emoji/ab.png Binary files differdeleted file mode 100644 index d9f2d17dea0..00000000000 --- a/app/assets/images/emoji/ab.png +++ /dev/null diff --git a/app/assets/images/emoji/abc.png b/app/assets/images/emoji/abc.png Binary files differdeleted file mode 100644 index 7688de692a9..00000000000 --- a/app/assets/images/emoji/abc.png +++ /dev/null diff --git a/app/assets/images/emoji/abcd.png b/app/assets/images/emoji/abcd.png Binary files differdeleted file mode 100644 index 0996a870570..00000000000 --- a/app/assets/images/emoji/abcd.png +++ /dev/null diff --git a/app/assets/images/emoji/accept.png b/app/assets/images/emoji/accept.png Binary files differdeleted file mode 100644 index 8afd7ce99cf..00000000000 --- a/app/assets/images/emoji/accept.png +++ /dev/null diff --git a/app/assets/images/emoji/aerial_tramway.png b/app/assets/images/emoji/aerial_tramway.png Binary files differdeleted file mode 100644 index 3eb4b61bf1d..00000000000 --- a/app/assets/images/emoji/aerial_tramway.png +++ /dev/null diff --git a/app/assets/images/emoji/airplane.png b/app/assets/images/emoji/airplane.png Binary files differdeleted file mode 100644 index 268d2ac3c8e..00000000000 --- a/app/assets/images/emoji/airplane.png +++ /dev/null diff --git a/app/assets/images/emoji/airplane_arriving.png b/app/assets/images/emoji/airplane_arriving.png Binary files differdeleted file mode 100644 index d66841962f2..00000000000 --- a/app/assets/images/emoji/airplane_arriving.png +++ /dev/null diff --git a/app/assets/images/emoji/airplane_departure.png b/app/assets/images/emoji/airplane_departure.png Binary files differdeleted file mode 100644 index a5766f9f4ae..00000000000 --- a/app/assets/images/emoji/airplane_departure.png +++ /dev/null diff --git a/app/assets/images/emoji/airplane_small.png b/app/assets/images/emoji/airplane_small.png Binary files differdeleted file mode 100644 index b731b15e3a8..00000000000 --- a/app/assets/images/emoji/airplane_small.png +++ /dev/null diff --git a/app/assets/images/emoji/alarm_clock.png b/app/assets/images/emoji/alarm_clock.png Binary files differdeleted file mode 100644 index cdbc2fbb950..00000000000 --- a/app/assets/images/emoji/alarm_clock.png +++ /dev/null diff --git a/app/assets/images/emoji/alembic.png b/app/assets/images/emoji/alembic.png Binary files differdeleted file mode 100644 index 307a7324249..00000000000 --- a/app/assets/images/emoji/alembic.png +++ /dev/null diff --git a/app/assets/images/emoji/alien.png b/app/assets/images/emoji/alien.png Binary files differdeleted file mode 100644 index 3b90e97433b..00000000000 --- a/app/assets/images/emoji/alien.png +++ /dev/null diff --git a/app/assets/images/emoji/ambulance.png b/app/assets/images/emoji/ambulance.png Binary files differdeleted file mode 100644 index 6fb8076d766..00000000000 --- a/app/assets/images/emoji/ambulance.png +++ /dev/null diff --git a/app/assets/images/emoji/amphora.png b/app/assets/images/emoji/amphora.png Binary files differdeleted file mode 100644 index 96de5056059..00000000000 --- a/app/assets/images/emoji/amphora.png +++ /dev/null diff --git a/app/assets/images/emoji/anchor.png b/app/assets/images/emoji/anchor.png Binary files differdeleted file mode 100644 index b036f70a00b..00000000000 --- a/app/assets/images/emoji/anchor.png +++ /dev/null diff --git a/app/assets/images/emoji/angel.png b/app/assets/images/emoji/angel.png Binary files differdeleted file mode 100644 index 66ea97a3b99..00000000000 --- a/app/assets/images/emoji/angel.png +++ /dev/null diff --git a/app/assets/images/emoji/angel_tone1.png b/app/assets/images/emoji/angel_tone1.png Binary files differdeleted file mode 100644 index 391694dc07e..00000000000 --- a/app/assets/images/emoji/angel_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/angel_tone2.png b/app/assets/images/emoji/angel_tone2.png Binary files differdeleted file mode 100644 index 700cbe6ed2c..00000000000 --- a/app/assets/images/emoji/angel_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/angel_tone3.png b/app/assets/images/emoji/angel_tone3.png Binary files differdeleted file mode 100644 index be597437d25..00000000000 --- a/app/assets/images/emoji/angel_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/angel_tone4.png b/app/assets/images/emoji/angel_tone4.png Binary files differdeleted file mode 100644 index b06d3c853ef..00000000000 --- a/app/assets/images/emoji/angel_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/angel_tone5.png b/app/assets/images/emoji/angel_tone5.png Binary files differdeleted file mode 100644 index 17bd677e334..00000000000 --- a/app/assets/images/emoji/angel_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/anger.png b/app/assets/images/emoji/anger.png Binary files differdeleted file mode 100644 index d63c2e000e4..00000000000 --- a/app/assets/images/emoji/anger.png +++ /dev/null diff --git a/app/assets/images/emoji/anger_right.png b/app/assets/images/emoji/anger_right.png Binary files differdeleted file mode 100644 index f5c97c4d297..00000000000 --- a/app/assets/images/emoji/anger_right.png +++ /dev/null diff --git a/app/assets/images/emoji/angry.png b/app/assets/images/emoji/angry.png Binary files differdeleted file mode 100644 index cfc4a6ecde5..00000000000 --- a/app/assets/images/emoji/angry.png +++ /dev/null diff --git a/app/assets/images/emoji/ant.png b/app/assets/images/emoji/ant.png Binary files differdeleted file mode 100644 index 994127ed6b3..00000000000 --- a/app/assets/images/emoji/ant.png +++ /dev/null diff --git a/app/assets/images/emoji/apple.png b/app/assets/images/emoji/apple.png Binary files differdeleted file mode 100644 index da650c60f62..00000000000 --- a/app/assets/images/emoji/apple.png +++ /dev/null diff --git a/app/assets/images/emoji/aquarius.png b/app/assets/images/emoji/aquarius.png Binary files differdeleted file mode 100644 index 641a4f68889..00000000000 --- a/app/assets/images/emoji/aquarius.png +++ /dev/null diff --git a/app/assets/images/emoji/aries.png b/app/assets/images/emoji/aries.png Binary files differdeleted file mode 100644 index 21a189d0ede..00000000000 --- a/app/assets/images/emoji/aries.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_backward.png b/app/assets/images/emoji/arrow_backward.png Binary files differdeleted file mode 100644 index ee38e3b038e..00000000000 --- a/app/assets/images/emoji/arrow_backward.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_double_down.png b/app/assets/images/emoji/arrow_double_down.png Binary files differdeleted file mode 100644 index 90193bfcb40..00000000000 --- a/app/assets/images/emoji/arrow_double_down.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_double_up.png b/app/assets/images/emoji/arrow_double_up.png Binary files differdeleted file mode 100644 index 13543d5eef2..00000000000 --- a/app/assets/images/emoji/arrow_double_up.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_down.png b/app/assets/images/emoji/arrow_down.png Binary files differdeleted file mode 100644 index b8eefd0b19f..00000000000 --- a/app/assets/images/emoji/arrow_down.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_down_small.png b/app/assets/images/emoji/arrow_down_small.png Binary files differdeleted file mode 100644 index 5870b9a2241..00000000000 --- a/app/assets/images/emoji/arrow_down_small.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_forward.png b/app/assets/images/emoji/arrow_forward.png Binary files differdeleted file mode 100644 index 4e2b682857c..00000000000 --- a/app/assets/images/emoji/arrow_forward.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_heading_down.png b/app/assets/images/emoji/arrow_heading_down.png Binary files differdeleted file mode 100644 index 2d9d24bca80..00000000000 --- a/app/assets/images/emoji/arrow_heading_down.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_heading_up.png b/app/assets/images/emoji/arrow_heading_up.png Binary files differdeleted file mode 100644 index f29bfcfc0de..00000000000 --- a/app/assets/images/emoji/arrow_heading_up.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_left.png b/app/assets/images/emoji/arrow_left.png Binary files differdeleted file mode 100644 index 8c685e0a81b..00000000000 --- a/app/assets/images/emoji/arrow_left.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_lower_left.png b/app/assets/images/emoji/arrow_lower_left.png Binary files differdeleted file mode 100644 index 88b37716078..00000000000 --- a/app/assets/images/emoji/arrow_lower_left.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_lower_right.png b/app/assets/images/emoji/arrow_lower_right.png Binary files differdeleted file mode 100644 index 7e807da7392..00000000000 --- a/app/assets/images/emoji/arrow_lower_right.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_right.png b/app/assets/images/emoji/arrow_right.png Binary files differdeleted file mode 100644 index 4755670b5cc..00000000000 --- a/app/assets/images/emoji/arrow_right.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_right_hook.png b/app/assets/images/emoji/arrow_right_hook.png Binary files differdeleted file mode 100644 index e7258ad3268..00000000000 --- a/app/assets/images/emoji/arrow_right_hook.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_up.png b/app/assets/images/emoji/arrow_up.png Binary files differdeleted file mode 100644 index af8218a87f7..00000000000 --- a/app/assets/images/emoji/arrow_up.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_up_down.png b/app/assets/images/emoji/arrow_up_down.png Binary files differdeleted file mode 100644 index dfa32b97186..00000000000 --- a/app/assets/images/emoji/arrow_up_down.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_up_small.png b/app/assets/images/emoji/arrow_up_small.png Binary files differdeleted file mode 100644 index 20a13dcd5cd..00000000000 --- a/app/assets/images/emoji/arrow_up_small.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_upper_left.png b/app/assets/images/emoji/arrow_upper_left.png Binary files differdeleted file mode 100644 index f38718fbe34..00000000000 --- a/app/assets/images/emoji/arrow_upper_left.png +++ /dev/null diff --git a/app/assets/images/emoji/arrow_upper_right.png b/app/assets/images/emoji/arrow_upper_right.png Binary files differdeleted file mode 100644 index c43e12d0f64..00000000000 --- a/app/assets/images/emoji/arrow_upper_right.png +++ /dev/null diff --git a/app/assets/images/emoji/arrows_clockwise.png b/app/assets/images/emoji/arrows_clockwise.png Binary files differdeleted file mode 100644 index 26e49c38388..00000000000 --- a/app/assets/images/emoji/arrows_clockwise.png +++ /dev/null diff --git a/app/assets/images/emoji/arrows_counterclockwise.png b/app/assets/images/emoji/arrows_counterclockwise.png Binary files differdeleted file mode 100644 index 8d06d8e0912..00000000000 --- a/app/assets/images/emoji/arrows_counterclockwise.png +++ /dev/null diff --git a/app/assets/images/emoji/art.png b/app/assets/images/emoji/art.png Binary files differdeleted file mode 100644 index bd6afe9ff06..00000000000 --- a/app/assets/images/emoji/art.png +++ /dev/null diff --git a/app/assets/images/emoji/articulated_lorry.png b/app/assets/images/emoji/articulated_lorry.png Binary files differdeleted file mode 100644 index c8217317132..00000000000 --- a/app/assets/images/emoji/articulated_lorry.png +++ /dev/null diff --git a/app/assets/images/emoji/asterisk.png b/app/assets/images/emoji/asterisk.png Binary files differdeleted file mode 100644 index 2f8e5113803..00000000000 --- a/app/assets/images/emoji/asterisk.png +++ /dev/null diff --git a/app/assets/images/emoji/astonished.png b/app/assets/images/emoji/astonished.png Binary files differdeleted file mode 100644 index bd0ac55ec8e..00000000000 --- a/app/assets/images/emoji/astonished.png +++ /dev/null diff --git a/app/assets/images/emoji/athletic_shoe.png b/app/assets/images/emoji/athletic_shoe.png Binary files differdeleted file mode 100644 index 423fa07dd5d..00000000000 --- a/app/assets/images/emoji/athletic_shoe.png +++ /dev/null diff --git a/app/assets/images/emoji/atm.png b/app/assets/images/emoji/atm.png Binary files differdeleted file mode 100644 index 4d935307b94..00000000000 --- a/app/assets/images/emoji/atm.png +++ /dev/null diff --git a/app/assets/images/emoji/atom.png b/app/assets/images/emoji/atom.png Binary files differdeleted file mode 100644 index 5f4567aa093..00000000000 --- a/app/assets/images/emoji/atom.png +++ /dev/null diff --git a/app/assets/images/emoji/avocado.png b/app/assets/images/emoji/avocado.png Binary files differdeleted file mode 100644 index 06f0d124aed..00000000000 --- a/app/assets/images/emoji/avocado.png +++ /dev/null diff --git a/app/assets/images/emoji/b.png b/app/assets/images/emoji/b.png Binary files differdeleted file mode 100644 index 25875bc6a14..00000000000 --- a/app/assets/images/emoji/b.png +++ /dev/null diff --git a/app/assets/images/emoji/baby.png b/app/assets/images/emoji/baby.png Binary files differdeleted file mode 100644 index a4af92c63c7..00000000000 --- a/app/assets/images/emoji/baby.png +++ /dev/null diff --git a/app/assets/images/emoji/baby_bottle.png b/app/assets/images/emoji/baby_bottle.png Binary files differdeleted file mode 100644 index 2bd10524180..00000000000 --- a/app/assets/images/emoji/baby_bottle.png +++ /dev/null diff --git a/app/assets/images/emoji/baby_chick.png b/app/assets/images/emoji/baby_chick.png Binary files differdeleted file mode 100644 index dccd96576ea..00000000000 --- a/app/assets/images/emoji/baby_chick.png +++ /dev/null diff --git a/app/assets/images/emoji/baby_symbol.png b/app/assets/images/emoji/baby_symbol.png Binary files differdeleted file mode 100644 index 64a10b71710..00000000000 --- a/app/assets/images/emoji/baby_symbol.png +++ /dev/null diff --git a/app/assets/images/emoji/baby_tone1.png b/app/assets/images/emoji/baby_tone1.png Binary files differdeleted file mode 100644 index d20911d40db..00000000000 --- a/app/assets/images/emoji/baby_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/baby_tone2.png b/app/assets/images/emoji/baby_tone2.png Binary files differdeleted file mode 100644 index b0a9b30ed17..00000000000 --- a/app/assets/images/emoji/baby_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/baby_tone3.png b/app/assets/images/emoji/baby_tone3.png Binary files differdeleted file mode 100644 index 7de5286fac1..00000000000 --- a/app/assets/images/emoji/baby_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/baby_tone4.png b/app/assets/images/emoji/baby_tone4.png Binary files differdeleted file mode 100644 index 9b7a86ac615..00000000000 --- a/app/assets/images/emoji/baby_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/baby_tone5.png b/app/assets/images/emoji/baby_tone5.png Binary files differdeleted file mode 100644 index fe1be34cb88..00000000000 --- a/app/assets/images/emoji/baby_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/back.png b/app/assets/images/emoji/back.png Binary files differdeleted file mode 100644 index d32c5d4f17f..00000000000 --- a/app/assets/images/emoji/back.png +++ /dev/null diff --git a/app/assets/images/emoji/bacon.png b/app/assets/images/emoji/bacon.png Binary files differdeleted file mode 100644 index f38a485fbe4..00000000000 --- a/app/assets/images/emoji/bacon.png +++ /dev/null diff --git a/app/assets/images/emoji/badminton.png b/app/assets/images/emoji/badminton.png Binary files differdeleted file mode 100644 index 7ba15708990..00000000000 --- a/app/assets/images/emoji/badminton.png +++ /dev/null diff --git a/app/assets/images/emoji/baggage_claim.png b/app/assets/images/emoji/baggage_claim.png Binary files differdeleted file mode 100644 index 409b593e78a..00000000000 --- a/app/assets/images/emoji/baggage_claim.png +++ /dev/null diff --git a/app/assets/images/emoji/balloon.png b/app/assets/images/emoji/balloon.png Binary files differdeleted file mode 100644 index 07916fe6df1..00000000000 --- a/app/assets/images/emoji/balloon.png +++ /dev/null diff --git a/app/assets/images/emoji/ballot_box.png b/app/assets/images/emoji/ballot_box.png Binary files differdeleted file mode 100644 index 9b6767aea9e..00000000000 --- a/app/assets/images/emoji/ballot_box.png +++ /dev/null diff --git a/app/assets/images/emoji/ballot_box_with_check.png b/app/assets/images/emoji/ballot_box_with_check.png Binary files differdeleted file mode 100644 index 284d9573847..00000000000 --- a/app/assets/images/emoji/ballot_box_with_check.png +++ /dev/null diff --git a/app/assets/images/emoji/bamboo.png b/app/assets/images/emoji/bamboo.png Binary files differdeleted file mode 100644 index 5d5e0e728a0..00000000000 --- a/app/assets/images/emoji/bamboo.png +++ /dev/null diff --git a/app/assets/images/emoji/banana.png b/app/assets/images/emoji/banana.png Binary files differdeleted file mode 100644 index f4987279580..00000000000 --- a/app/assets/images/emoji/banana.png +++ /dev/null diff --git a/app/assets/images/emoji/bangbang.png b/app/assets/images/emoji/bangbang.png Binary files differdeleted file mode 100644 index 58a9c528fca..00000000000 --- a/app/assets/images/emoji/bangbang.png +++ /dev/null diff --git a/app/assets/images/emoji/bank.png b/app/assets/images/emoji/bank.png Binary files differdeleted file mode 100644 index dffdcef36a1..00000000000 --- a/app/assets/images/emoji/bank.png +++ /dev/null diff --git a/app/assets/images/emoji/bar_chart.png b/app/assets/images/emoji/bar_chart.png Binary files differdeleted file mode 100644 index 53c89455008..00000000000 --- a/app/assets/images/emoji/bar_chart.png +++ /dev/null diff --git a/app/assets/images/emoji/barber.png b/app/assets/images/emoji/barber.png Binary files differdeleted file mode 100644 index 896f4d716cf..00000000000 --- a/app/assets/images/emoji/barber.png +++ /dev/null diff --git a/app/assets/images/emoji/baseball.png b/app/assets/images/emoji/baseball.png Binary files differdeleted file mode 100644 index f8463f1538b..00000000000 --- a/app/assets/images/emoji/baseball.png +++ /dev/null diff --git a/app/assets/images/emoji/basketball.png b/app/assets/images/emoji/basketball.png Binary files differdeleted file mode 100644 index 64c76b79c6d..00000000000 --- a/app/assets/images/emoji/basketball.png +++ /dev/null diff --git a/app/assets/images/emoji/basketball_player.png b/app/assets/images/emoji/basketball_player.png Binary files differdeleted file mode 100644 index 8ce90c5cad6..00000000000 --- a/app/assets/images/emoji/basketball_player.png +++ /dev/null diff --git a/app/assets/images/emoji/basketball_player_tone1.png b/app/assets/images/emoji/basketball_player_tone1.png Binary files differdeleted file mode 100644 index cd12c7ab9bf..00000000000 --- a/app/assets/images/emoji/basketball_player_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/basketball_player_tone2.png b/app/assets/images/emoji/basketball_player_tone2.png Binary files differdeleted file mode 100644 index f892fd596da..00000000000 --- a/app/assets/images/emoji/basketball_player_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/basketball_player_tone3.png b/app/assets/images/emoji/basketball_player_tone3.png Binary files differdeleted file mode 100644 index e109997a91a..00000000000 --- a/app/assets/images/emoji/basketball_player_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/basketball_player_tone4.png b/app/assets/images/emoji/basketball_player_tone4.png Binary files differdeleted file mode 100644 index 3b90b946af4..00000000000 --- a/app/assets/images/emoji/basketball_player_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/basketball_player_tone5.png b/app/assets/images/emoji/basketball_player_tone5.png Binary files differdeleted file mode 100644 index bafed7828a7..00000000000 --- a/app/assets/images/emoji/basketball_player_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/bat.png b/app/assets/images/emoji/bat.png Binary files differdeleted file mode 100644 index 3152c047e00..00000000000 --- a/app/assets/images/emoji/bat.png +++ /dev/null diff --git a/app/assets/images/emoji/bath.png b/app/assets/images/emoji/bath.png Binary files differdeleted file mode 100644 index 43fba5c8a28..00000000000 --- a/app/assets/images/emoji/bath.png +++ /dev/null diff --git a/app/assets/images/emoji/bath_tone1.png b/app/assets/images/emoji/bath_tone1.png Binary files differdeleted file mode 100644 index 2152eabf2f5..00000000000 --- a/app/assets/images/emoji/bath_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/bath_tone2.png b/app/assets/images/emoji/bath_tone2.png Binary files differdeleted file mode 100644 index 2102e6133e3..00000000000 --- a/app/assets/images/emoji/bath_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/bath_tone3.png b/app/assets/images/emoji/bath_tone3.png Binary files differdeleted file mode 100644 index fae66181e9f..00000000000 --- a/app/assets/images/emoji/bath_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/bath_tone4.png b/app/assets/images/emoji/bath_tone4.png Binary files differdeleted file mode 100644 index 1f8959d0d99..00000000000 --- a/app/assets/images/emoji/bath_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/bath_tone5.png b/app/assets/images/emoji/bath_tone5.png Binary files differdeleted file mode 100644 index c8a08e84f25..00000000000 --- a/app/assets/images/emoji/bath_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/bathtub.png b/app/assets/images/emoji/bathtub.png Binary files differdeleted file mode 100644 index 9a5f09361eb..00000000000 --- a/app/assets/images/emoji/bathtub.png +++ /dev/null diff --git a/app/assets/images/emoji/battery.png b/app/assets/images/emoji/battery.png Binary files differdeleted file mode 100644 index f593e2bdb65..00000000000 --- a/app/assets/images/emoji/battery.png +++ /dev/null diff --git a/app/assets/images/emoji/beach.png b/app/assets/images/emoji/beach.png Binary files differdeleted file mode 100644 index 69108c8ea10..00000000000 --- a/app/assets/images/emoji/beach.png +++ /dev/null diff --git a/app/assets/images/emoji/beach_umbrella.png b/app/assets/images/emoji/beach_umbrella.png Binary files differdeleted file mode 100644 index 220a74f8132..00000000000 --- a/app/assets/images/emoji/beach_umbrella.png +++ /dev/null diff --git a/app/assets/images/emoji/bear.png b/app/assets/images/emoji/bear.png Binary files differdeleted file mode 100644 index 272d56bbbcc..00000000000 --- a/app/assets/images/emoji/bear.png +++ /dev/null diff --git a/app/assets/images/emoji/bed.png b/app/assets/images/emoji/bed.png Binary files differdeleted file mode 100644 index 86f964e245d..00000000000 --- a/app/assets/images/emoji/bed.png +++ /dev/null diff --git a/app/assets/images/emoji/bee.png b/app/assets/images/emoji/bee.png Binary files differdeleted file mode 100644 index 46156060096..00000000000 --- a/app/assets/images/emoji/bee.png +++ /dev/null diff --git a/app/assets/images/emoji/beer.png b/app/assets/images/emoji/beer.png Binary files differdeleted file mode 100644 index b6d73dc0b7a..00000000000 --- a/app/assets/images/emoji/beer.png +++ /dev/null diff --git a/app/assets/images/emoji/beers.png b/app/assets/images/emoji/beers.png Binary files differdeleted file mode 100644 index b55deb66b41..00000000000 --- a/app/assets/images/emoji/beers.png +++ /dev/null diff --git a/app/assets/images/emoji/beetle.png b/app/assets/images/emoji/beetle.png Binary files differdeleted file mode 100644 index 3d93174d7fc..00000000000 --- a/app/assets/images/emoji/beetle.png +++ /dev/null diff --git a/app/assets/images/emoji/beginner.png b/app/assets/images/emoji/beginner.png Binary files differdeleted file mode 100644 index bc434fb7cb5..00000000000 --- a/app/assets/images/emoji/beginner.png +++ /dev/null diff --git a/app/assets/images/emoji/bell.png b/app/assets/images/emoji/bell.png Binary files differdeleted file mode 100644 index 5b3b0461999..00000000000 --- a/app/assets/images/emoji/bell.png +++ /dev/null diff --git a/app/assets/images/emoji/bellhop.png b/app/assets/images/emoji/bellhop.png Binary files differdeleted file mode 100644 index 6b3297ceaf7..00000000000 --- a/app/assets/images/emoji/bellhop.png +++ /dev/null diff --git a/app/assets/images/emoji/bento.png b/app/assets/images/emoji/bento.png Binary files differdeleted file mode 100644 index 83d41ca7eb9..00000000000 --- a/app/assets/images/emoji/bento.png +++ /dev/null diff --git a/app/assets/images/emoji/bicyclist.png b/app/assets/images/emoji/bicyclist.png Binary files differdeleted file mode 100644 index 9274da11048..00000000000 --- a/app/assets/images/emoji/bicyclist.png +++ /dev/null diff --git a/app/assets/images/emoji/bicyclist_tone1.png b/app/assets/images/emoji/bicyclist_tone1.png Binary files differdeleted file mode 100644 index decc2f728fe..00000000000 --- a/app/assets/images/emoji/bicyclist_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/bicyclist_tone2.png b/app/assets/images/emoji/bicyclist_tone2.png Binary files differdeleted file mode 100644 index 0067717b80a..00000000000 --- a/app/assets/images/emoji/bicyclist_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/bicyclist_tone3.png b/app/assets/images/emoji/bicyclist_tone3.png Binary files differdeleted file mode 100644 index a4f7b5e2776..00000000000 --- a/app/assets/images/emoji/bicyclist_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/bicyclist_tone4.png b/app/assets/images/emoji/bicyclist_tone4.png Binary files differdeleted file mode 100644 index a3c8a797db4..00000000000 --- a/app/assets/images/emoji/bicyclist_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/bicyclist_tone5.png b/app/assets/images/emoji/bicyclist_tone5.png Binary files differdeleted file mode 100644 index 1606a874051..00000000000 --- a/app/assets/images/emoji/bicyclist_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/bike.png b/app/assets/images/emoji/bike.png Binary files differdeleted file mode 100644 index 556ed70f1a7..00000000000 --- a/app/assets/images/emoji/bike.png +++ /dev/null diff --git a/app/assets/images/emoji/bikini.png b/app/assets/images/emoji/bikini.png Binary files differdeleted file mode 100644 index 77a8a0aae5b..00000000000 --- a/app/assets/images/emoji/bikini.png +++ /dev/null diff --git a/app/assets/images/emoji/biohazard.png b/app/assets/images/emoji/biohazard.png Binary files differdeleted file mode 100644 index 007b4fc2d85..00000000000 --- a/app/assets/images/emoji/biohazard.png +++ /dev/null diff --git a/app/assets/images/emoji/bird.png b/app/assets/images/emoji/bird.png Binary files differdeleted file mode 100644 index e201c22be33..00000000000 --- a/app/assets/images/emoji/bird.png +++ /dev/null diff --git a/app/assets/images/emoji/birthday.png b/app/assets/images/emoji/birthday.png Binary files differdeleted file mode 100644 index 317e9a41949..00000000000 --- a/app/assets/images/emoji/birthday.png +++ /dev/null diff --git a/app/assets/images/emoji/black_circle.png b/app/assets/images/emoji/black_circle.png Binary files differdeleted file mode 100644 index b62b87170e8..00000000000 --- a/app/assets/images/emoji/black_circle.png +++ /dev/null diff --git a/app/assets/images/emoji/black_heart.png b/app/assets/images/emoji/black_heart.png Binary files differdeleted file mode 100644 index b4068c3e6e8..00000000000 --- a/app/assets/images/emoji/black_heart.png +++ /dev/null diff --git a/app/assets/images/emoji/black_joker.png b/app/assets/images/emoji/black_joker.png Binary files differdeleted file mode 100644 index 3d0924b68aa..00000000000 --- a/app/assets/images/emoji/black_joker.png +++ /dev/null diff --git a/app/assets/images/emoji/black_large_square.png b/app/assets/images/emoji/black_large_square.png Binary files differdeleted file mode 100644 index 162f2bb4290..00000000000 --- a/app/assets/images/emoji/black_large_square.png +++ /dev/null diff --git a/app/assets/images/emoji/black_medium_small_square.png b/app/assets/images/emoji/black_medium_small_square.png Binary files differdeleted file mode 100644 index 39765bba610..00000000000 --- a/app/assets/images/emoji/black_medium_small_square.png +++ /dev/null diff --git a/app/assets/images/emoji/black_medium_square.png b/app/assets/images/emoji/black_medium_square.png Binary files differdeleted file mode 100644 index 05a30a6aa2d..00000000000 --- a/app/assets/images/emoji/black_medium_square.png +++ /dev/null diff --git a/app/assets/images/emoji/black_nib.png b/app/assets/images/emoji/black_nib.png Binary files differdeleted file mode 100644 index 872d0ae1598..00000000000 --- a/app/assets/images/emoji/black_nib.png +++ /dev/null diff --git a/app/assets/images/emoji/black_small_square.png b/app/assets/images/emoji/black_small_square.png Binary files differdeleted file mode 100644 index 48595d3e1a9..00000000000 --- a/app/assets/images/emoji/black_small_square.png +++ /dev/null diff --git a/app/assets/images/emoji/black_square_button.png b/app/assets/images/emoji/black_square_button.png Binary files differdeleted file mode 100644 index a78fc2f6b63..00000000000 --- a/app/assets/images/emoji/black_square_button.png +++ /dev/null diff --git a/app/assets/images/emoji/blossom.png b/app/assets/images/emoji/blossom.png Binary files differdeleted file mode 100644 index 4083026c157..00000000000 --- a/app/assets/images/emoji/blossom.png +++ /dev/null diff --git a/app/assets/images/emoji/blowfish.png b/app/assets/images/emoji/blowfish.png Binary files differdeleted file mode 100644 index a10f4f84e35..00000000000 --- a/app/assets/images/emoji/blowfish.png +++ /dev/null diff --git a/app/assets/images/emoji/blue_book.png b/app/assets/images/emoji/blue_book.png Binary files differdeleted file mode 100644 index e1e455401cc..00000000000 --- a/app/assets/images/emoji/blue_book.png +++ /dev/null diff --git a/app/assets/images/emoji/blue_car.png b/app/assets/images/emoji/blue_car.png Binary files differdeleted file mode 100644 index e8ba817d393..00000000000 --- a/app/assets/images/emoji/blue_car.png +++ /dev/null diff --git a/app/assets/images/emoji/blue_heart.png b/app/assets/images/emoji/blue_heart.png Binary files differdeleted file mode 100644 index bdf1287e55e..00000000000 --- a/app/assets/images/emoji/blue_heart.png +++ /dev/null diff --git a/app/assets/images/emoji/blush.png b/app/assets/images/emoji/blush.png Binary files differdeleted file mode 100644 index aac1a424ad4..00000000000 --- a/app/assets/images/emoji/blush.png +++ /dev/null diff --git a/app/assets/images/emoji/boar.png b/app/assets/images/emoji/boar.png Binary files differdeleted file mode 100644 index fead972633c..00000000000 --- a/app/assets/images/emoji/boar.png +++ /dev/null diff --git a/app/assets/images/emoji/bomb.png b/app/assets/images/emoji/bomb.png Binary files differdeleted file mode 100644 index c7f8f81c939..00000000000 --- a/app/assets/images/emoji/bomb.png +++ /dev/null diff --git a/app/assets/images/emoji/book.png b/app/assets/images/emoji/book.png Binary files differdeleted file mode 100644 index 0f4447ed396..00000000000 --- a/app/assets/images/emoji/book.png +++ /dev/null diff --git a/app/assets/images/emoji/bookmark.png b/app/assets/images/emoji/bookmark.png Binary files differdeleted file mode 100644 index bbb444611f0..00000000000 --- a/app/assets/images/emoji/bookmark.png +++ /dev/null diff --git a/app/assets/images/emoji/bookmark_tabs.png b/app/assets/images/emoji/bookmark_tabs.png Binary files differdeleted file mode 100644 index f8d9e01b428..00000000000 --- a/app/assets/images/emoji/bookmark_tabs.png +++ /dev/null diff --git a/app/assets/images/emoji/books.png b/app/assets/images/emoji/books.png Binary files differdeleted file mode 100644 index 59a8bafeb0d..00000000000 --- a/app/assets/images/emoji/books.png +++ /dev/null diff --git a/app/assets/images/emoji/boom.png b/app/assets/images/emoji/boom.png Binary files differdeleted file mode 100644 index 9b0f027b1a8..00000000000 --- a/app/assets/images/emoji/boom.png +++ /dev/null diff --git a/app/assets/images/emoji/boot.png b/app/assets/images/emoji/boot.png Binary files differdeleted file mode 100644 index 11f1065ed07..00000000000 --- a/app/assets/images/emoji/boot.png +++ /dev/null diff --git a/app/assets/images/emoji/bouquet.png b/app/assets/images/emoji/bouquet.png Binary files differdeleted file mode 100644 index 11455af6df4..00000000000 --- a/app/assets/images/emoji/bouquet.png +++ /dev/null diff --git a/app/assets/images/emoji/bow.png b/app/assets/images/emoji/bow.png Binary files differdeleted file mode 100644 index d8f793088dc..00000000000 --- a/app/assets/images/emoji/bow.png +++ /dev/null diff --git a/app/assets/images/emoji/bow_and_arrow.png b/app/assets/images/emoji/bow_and_arrow.png Binary files differdeleted file mode 100644 index 6a538bf475f..00000000000 --- a/app/assets/images/emoji/bow_and_arrow.png +++ /dev/null diff --git a/app/assets/images/emoji/bow_tone1.png b/app/assets/images/emoji/bow_tone1.png Binary files differdeleted file mode 100644 index 87afb7b54cf..00000000000 --- a/app/assets/images/emoji/bow_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/bow_tone2.png b/app/assets/images/emoji/bow_tone2.png Binary files differdeleted file mode 100644 index 3ccf7dc0850..00000000000 --- a/app/assets/images/emoji/bow_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/bow_tone3.png b/app/assets/images/emoji/bow_tone3.png Binary files differdeleted file mode 100644 index 8b9eb64f926..00000000000 --- a/app/assets/images/emoji/bow_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/bow_tone4.png b/app/assets/images/emoji/bow_tone4.png Binary files differdeleted file mode 100644 index 683795ff40d..00000000000 --- a/app/assets/images/emoji/bow_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/bow_tone5.png b/app/assets/images/emoji/bow_tone5.png Binary files differdeleted file mode 100644 index 7969d971752..00000000000 --- a/app/assets/images/emoji/bow_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/bowling.png b/app/assets/images/emoji/bowling.png Binary files differdeleted file mode 100644 index 63add89e53b..00000000000 --- a/app/assets/images/emoji/bowling.png +++ /dev/null diff --git a/app/assets/images/emoji/boxing_glove.png b/app/assets/images/emoji/boxing_glove.png Binary files differdeleted file mode 100644 index 9838f24e51a..00000000000 --- a/app/assets/images/emoji/boxing_glove.png +++ /dev/null diff --git a/app/assets/images/emoji/boy.png b/app/assets/images/emoji/boy.png Binary files differdeleted file mode 100644 index 8ecfb0a4e92..00000000000 --- a/app/assets/images/emoji/boy.png +++ /dev/null diff --git a/app/assets/images/emoji/boy_tone1.png b/app/assets/images/emoji/boy_tone1.png Binary files differdeleted file mode 100644 index 2fc436ea512..00000000000 --- a/app/assets/images/emoji/boy_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/boy_tone2.png b/app/assets/images/emoji/boy_tone2.png Binary files differdeleted file mode 100644 index 09a5f18d360..00000000000 --- a/app/assets/images/emoji/boy_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/boy_tone3.png b/app/assets/images/emoji/boy_tone3.png Binary files differdeleted file mode 100644 index 3cfe675dd3a..00000000000 --- a/app/assets/images/emoji/boy_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/boy_tone4.png b/app/assets/images/emoji/boy_tone4.png Binary files differdeleted file mode 100644 index 780be0ace36..00000000000 --- a/app/assets/images/emoji/boy_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/boy_tone5.png b/app/assets/images/emoji/boy_tone5.png Binary files differdeleted file mode 100644 index f32fe22e35c..00000000000 --- a/app/assets/images/emoji/boy_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/bread.png b/app/assets/images/emoji/bread.png Binary files differdeleted file mode 100644 index 6676510aaa5..00000000000 --- a/app/assets/images/emoji/bread.png +++ /dev/null diff --git a/app/assets/images/emoji/bride_with_veil.png b/app/assets/images/emoji/bride_with_veil.png Binary files differdeleted file mode 100644 index eaf4bd97890..00000000000 --- a/app/assets/images/emoji/bride_with_veil.png +++ /dev/null diff --git a/app/assets/images/emoji/bride_with_veil_tone1.png b/app/assets/images/emoji/bride_with_veil_tone1.png Binary files differdeleted file mode 100644 index c4fb141ae8f..00000000000 --- a/app/assets/images/emoji/bride_with_veil_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/bride_with_veil_tone2.png b/app/assets/images/emoji/bride_with_veil_tone2.png Binary files differdeleted file mode 100644 index c248769fc06..00000000000 --- a/app/assets/images/emoji/bride_with_veil_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/bride_with_veil_tone3.png b/app/assets/images/emoji/bride_with_veil_tone3.png Binary files differdeleted file mode 100644 index 962c0a6eedb..00000000000 --- a/app/assets/images/emoji/bride_with_veil_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/bride_with_veil_tone4.png b/app/assets/images/emoji/bride_with_veil_tone4.png Binary files differdeleted file mode 100644 index 740ca208cd4..00000000000 --- a/app/assets/images/emoji/bride_with_veil_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/bride_with_veil_tone5.png b/app/assets/images/emoji/bride_with_veil_tone5.png Binary files differdeleted file mode 100644 index 5cc5598587d..00000000000 --- a/app/assets/images/emoji/bride_with_veil_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/bridge_at_night.png b/app/assets/images/emoji/bridge_at_night.png Binary files differdeleted file mode 100644 index 1d444e0be65..00000000000 --- a/app/assets/images/emoji/bridge_at_night.png +++ /dev/null diff --git a/app/assets/images/emoji/briefcase.png b/app/assets/images/emoji/briefcase.png Binary files differdeleted file mode 100644 index b9912ba2148..00000000000 --- a/app/assets/images/emoji/briefcase.png +++ /dev/null diff --git a/app/assets/images/emoji/broken_heart.png b/app/assets/images/emoji/broken_heart.png Binary files differdeleted file mode 100644 index 718e26ee122..00000000000 --- a/app/assets/images/emoji/broken_heart.png +++ /dev/null diff --git a/app/assets/images/emoji/bug.png b/app/assets/images/emoji/bug.png Binary files differdeleted file mode 100644 index e64e72f259a..00000000000 --- a/app/assets/images/emoji/bug.png +++ /dev/null diff --git a/app/assets/images/emoji/bulb.png b/app/assets/images/emoji/bulb.png Binary files differdeleted file mode 100644 index 38e32e02d9f..00000000000 --- a/app/assets/images/emoji/bulb.png +++ /dev/null diff --git a/app/assets/images/emoji/bullettrain_front.png b/app/assets/images/emoji/bullettrain_front.png Binary files differdeleted file mode 100644 index 4f698e056fa..00000000000 --- a/app/assets/images/emoji/bullettrain_front.png +++ /dev/null diff --git a/app/assets/images/emoji/bullettrain_side.png b/app/assets/images/emoji/bullettrain_side.png Binary files differdeleted file mode 100644 index ed61c67bf07..00000000000 --- a/app/assets/images/emoji/bullettrain_side.png +++ /dev/null diff --git a/app/assets/images/emoji/burrito.png b/app/assets/images/emoji/burrito.png Binary files differdeleted file mode 100644 index 02bd5601df7..00000000000 --- a/app/assets/images/emoji/burrito.png +++ /dev/null diff --git a/app/assets/images/emoji/bus.png b/app/assets/images/emoji/bus.png Binary files differdeleted file mode 100644 index 641ddc56ca7..00000000000 --- a/app/assets/images/emoji/bus.png +++ /dev/null diff --git a/app/assets/images/emoji/busstop.png b/app/assets/images/emoji/busstop.png Binary files differdeleted file mode 100644 index b2b62208bfd..00000000000 --- a/app/assets/images/emoji/busstop.png +++ /dev/null diff --git a/app/assets/images/emoji/bust_in_silhouette.png b/app/assets/images/emoji/bust_in_silhouette.png Binary files differdeleted file mode 100644 index 123b2cbe1fb..00000000000 --- a/app/assets/images/emoji/bust_in_silhouette.png +++ /dev/null diff --git a/app/assets/images/emoji/busts_in_silhouette.png b/app/assets/images/emoji/busts_in_silhouette.png Binary files differdeleted file mode 100644 index d7656860a1c..00000000000 --- a/app/assets/images/emoji/busts_in_silhouette.png +++ /dev/null diff --git a/app/assets/images/emoji/butterfly.png b/app/assets/images/emoji/butterfly.png Binary files differdeleted file mode 100644 index 5631fe99226..00000000000 --- a/app/assets/images/emoji/butterfly.png +++ /dev/null diff --git a/app/assets/images/emoji/cactus.png b/app/assets/images/emoji/cactus.png Binary files differdeleted file mode 100644 index 9b48ccf3d0c..00000000000 --- a/app/assets/images/emoji/cactus.png +++ /dev/null diff --git a/app/assets/images/emoji/cake.png b/app/assets/images/emoji/cake.png Binary files differdeleted file mode 100644 index 4368177be9a..00000000000 --- a/app/assets/images/emoji/cake.png +++ /dev/null diff --git a/app/assets/images/emoji/calendar.png b/app/assets/images/emoji/calendar.png Binary files differdeleted file mode 100644 index 47353b74447..00000000000 --- a/app/assets/images/emoji/calendar.png +++ /dev/null diff --git a/app/assets/images/emoji/calendar_spiral.png b/app/assets/images/emoji/calendar_spiral.png Binary files differdeleted file mode 100644 index dec8d49bfa8..00000000000 --- a/app/assets/images/emoji/calendar_spiral.png +++ /dev/null diff --git a/app/assets/images/emoji/call_me.png b/app/assets/images/emoji/call_me.png Binary files differdeleted file mode 100644 index a10c59ba711..00000000000 --- a/app/assets/images/emoji/call_me.png +++ /dev/null diff --git a/app/assets/images/emoji/call_me_tone1.png b/app/assets/images/emoji/call_me_tone1.png Binary files differdeleted file mode 100644 index 2c93201181a..00000000000 --- a/app/assets/images/emoji/call_me_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/call_me_tone2.png b/app/assets/images/emoji/call_me_tone2.png Binary files differdeleted file mode 100644 index c39f45a41ed..00000000000 --- a/app/assets/images/emoji/call_me_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/call_me_tone3.png b/app/assets/images/emoji/call_me_tone3.png Binary files differdeleted file mode 100644 index 83a57f63c29..00000000000 --- a/app/assets/images/emoji/call_me_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/call_me_tone4.png b/app/assets/images/emoji/call_me_tone4.png Binary files differdeleted file mode 100644 index 65b3468fe44..00000000000 --- a/app/assets/images/emoji/call_me_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/call_me_tone5.png b/app/assets/images/emoji/call_me_tone5.png Binary files differdeleted file mode 100644 index 94ef68ff3b3..00000000000 --- a/app/assets/images/emoji/call_me_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/calling.png b/app/assets/images/emoji/calling.png Binary files differdeleted file mode 100644 index e2f308f8e46..00000000000 --- a/app/assets/images/emoji/calling.png +++ /dev/null diff --git a/app/assets/images/emoji/camel.png b/app/assets/images/emoji/camel.png Binary files differdeleted file mode 100644 index b421d07a805..00000000000 --- a/app/assets/images/emoji/camel.png +++ /dev/null diff --git a/app/assets/images/emoji/camera.png b/app/assets/images/emoji/camera.png Binary files differdeleted file mode 100644 index 0a3429f72ef..00000000000 --- a/app/assets/images/emoji/camera.png +++ /dev/null diff --git a/app/assets/images/emoji/camera_with_flash.png b/app/assets/images/emoji/camera_with_flash.png Binary files differdeleted file mode 100644 index 27471da2029..00000000000 --- a/app/assets/images/emoji/camera_with_flash.png +++ /dev/null diff --git a/app/assets/images/emoji/camping.png b/app/assets/images/emoji/camping.png Binary files differdeleted file mode 100644 index d589cc1f44b..00000000000 --- a/app/assets/images/emoji/camping.png +++ /dev/null diff --git a/app/assets/images/emoji/cancer.png b/app/assets/images/emoji/cancer.png Binary files differdeleted file mode 100644 index a64af07cb5f..00000000000 --- a/app/assets/images/emoji/cancer.png +++ /dev/null diff --git a/app/assets/images/emoji/candle.png b/app/assets/images/emoji/candle.png Binary files differdeleted file mode 100644 index 0b56444e355..00000000000 --- a/app/assets/images/emoji/candle.png +++ /dev/null diff --git a/app/assets/images/emoji/candy.png b/app/assets/images/emoji/candy.png Binary files differdeleted file mode 100644 index 8c67ace3a35..00000000000 --- a/app/assets/images/emoji/candy.png +++ /dev/null diff --git a/app/assets/images/emoji/canoe.png b/app/assets/images/emoji/canoe.png Binary files differdeleted file mode 100644 index e26cdb9da69..00000000000 --- a/app/assets/images/emoji/canoe.png +++ /dev/null diff --git a/app/assets/images/emoji/capital_abcd.png b/app/assets/images/emoji/capital_abcd.png Binary files differdeleted file mode 100644 index fe9482d2d8a..00000000000 --- a/app/assets/images/emoji/capital_abcd.png +++ /dev/null diff --git a/app/assets/images/emoji/capricorn.png b/app/assets/images/emoji/capricorn.png Binary files differdeleted file mode 100644 index 6293d31d4b1..00000000000 --- a/app/assets/images/emoji/capricorn.png +++ /dev/null diff --git a/app/assets/images/emoji/card_box.png b/app/assets/images/emoji/card_box.png Binary files differdeleted file mode 100644 index f2e764ce59d..00000000000 --- a/app/assets/images/emoji/card_box.png +++ /dev/null diff --git a/app/assets/images/emoji/card_index.png b/app/assets/images/emoji/card_index.png Binary files differdeleted file mode 100644 index 151e11cb3b4..00000000000 --- a/app/assets/images/emoji/card_index.png +++ /dev/null diff --git a/app/assets/images/emoji/carousel_horse.png b/app/assets/images/emoji/carousel_horse.png Binary files differdeleted file mode 100644 index a17074edf05..00000000000 --- a/app/assets/images/emoji/carousel_horse.png +++ /dev/null diff --git a/app/assets/images/emoji/carrot.png b/app/assets/images/emoji/carrot.png Binary files differdeleted file mode 100644 index c68829b58e7..00000000000 --- a/app/assets/images/emoji/carrot.png +++ /dev/null diff --git a/app/assets/images/emoji/cartwheel.png b/app/assets/images/emoji/cartwheel.png Binary files differdeleted file mode 100644 index cbcaa578253..00000000000 --- a/app/assets/images/emoji/cartwheel.png +++ /dev/null diff --git a/app/assets/images/emoji/cartwheel_tone1.png b/app/assets/images/emoji/cartwheel_tone1.png Binary files differdeleted file mode 100644 index db6d65895fb..00000000000 --- a/app/assets/images/emoji/cartwheel_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/cartwheel_tone2.png b/app/assets/images/emoji/cartwheel_tone2.png Binary files differdeleted file mode 100644 index e00ffbc27a8..00000000000 --- a/app/assets/images/emoji/cartwheel_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/cartwheel_tone3.png b/app/assets/images/emoji/cartwheel_tone3.png Binary files differdeleted file mode 100644 index 49321be391f..00000000000 --- a/app/assets/images/emoji/cartwheel_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/cartwheel_tone4.png b/app/assets/images/emoji/cartwheel_tone4.png Binary files differdeleted file mode 100644 index d4562b5e3dd..00000000000 --- a/app/assets/images/emoji/cartwheel_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/cartwheel_tone5.png b/app/assets/images/emoji/cartwheel_tone5.png Binary files differdeleted file mode 100644 index 6e09a870767..00000000000 --- a/app/assets/images/emoji/cartwheel_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/cat.png b/app/assets/images/emoji/cat.png Binary files differdeleted file mode 100644 index efd82c2abf3..00000000000 --- a/app/assets/images/emoji/cat.png +++ /dev/null diff --git a/app/assets/images/emoji/cat2.png b/app/assets/images/emoji/cat2.png Binary files differdeleted file mode 100644 index 46abe8cbc14..00000000000 --- a/app/assets/images/emoji/cat2.png +++ /dev/null diff --git a/app/assets/images/emoji/cd.png b/app/assets/images/emoji/cd.png Binary files differdeleted file mode 100644 index e6b01449cd9..00000000000 --- a/app/assets/images/emoji/cd.png +++ /dev/null diff --git a/app/assets/images/emoji/chains.png b/app/assets/images/emoji/chains.png Binary files differdeleted file mode 100644 index 57f46139a06..00000000000 --- a/app/assets/images/emoji/chains.png +++ /dev/null diff --git a/app/assets/images/emoji/champagne.png b/app/assets/images/emoji/champagne.png Binary files differdeleted file mode 100644 index 285a79a93d0..00000000000 --- a/app/assets/images/emoji/champagne.png +++ /dev/null diff --git a/app/assets/images/emoji/champagne_glass.png b/app/assets/images/emoji/champagne_glass.png Binary files differdeleted file mode 100644 index 31937ae9392..00000000000 --- a/app/assets/images/emoji/champagne_glass.png +++ /dev/null diff --git a/app/assets/images/emoji/chart.png b/app/assets/images/emoji/chart.png Binary files differdeleted file mode 100644 index 9773f03be22..00000000000 --- a/app/assets/images/emoji/chart.png +++ /dev/null diff --git a/app/assets/images/emoji/chart_with_downwards_trend.png b/app/assets/images/emoji/chart_with_downwards_trend.png Binary files differdeleted file mode 100644 index 5222ec72d85..00000000000 --- a/app/assets/images/emoji/chart_with_downwards_trend.png +++ /dev/null diff --git a/app/assets/images/emoji/chart_with_upwards_trend.png b/app/assets/images/emoji/chart_with_upwards_trend.png Binary files differdeleted file mode 100644 index f13cfcf9956..00000000000 --- a/app/assets/images/emoji/chart_with_upwards_trend.png +++ /dev/null diff --git a/app/assets/images/emoji/checkered_flag.png b/app/assets/images/emoji/checkered_flag.png Binary files differdeleted file mode 100644 index 5a71eecb89b..00000000000 --- a/app/assets/images/emoji/checkered_flag.png +++ /dev/null diff --git a/app/assets/images/emoji/cheese.png b/app/assets/images/emoji/cheese.png Binary files differdeleted file mode 100644 index 00e99762286..00000000000 --- a/app/assets/images/emoji/cheese.png +++ /dev/null diff --git a/app/assets/images/emoji/cherries.png b/app/assets/images/emoji/cherries.png Binary files differdeleted file mode 100644 index 9b10cbaac5e..00000000000 --- a/app/assets/images/emoji/cherries.png +++ /dev/null diff --git a/app/assets/images/emoji/cherry_blossom.png b/app/assets/images/emoji/cherry_blossom.png Binary files differdeleted file mode 100644 index 282f3e7bc81..00000000000 --- a/app/assets/images/emoji/cherry_blossom.png +++ /dev/null diff --git a/app/assets/images/emoji/chestnut.png b/app/assets/images/emoji/chestnut.png Binary files differdeleted file mode 100644 index e9fb40468ed..00000000000 --- a/app/assets/images/emoji/chestnut.png +++ /dev/null diff --git a/app/assets/images/emoji/chicken.png b/app/assets/images/emoji/chicken.png Binary files differdeleted file mode 100644 index 9a6992e55ba..00000000000 --- a/app/assets/images/emoji/chicken.png +++ /dev/null diff --git a/app/assets/images/emoji/children_crossing.png b/app/assets/images/emoji/children_crossing.png Binary files differdeleted file mode 100644 index fa4c091c7c3..00000000000 --- a/app/assets/images/emoji/children_crossing.png +++ /dev/null diff --git a/app/assets/images/emoji/chipmunk.png b/app/assets/images/emoji/chipmunk.png Binary files differdeleted file mode 100644 index 2aac560cb22..00000000000 --- a/app/assets/images/emoji/chipmunk.png +++ /dev/null diff --git a/app/assets/images/emoji/chocolate_bar.png b/app/assets/images/emoji/chocolate_bar.png Binary files differdeleted file mode 100644 index 318bbd40ef9..00000000000 --- a/app/assets/images/emoji/chocolate_bar.png +++ /dev/null diff --git a/app/assets/images/emoji/christmas_tree.png b/app/assets/images/emoji/christmas_tree.png Binary files differdeleted file mode 100644 index 4197d37a52b..00000000000 --- a/app/assets/images/emoji/christmas_tree.png +++ /dev/null diff --git a/app/assets/images/emoji/church.png b/app/assets/images/emoji/church.png Binary files differdeleted file mode 100644 index 8242fd272b3..00000000000 --- a/app/assets/images/emoji/church.png +++ /dev/null diff --git a/app/assets/images/emoji/cinema.png b/app/assets/images/emoji/cinema.png Binary files differdeleted file mode 100644 index 65f27b386f2..00000000000 --- a/app/assets/images/emoji/cinema.png +++ /dev/null diff --git a/app/assets/images/emoji/circus_tent.png b/app/assets/images/emoji/circus_tent.png Binary files differdeleted file mode 100644 index b0379775b12..00000000000 --- a/app/assets/images/emoji/circus_tent.png +++ /dev/null diff --git a/app/assets/images/emoji/city_dusk.png b/app/assets/images/emoji/city_dusk.png Binary files differdeleted file mode 100644 index 80cdff7cf5d..00000000000 --- a/app/assets/images/emoji/city_dusk.png +++ /dev/null diff --git a/app/assets/images/emoji/city_sunset.png b/app/assets/images/emoji/city_sunset.png Binary files differdeleted file mode 100644 index 7cded0ba55b..00000000000 --- a/app/assets/images/emoji/city_sunset.png +++ /dev/null diff --git a/app/assets/images/emoji/cityscape.png b/app/assets/images/emoji/cityscape.png Binary files differdeleted file mode 100644 index d7b9844a0b4..00000000000 --- a/app/assets/images/emoji/cityscape.png +++ /dev/null diff --git a/app/assets/images/emoji/cl.png b/app/assets/images/emoji/cl.png Binary files differdeleted file mode 100644 index 8b01b4343e2..00000000000 --- a/app/assets/images/emoji/cl.png +++ /dev/null diff --git a/app/assets/images/emoji/clap.png b/app/assets/images/emoji/clap.png Binary files differdeleted file mode 100644 index b0ffe928920..00000000000 --- a/app/assets/images/emoji/clap.png +++ /dev/null diff --git a/app/assets/images/emoji/clap_tone1.png b/app/assets/images/emoji/clap_tone1.png Binary files differdeleted file mode 100644 index de4bc837b96..00000000000 --- a/app/assets/images/emoji/clap_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/clap_tone2.png b/app/assets/images/emoji/clap_tone2.png Binary files differdeleted file mode 100644 index 1323de775ba..00000000000 --- a/app/assets/images/emoji/clap_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/clap_tone3.png b/app/assets/images/emoji/clap_tone3.png Binary files differdeleted file mode 100644 index d448ca19dde..00000000000 --- a/app/assets/images/emoji/clap_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/clap_tone4.png b/app/assets/images/emoji/clap_tone4.png Binary files differdeleted file mode 100644 index c49f44ee91d..00000000000 --- a/app/assets/images/emoji/clap_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/clap_tone5.png b/app/assets/images/emoji/clap_tone5.png Binary files differdeleted file mode 100644 index 29ee9bdf37c..00000000000 --- a/app/assets/images/emoji/clap_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/clapper.png b/app/assets/images/emoji/clapper.png Binary files differdeleted file mode 100644 index 81390883111..00000000000 --- a/app/assets/images/emoji/clapper.png +++ /dev/null diff --git a/app/assets/images/emoji/classical_building.png b/app/assets/images/emoji/classical_building.png Binary files differdeleted file mode 100644 index de7b559daaf..00000000000 --- a/app/assets/images/emoji/classical_building.png +++ /dev/null diff --git a/app/assets/images/emoji/clipboard.png b/app/assets/images/emoji/clipboard.png Binary files differdeleted file mode 100644 index 7edcfc52509..00000000000 --- a/app/assets/images/emoji/clipboard.png +++ /dev/null diff --git a/app/assets/images/emoji/clock.png b/app/assets/images/emoji/clock.png Binary files differdeleted file mode 100644 index ffdb451e3a8..00000000000 --- a/app/assets/images/emoji/clock.png +++ /dev/null diff --git a/app/assets/images/emoji/clock1.png b/app/assets/images/emoji/clock1.png Binary files differdeleted file mode 100644 index d6e34941f23..00000000000 --- a/app/assets/images/emoji/clock1.png +++ /dev/null diff --git a/app/assets/images/emoji/clock10.png b/app/assets/images/emoji/clock10.png Binary files differdeleted file mode 100644 index e62b245cdbe..00000000000 --- a/app/assets/images/emoji/clock10.png +++ /dev/null diff --git a/app/assets/images/emoji/clock1030.png b/app/assets/images/emoji/clock1030.png Binary files differdeleted file mode 100644 index 0802b3c65b9..00000000000 --- a/app/assets/images/emoji/clock1030.png +++ /dev/null diff --git a/app/assets/images/emoji/clock11.png b/app/assets/images/emoji/clock11.png Binary files differdeleted file mode 100644 index 0983345273b..00000000000 --- a/app/assets/images/emoji/clock11.png +++ /dev/null diff --git a/app/assets/images/emoji/clock1130.png b/app/assets/images/emoji/clock1130.png Binary files differdeleted file mode 100644 index d970d03b809..00000000000 --- a/app/assets/images/emoji/clock1130.png +++ /dev/null diff --git a/app/assets/images/emoji/clock12.png b/app/assets/images/emoji/clock12.png Binary files differdeleted file mode 100644 index e61caa4b3e2..00000000000 --- a/app/assets/images/emoji/clock12.png +++ /dev/null diff --git a/app/assets/images/emoji/clock1230.png b/app/assets/images/emoji/clock1230.png Binary files differdeleted file mode 100644 index f2b1d261721..00000000000 --- a/app/assets/images/emoji/clock1230.png +++ /dev/null diff --git a/app/assets/images/emoji/clock130.png b/app/assets/images/emoji/clock130.png Binary files differdeleted file mode 100644 index 86b7689b84e..00000000000 --- a/app/assets/images/emoji/clock130.png +++ /dev/null diff --git a/app/assets/images/emoji/clock2.png b/app/assets/images/emoji/clock2.png Binary files differdeleted file mode 100644 index a54253d7d57..00000000000 --- a/app/assets/images/emoji/clock2.png +++ /dev/null diff --git a/app/assets/images/emoji/clock230.png b/app/assets/images/emoji/clock230.png Binary files differdeleted file mode 100644 index 7a787e018e6..00000000000 --- a/app/assets/images/emoji/clock230.png +++ /dev/null diff --git a/app/assets/images/emoji/clock3.png b/app/assets/images/emoji/clock3.png Binary files differdeleted file mode 100644 index 27ec4b1f514..00000000000 --- a/app/assets/images/emoji/clock3.png +++ /dev/null diff --git a/app/assets/images/emoji/clock330.png b/app/assets/images/emoji/clock330.png Binary files differdeleted file mode 100644 index c6860395cec..00000000000 --- a/app/assets/images/emoji/clock330.png +++ /dev/null diff --git a/app/assets/images/emoji/clock4.png b/app/assets/images/emoji/clock4.png Binary files differdeleted file mode 100644 index 60a1ef4cc13..00000000000 --- a/app/assets/images/emoji/clock4.png +++ /dev/null diff --git a/app/assets/images/emoji/clock430.png b/app/assets/images/emoji/clock430.png Binary files differdeleted file mode 100644 index 3c05b362122..00000000000 --- a/app/assets/images/emoji/clock430.png +++ /dev/null diff --git a/app/assets/images/emoji/clock5.png b/app/assets/images/emoji/clock5.png Binary files differdeleted file mode 100644 index c9382d1e094..00000000000 --- a/app/assets/images/emoji/clock5.png +++ /dev/null diff --git a/app/assets/images/emoji/clock530.png b/app/assets/images/emoji/clock530.png Binary files differdeleted file mode 100644 index c21fa926db2..00000000000 --- a/app/assets/images/emoji/clock530.png +++ /dev/null diff --git a/app/assets/images/emoji/clock6.png b/app/assets/images/emoji/clock6.png Binary files differdeleted file mode 100644 index 8fd5d3f5bd7..00000000000 --- a/app/assets/images/emoji/clock6.png +++ /dev/null diff --git a/app/assets/images/emoji/clock630.png b/app/assets/images/emoji/clock630.png Binary files differdeleted file mode 100644 index 2aec87fefcf..00000000000 --- a/app/assets/images/emoji/clock630.png +++ /dev/null diff --git a/app/assets/images/emoji/clock7.png b/app/assets/images/emoji/clock7.png Binary files differdeleted file mode 100644 index 8c7084036f2..00000000000 --- a/app/assets/images/emoji/clock7.png +++ /dev/null diff --git a/app/assets/images/emoji/clock730.png b/app/assets/images/emoji/clock730.png Binary files differdeleted file mode 100644 index f7a1135e03f..00000000000 --- a/app/assets/images/emoji/clock730.png +++ /dev/null diff --git a/app/assets/images/emoji/clock8.png b/app/assets/images/emoji/clock8.png Binary files differdeleted file mode 100644 index fcddf722e95..00000000000 --- a/app/assets/images/emoji/clock8.png +++ /dev/null diff --git a/app/assets/images/emoji/clock830.png b/app/assets/images/emoji/clock830.png Binary files differdeleted file mode 100644 index 799b4aebc08..00000000000 --- a/app/assets/images/emoji/clock830.png +++ /dev/null diff --git a/app/assets/images/emoji/clock9.png b/app/assets/images/emoji/clock9.png Binary files differdeleted file mode 100644 index dfbe0117981..00000000000 --- a/app/assets/images/emoji/clock9.png +++ /dev/null diff --git a/app/assets/images/emoji/clock930.png b/app/assets/images/emoji/clock930.png Binary files differdeleted file mode 100644 index 4a2092ee6f0..00000000000 --- a/app/assets/images/emoji/clock930.png +++ /dev/null diff --git a/app/assets/images/emoji/closed_book.png b/app/assets/images/emoji/closed_book.png Binary files differdeleted file mode 100644 index 6395cf2151e..00000000000 --- a/app/assets/images/emoji/closed_book.png +++ /dev/null diff --git a/app/assets/images/emoji/closed_lock_with_key.png b/app/assets/images/emoji/closed_lock_with_key.png Binary files differdeleted file mode 100644 index 1c1cd5d0741..00000000000 --- a/app/assets/images/emoji/closed_lock_with_key.png +++ /dev/null diff --git a/app/assets/images/emoji/closed_umbrella.png b/app/assets/images/emoji/closed_umbrella.png Binary files differdeleted file mode 100644 index ecefba9e446..00000000000 --- a/app/assets/images/emoji/closed_umbrella.png +++ /dev/null diff --git a/app/assets/images/emoji/cloud.png b/app/assets/images/emoji/cloud.png Binary files differdeleted file mode 100644 index 5b4f57f77ba..00000000000 --- a/app/assets/images/emoji/cloud.png +++ /dev/null diff --git a/app/assets/images/emoji/cloud_lightning.png b/app/assets/images/emoji/cloud_lightning.png Binary files differdeleted file mode 100644 index 0831e88aa31..00000000000 --- a/app/assets/images/emoji/cloud_lightning.png +++ /dev/null diff --git a/app/assets/images/emoji/cloud_rain.png b/app/assets/images/emoji/cloud_rain.png Binary files differdeleted file mode 100644 index 385685e0512..00000000000 --- a/app/assets/images/emoji/cloud_rain.png +++ /dev/null diff --git a/app/assets/images/emoji/cloud_snow.png b/app/assets/images/emoji/cloud_snow.png Binary files differdeleted file mode 100644 index 9720384eb99..00000000000 --- a/app/assets/images/emoji/cloud_snow.png +++ /dev/null diff --git a/app/assets/images/emoji/cloud_tornado.png b/app/assets/images/emoji/cloud_tornado.png Binary files differdeleted file mode 100644 index 4821c89da1e..00000000000 --- a/app/assets/images/emoji/cloud_tornado.png +++ /dev/null diff --git a/app/assets/images/emoji/clown.png b/app/assets/images/emoji/clown.png Binary files differdeleted file mode 100644 index 02b7ff70049..00000000000 --- a/app/assets/images/emoji/clown.png +++ /dev/null diff --git a/app/assets/images/emoji/clubs.png b/app/assets/images/emoji/clubs.png Binary files differdeleted file mode 100644 index 4f2abf791ca..00000000000 --- a/app/assets/images/emoji/clubs.png +++ /dev/null diff --git a/app/assets/images/emoji/cocktail.png b/app/assets/images/emoji/cocktail.png Binary files differdeleted file mode 100644 index 2e50c57e98d..00000000000 --- a/app/assets/images/emoji/cocktail.png +++ /dev/null diff --git a/app/assets/images/emoji/coffee.png b/app/assets/images/emoji/coffee.png Binary files differdeleted file mode 100644 index 553061471b1..00000000000 --- a/app/assets/images/emoji/coffee.png +++ /dev/null diff --git a/app/assets/images/emoji/coffin.png b/app/assets/images/emoji/coffin.png Binary files differdeleted file mode 100644 index fb2932aa5f6..00000000000 --- a/app/assets/images/emoji/coffin.png +++ /dev/null diff --git a/app/assets/images/emoji/cold_sweat.png b/app/assets/images/emoji/cold_sweat.png Binary files differdeleted file mode 100644 index 85b2231bbf6..00000000000 --- a/app/assets/images/emoji/cold_sweat.png +++ /dev/null diff --git a/app/assets/images/emoji/comet.png b/app/assets/images/emoji/comet.png Binary files differdeleted file mode 100644 index a99751f79be..00000000000 --- a/app/assets/images/emoji/comet.png +++ /dev/null diff --git a/app/assets/images/emoji/compression.png b/app/assets/images/emoji/compression.png Binary files differdeleted file mode 100644 index d7eda7f362a..00000000000 --- a/app/assets/images/emoji/compression.png +++ /dev/null diff --git a/app/assets/images/emoji/computer.png b/app/assets/images/emoji/computer.png Binary files differdeleted file mode 100644 index c1fee27e3a9..00000000000 --- a/app/assets/images/emoji/computer.png +++ /dev/null diff --git a/app/assets/images/emoji/confetti_ball.png b/app/assets/images/emoji/confetti_ball.png Binary files differdeleted file mode 100644 index ba4fd9b12be..00000000000 --- a/app/assets/images/emoji/confetti_ball.png +++ /dev/null diff --git a/app/assets/images/emoji/confounded.png b/app/assets/images/emoji/confounded.png Binary files differdeleted file mode 100644 index aa4b29e9375..00000000000 --- a/app/assets/images/emoji/confounded.png +++ /dev/null diff --git a/app/assets/images/emoji/confused.png b/app/assets/images/emoji/confused.png Binary files differdeleted file mode 100644 index 502b6bf0e0b..00000000000 --- a/app/assets/images/emoji/confused.png +++ /dev/null diff --git a/app/assets/images/emoji/congratulations.png b/app/assets/images/emoji/congratulations.png Binary files differdeleted file mode 100644 index ba8c89d95ee..00000000000 --- a/app/assets/images/emoji/congratulations.png +++ /dev/null diff --git a/app/assets/images/emoji/construction.png b/app/assets/images/emoji/construction.png Binary files differdeleted file mode 100644 index ef8db5f471c..00000000000 --- a/app/assets/images/emoji/construction.png +++ /dev/null diff --git a/app/assets/images/emoji/construction_site.png b/app/assets/images/emoji/construction_site.png Binary files differdeleted file mode 100644 index 8206a20f63f..00000000000 --- a/app/assets/images/emoji/construction_site.png +++ /dev/null diff --git a/app/assets/images/emoji/construction_worker.png b/app/assets/images/emoji/construction_worker.png Binary files differdeleted file mode 100644 index a9970a89005..00000000000 --- a/app/assets/images/emoji/construction_worker.png +++ /dev/null diff --git a/app/assets/images/emoji/construction_worker_tone1.png b/app/assets/images/emoji/construction_worker_tone1.png Binary files differdeleted file mode 100644 index 2f24a2bab24..00000000000 --- a/app/assets/images/emoji/construction_worker_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/construction_worker_tone2.png b/app/assets/images/emoji/construction_worker_tone2.png Binary files differdeleted file mode 100644 index 93c8fec5a75..00000000000 --- a/app/assets/images/emoji/construction_worker_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/construction_worker_tone3.png b/app/assets/images/emoji/construction_worker_tone3.png Binary files differdeleted file mode 100644 index abc1f2af2e0..00000000000 --- a/app/assets/images/emoji/construction_worker_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/construction_worker_tone4.png b/app/assets/images/emoji/construction_worker_tone4.png Binary files differdeleted file mode 100644 index eed83289aeb..00000000000 --- a/app/assets/images/emoji/construction_worker_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/construction_worker_tone5.png b/app/assets/images/emoji/construction_worker_tone5.png Binary files differdeleted file mode 100644 index acbb220b8bb..00000000000 --- a/app/assets/images/emoji/construction_worker_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/control_knobs.png b/app/assets/images/emoji/control_knobs.png Binary files differdeleted file mode 100644 index 6635ac93b50..00000000000 --- a/app/assets/images/emoji/control_knobs.png +++ /dev/null diff --git a/app/assets/images/emoji/convenience_store.png b/app/assets/images/emoji/convenience_store.png Binary files differdeleted file mode 100644 index 26b53b5669e..00000000000 --- a/app/assets/images/emoji/convenience_store.png +++ /dev/null diff --git a/app/assets/images/emoji/cookie.png b/app/assets/images/emoji/cookie.png Binary files differdeleted file mode 100644 index 1b6bcb1554f..00000000000 --- a/app/assets/images/emoji/cookie.png +++ /dev/null diff --git a/app/assets/images/emoji/cooking.png b/app/assets/images/emoji/cooking.png Binary files differdeleted file mode 100644 index 918c980577a..00000000000 --- a/app/assets/images/emoji/cooking.png +++ /dev/null diff --git a/app/assets/images/emoji/cool.png b/app/assets/images/emoji/cool.png Binary files differdeleted file mode 100644 index 74674978d00..00000000000 --- a/app/assets/images/emoji/cool.png +++ /dev/null diff --git a/app/assets/images/emoji/cop.png b/app/assets/images/emoji/cop.png Binary files differdeleted file mode 100644 index 0b16d7c17b7..00000000000 --- a/app/assets/images/emoji/cop.png +++ /dev/null diff --git a/app/assets/images/emoji/cop_tone1.png b/app/assets/images/emoji/cop_tone1.png Binary files differdeleted file mode 100644 index 6ccba3879dc..00000000000 --- a/app/assets/images/emoji/cop_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/cop_tone2.png b/app/assets/images/emoji/cop_tone2.png Binary files differdeleted file mode 100644 index 7814ea9f52d..00000000000 --- a/app/assets/images/emoji/cop_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/cop_tone3.png b/app/assets/images/emoji/cop_tone3.png Binary files differdeleted file mode 100644 index d78e88ec872..00000000000 --- a/app/assets/images/emoji/cop_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/cop_tone4.png b/app/assets/images/emoji/cop_tone4.png Binary files differdeleted file mode 100644 index 2e13c508315..00000000000 --- a/app/assets/images/emoji/cop_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/cop_tone5.png b/app/assets/images/emoji/cop_tone5.png Binary files differdeleted file mode 100644 index 2980d61cc2e..00000000000 --- a/app/assets/images/emoji/cop_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/copyright.png b/app/assets/images/emoji/copyright.png Binary files differdeleted file mode 100644 index 6b9a6adbfd2..00000000000 --- a/app/assets/images/emoji/copyright.png +++ /dev/null diff --git a/app/assets/images/emoji/corn.png b/app/assets/images/emoji/corn.png Binary files differdeleted file mode 100644 index 36e20127931..00000000000 --- a/app/assets/images/emoji/corn.png +++ /dev/null diff --git a/app/assets/images/emoji/couch.png b/app/assets/images/emoji/couch.png Binary files differdeleted file mode 100644 index 27b19b13bb0..00000000000 --- a/app/assets/images/emoji/couch.png +++ /dev/null diff --git a/app/assets/images/emoji/couple.png b/app/assets/images/emoji/couple.png Binary files differdeleted file mode 100644 index 960323f3c16..00000000000 --- a/app/assets/images/emoji/couple.png +++ /dev/null diff --git a/app/assets/images/emoji/couple_mm.png b/app/assets/images/emoji/couple_mm.png Binary files differdeleted file mode 100644 index 8759fa5db87..00000000000 --- a/app/assets/images/emoji/couple_mm.png +++ /dev/null diff --git a/app/assets/images/emoji/couple_with_heart.png b/app/assets/images/emoji/couple_with_heart.png Binary files differdeleted file mode 100644 index 62111601b36..00000000000 --- a/app/assets/images/emoji/couple_with_heart.png +++ /dev/null diff --git a/app/assets/images/emoji/couple_ww.png b/app/assets/images/emoji/couple_ww.png Binary files differdeleted file mode 100644 index 08fdabcdc5c..00000000000 --- a/app/assets/images/emoji/couple_ww.png +++ /dev/null diff --git a/app/assets/images/emoji/couplekiss.png b/app/assets/images/emoji/couplekiss.png Binary files differdeleted file mode 100644 index 9aa519da9e8..00000000000 --- a/app/assets/images/emoji/couplekiss.png +++ /dev/null diff --git a/app/assets/images/emoji/cow.png b/app/assets/images/emoji/cow.png Binary files differdeleted file mode 100644 index 718a3986d64..00000000000 --- a/app/assets/images/emoji/cow.png +++ /dev/null diff --git a/app/assets/images/emoji/cow2.png b/app/assets/images/emoji/cow2.png Binary files differdeleted file mode 100644 index 4d0ca534ff1..00000000000 --- a/app/assets/images/emoji/cow2.png +++ /dev/null diff --git a/app/assets/images/emoji/cowboy.png b/app/assets/images/emoji/cowboy.png Binary files differdeleted file mode 100644 index 70dd5d0d9d1..00000000000 --- a/app/assets/images/emoji/cowboy.png +++ /dev/null diff --git a/app/assets/images/emoji/crab.png b/app/assets/images/emoji/crab.png Binary files differdeleted file mode 100644 index 19f3047ab61..00000000000 --- a/app/assets/images/emoji/crab.png +++ /dev/null diff --git a/app/assets/images/emoji/crayon.png b/app/assets/images/emoji/crayon.png Binary files differdeleted file mode 100644 index 8d7b427aaa3..00000000000 --- a/app/assets/images/emoji/crayon.png +++ /dev/null diff --git a/app/assets/images/emoji/credit_card.png b/app/assets/images/emoji/credit_card.png Binary files differdeleted file mode 100644 index 372777d5c61..00000000000 --- a/app/assets/images/emoji/credit_card.png +++ /dev/null diff --git a/app/assets/images/emoji/crescent_moon.png b/app/assets/images/emoji/crescent_moon.png Binary files differdeleted file mode 100644 index 765420ecec7..00000000000 --- a/app/assets/images/emoji/crescent_moon.png +++ /dev/null diff --git a/app/assets/images/emoji/cricket.png b/app/assets/images/emoji/cricket.png Binary files differdeleted file mode 100644 index d602294a2cd..00000000000 --- a/app/assets/images/emoji/cricket.png +++ /dev/null diff --git a/app/assets/images/emoji/crocodile.png b/app/assets/images/emoji/crocodile.png Binary files differdeleted file mode 100644 index 3005c46f176..00000000000 --- a/app/assets/images/emoji/crocodile.png +++ /dev/null diff --git a/app/assets/images/emoji/croissant.png b/app/assets/images/emoji/croissant.png Binary files differdeleted file mode 100644 index fb33feb1a38..00000000000 --- a/app/assets/images/emoji/croissant.png +++ /dev/null diff --git a/app/assets/images/emoji/cross.png b/app/assets/images/emoji/cross.png Binary files differdeleted file mode 100644 index 42b10e82257..00000000000 --- a/app/assets/images/emoji/cross.png +++ /dev/null diff --git a/app/assets/images/emoji/crossed_flags.png b/app/assets/images/emoji/crossed_flags.png Binary files differdeleted file mode 100644 index 273bd0f0fe5..00000000000 --- a/app/assets/images/emoji/crossed_flags.png +++ /dev/null diff --git a/app/assets/images/emoji/crossed_swords.png b/app/assets/images/emoji/crossed_swords.png Binary files differdeleted file mode 100644 index 907e9607134..00000000000 --- a/app/assets/images/emoji/crossed_swords.png +++ /dev/null diff --git a/app/assets/images/emoji/crown.png b/app/assets/images/emoji/crown.png Binary files differdeleted file mode 100644 index 93b82d92f04..00000000000 --- a/app/assets/images/emoji/crown.png +++ /dev/null diff --git a/app/assets/images/emoji/cruise_ship.png b/app/assets/images/emoji/cruise_ship.png Binary files differdeleted file mode 100644 index 19d4acbe40c..00000000000 --- a/app/assets/images/emoji/cruise_ship.png +++ /dev/null diff --git a/app/assets/images/emoji/cry.png b/app/assets/images/emoji/cry.png Binary files differdeleted file mode 100644 index b7877f8a173..00000000000 --- a/app/assets/images/emoji/cry.png +++ /dev/null diff --git a/app/assets/images/emoji/crying_cat_face.png b/app/assets/images/emoji/crying_cat_face.png Binary files differdeleted file mode 100644 index b4f49715e00..00000000000 --- a/app/assets/images/emoji/crying_cat_face.png +++ /dev/null diff --git a/app/assets/images/emoji/crystal_ball.png b/app/assets/images/emoji/crystal_ball.png Binary files differdeleted file mode 100644 index 485d5c888f1..00000000000 --- a/app/assets/images/emoji/crystal_ball.png +++ /dev/null diff --git a/app/assets/images/emoji/cucumber.png b/app/assets/images/emoji/cucumber.png Binary files differdeleted file mode 100644 index 500807059d2..00000000000 --- a/app/assets/images/emoji/cucumber.png +++ /dev/null diff --git a/app/assets/images/emoji/cupid.png b/app/assets/images/emoji/cupid.png Binary files differdeleted file mode 100644 index 2df0078ddd1..00000000000 --- a/app/assets/images/emoji/cupid.png +++ /dev/null diff --git a/app/assets/images/emoji/curly_loop.png b/app/assets/images/emoji/curly_loop.png Binary files differdeleted file mode 100644 index 440aa56d50e..00000000000 --- a/app/assets/images/emoji/curly_loop.png +++ /dev/null diff --git a/app/assets/images/emoji/currency_exchange.png b/app/assets/images/emoji/currency_exchange.png Binary files differdeleted file mode 100644 index 4d46c6050e7..00000000000 --- a/app/assets/images/emoji/currency_exchange.png +++ /dev/null diff --git a/app/assets/images/emoji/curry.png b/app/assets/images/emoji/curry.png Binary files differdeleted file mode 100644 index 69657ca8103..00000000000 --- a/app/assets/images/emoji/curry.png +++ /dev/null diff --git a/app/assets/images/emoji/custard.png b/app/assets/images/emoji/custard.png Binary files differdeleted file mode 100644 index fa3df67b8f6..00000000000 --- a/app/assets/images/emoji/custard.png +++ /dev/null diff --git a/app/assets/images/emoji/customs.png b/app/assets/images/emoji/customs.png Binary files differdeleted file mode 100644 index 21b7ce2c69e..00000000000 --- a/app/assets/images/emoji/customs.png +++ /dev/null diff --git a/app/assets/images/emoji/cyclone.png b/app/assets/images/emoji/cyclone.png Binary files differdeleted file mode 100644 index ff00b1afe70..00000000000 --- a/app/assets/images/emoji/cyclone.png +++ /dev/null diff --git a/app/assets/images/emoji/dagger.png b/app/assets/images/emoji/dagger.png Binary files differdeleted file mode 100644 index 66e97b0aa25..00000000000 --- a/app/assets/images/emoji/dagger.png +++ /dev/null diff --git a/app/assets/images/emoji/dancer.png b/app/assets/images/emoji/dancer.png Binary files differdeleted file mode 100644 index 04b166991cb..00000000000 --- a/app/assets/images/emoji/dancer.png +++ /dev/null diff --git a/app/assets/images/emoji/dancer_tone1.png b/app/assets/images/emoji/dancer_tone1.png Binary files differdeleted file mode 100644 index 2c7b11c3a6e..00000000000 --- a/app/assets/images/emoji/dancer_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/dancer_tone2.png b/app/assets/images/emoji/dancer_tone2.png Binary files differdeleted file mode 100644 index cb04b1f907e..00000000000 --- a/app/assets/images/emoji/dancer_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/dancer_tone3.png b/app/assets/images/emoji/dancer_tone3.png Binary files differdeleted file mode 100644 index 98c5bca7b64..00000000000 --- a/app/assets/images/emoji/dancer_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/dancer_tone4.png b/app/assets/images/emoji/dancer_tone4.png Binary files differdeleted file mode 100644 index fdb1e00cbba..00000000000 --- a/app/assets/images/emoji/dancer_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/dancer_tone5.png b/app/assets/images/emoji/dancer_tone5.png Binary files differdeleted file mode 100644 index 0e34e0e23f0..00000000000 --- a/app/assets/images/emoji/dancer_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/dancers.png b/app/assets/images/emoji/dancers.png Binary files differdeleted file mode 100644 index 67e6ffacb76..00000000000 --- a/app/assets/images/emoji/dancers.png +++ /dev/null diff --git a/app/assets/images/emoji/dango.png b/app/assets/images/emoji/dango.png Binary files differdeleted file mode 100644 index f73f37b01c7..00000000000 --- a/app/assets/images/emoji/dango.png +++ /dev/null diff --git a/app/assets/images/emoji/dark_sunglasses.png b/app/assets/images/emoji/dark_sunglasses.png Binary files differdeleted file mode 100644 index b1b6db0acff..00000000000 --- a/app/assets/images/emoji/dark_sunglasses.png +++ /dev/null diff --git a/app/assets/images/emoji/dart.png b/app/assets/images/emoji/dart.png Binary files differdeleted file mode 100644 index f6704aeb8ba..00000000000 --- a/app/assets/images/emoji/dart.png +++ /dev/null diff --git a/app/assets/images/emoji/dash.png b/app/assets/images/emoji/dash.png Binary files differdeleted file mode 100644 index 064b8525c12..00000000000 --- a/app/assets/images/emoji/dash.png +++ /dev/null diff --git a/app/assets/images/emoji/date.png b/app/assets/images/emoji/date.png Binary files differdeleted file mode 100644 index f05b3da97b8..00000000000 --- a/app/assets/images/emoji/date.png +++ /dev/null diff --git a/app/assets/images/emoji/deciduous_tree.png b/app/assets/images/emoji/deciduous_tree.png Binary files differdeleted file mode 100644 index 785fc1c30ea..00000000000 --- a/app/assets/images/emoji/deciduous_tree.png +++ /dev/null diff --git a/app/assets/images/emoji/deer.png b/app/assets/images/emoji/deer.png Binary files differdeleted file mode 100644 index d8698195ff0..00000000000 --- a/app/assets/images/emoji/deer.png +++ /dev/null diff --git a/app/assets/images/emoji/department_store.png b/app/assets/images/emoji/department_store.png Binary files differdeleted file mode 100644 index 58867c7a6e1..00000000000 --- a/app/assets/images/emoji/department_store.png +++ /dev/null diff --git a/app/assets/images/emoji/desert.png b/app/assets/images/emoji/desert.png Binary files differdeleted file mode 100644 index e9966ff8c65..00000000000 --- a/app/assets/images/emoji/desert.png +++ /dev/null diff --git a/app/assets/images/emoji/desktop.png b/app/assets/images/emoji/desktop.png Binary files differdeleted file mode 100644 index 909bd42b5e1..00000000000 --- a/app/assets/images/emoji/desktop.png +++ /dev/null diff --git a/app/assets/images/emoji/diamond_shape_with_a_dot_inside.png b/app/assets/images/emoji/diamond_shape_with_a_dot_inside.png Binary files differdeleted file mode 100644 index 2a22a26d1e2..00000000000 --- a/app/assets/images/emoji/diamond_shape_with_a_dot_inside.png +++ /dev/null diff --git a/app/assets/images/emoji/diamonds.png b/app/assets/images/emoji/diamonds.png Binary files differdeleted file mode 100644 index 1f25f51f97a..00000000000 --- a/app/assets/images/emoji/diamonds.png +++ /dev/null diff --git a/app/assets/images/emoji/disappointed.png b/app/assets/images/emoji/disappointed.png Binary files differdeleted file mode 100644 index efe4e67e23c..00000000000 --- a/app/assets/images/emoji/disappointed.png +++ /dev/null diff --git a/app/assets/images/emoji/disappointed_relieved.png b/app/assets/images/emoji/disappointed_relieved.png Binary files differdeleted file mode 100644 index aef864d2b3d..00000000000 --- a/app/assets/images/emoji/disappointed_relieved.png +++ /dev/null diff --git a/app/assets/images/emoji/dividers.png b/app/assets/images/emoji/dividers.png Binary files differdeleted file mode 100644 index 46a7e403f9d..00000000000 --- a/app/assets/images/emoji/dividers.png +++ /dev/null diff --git a/app/assets/images/emoji/dizzy.png b/app/assets/images/emoji/dizzy.png Binary files differdeleted file mode 100644 index 85f52efad24..00000000000 --- a/app/assets/images/emoji/dizzy.png +++ /dev/null diff --git a/app/assets/images/emoji/dizzy_face.png b/app/assets/images/emoji/dizzy_face.png Binary files differdeleted file mode 100644 index 3120316ab5e..00000000000 --- a/app/assets/images/emoji/dizzy_face.png +++ /dev/null diff --git a/app/assets/images/emoji/do_not_litter.png b/app/assets/images/emoji/do_not_litter.png Binary files differdeleted file mode 100644 index 341d2575f4f..00000000000 --- a/app/assets/images/emoji/do_not_litter.png +++ /dev/null diff --git a/app/assets/images/emoji/dog.png b/app/assets/images/emoji/dog.png Binary files differdeleted file mode 100644 index 281b81d58bd..00000000000 --- a/app/assets/images/emoji/dog.png +++ /dev/null diff --git a/app/assets/images/emoji/dog2.png b/app/assets/images/emoji/dog2.png Binary files differdeleted file mode 100644 index 976143dbdbe..00000000000 --- a/app/assets/images/emoji/dog2.png +++ /dev/null diff --git a/app/assets/images/emoji/dollar.png b/app/assets/images/emoji/dollar.png Binary files differdeleted file mode 100644 index a9904c28293..00000000000 --- a/app/assets/images/emoji/dollar.png +++ /dev/null diff --git a/app/assets/images/emoji/dolls.png b/app/assets/images/emoji/dolls.png Binary files differdeleted file mode 100644 index 10955615110..00000000000 --- a/app/assets/images/emoji/dolls.png +++ /dev/null diff --git a/app/assets/images/emoji/dolphin.png b/app/assets/images/emoji/dolphin.png Binary files differdeleted file mode 100644 index 81434809003..00000000000 --- a/app/assets/images/emoji/dolphin.png +++ /dev/null diff --git a/app/assets/images/emoji/door.png b/app/assets/images/emoji/door.png Binary files differdeleted file mode 100644 index 36ae3e27494..00000000000 --- a/app/assets/images/emoji/door.png +++ /dev/null diff --git a/app/assets/images/emoji/doughnut.png b/app/assets/images/emoji/doughnut.png Binary files differdeleted file mode 100644 index 0ca4cd0bde8..00000000000 --- a/app/assets/images/emoji/doughnut.png +++ /dev/null diff --git a/app/assets/images/emoji/dove.png b/app/assets/images/emoji/dove.png Binary files differdeleted file mode 100644 index 9580c4917d7..00000000000 --- a/app/assets/images/emoji/dove.png +++ /dev/null diff --git a/app/assets/images/emoji/dragon.png b/app/assets/images/emoji/dragon.png Binary files differdeleted file mode 100644 index d6311cf5429..00000000000 --- a/app/assets/images/emoji/dragon.png +++ /dev/null diff --git a/app/assets/images/emoji/dragon_face.png b/app/assets/images/emoji/dragon_face.png Binary files differdeleted file mode 100644 index 3c2720446c6..00000000000 --- a/app/assets/images/emoji/dragon_face.png +++ /dev/null diff --git a/app/assets/images/emoji/dress.png b/app/assets/images/emoji/dress.png Binary files differdeleted file mode 100644 index a697ca5c57d..00000000000 --- a/app/assets/images/emoji/dress.png +++ /dev/null diff --git a/app/assets/images/emoji/dromedary_camel.png b/app/assets/images/emoji/dromedary_camel.png Binary files differdeleted file mode 100644 index 5271637c7c4..00000000000 --- a/app/assets/images/emoji/dromedary_camel.png +++ /dev/null diff --git a/app/assets/images/emoji/drooling_face.png b/app/assets/images/emoji/drooling_face.png Binary files differdeleted file mode 100644 index a5460532597..00000000000 --- a/app/assets/images/emoji/drooling_face.png +++ /dev/null diff --git a/app/assets/images/emoji/droplet.png b/app/assets/images/emoji/droplet.png Binary files differdeleted file mode 100644 index 71241ec3061..00000000000 --- a/app/assets/images/emoji/droplet.png +++ /dev/null diff --git a/app/assets/images/emoji/drum.png b/app/assets/images/emoji/drum.png Binary files differdeleted file mode 100644 index b038727cc99..00000000000 --- a/app/assets/images/emoji/drum.png +++ /dev/null diff --git a/app/assets/images/emoji/duck.png b/app/assets/images/emoji/duck.png Binary files differdeleted file mode 100644 index 74330b77ca3..00000000000 --- a/app/assets/images/emoji/duck.png +++ /dev/null diff --git a/app/assets/images/emoji/dvd.png b/app/assets/images/emoji/dvd.png Binary files differdeleted file mode 100644 index 045a6f7a08d..00000000000 --- a/app/assets/images/emoji/dvd.png +++ /dev/null diff --git a/app/assets/images/emoji/e-mail.png b/app/assets/images/emoji/e-mail.png Binary files differdeleted file mode 100644 index d22e654a20b..00000000000 --- a/app/assets/images/emoji/e-mail.png +++ /dev/null diff --git a/app/assets/images/emoji/eagle.png b/app/assets/images/emoji/eagle.png Binary files differdeleted file mode 100644 index 4f277debeef..00000000000 --- a/app/assets/images/emoji/eagle.png +++ /dev/null diff --git a/app/assets/images/emoji/ear.png b/app/assets/images/emoji/ear.png Binary files differdeleted file mode 100644 index f84f9ff154a..00000000000 --- a/app/assets/images/emoji/ear.png +++ /dev/null diff --git a/app/assets/images/emoji/ear_of_rice.png b/app/assets/images/emoji/ear_of_rice.png Binary files differdeleted file mode 100644 index 3564d9d643a..00000000000 --- a/app/assets/images/emoji/ear_of_rice.png +++ /dev/null diff --git a/app/assets/images/emoji/ear_tone1.png b/app/assets/images/emoji/ear_tone1.png Binary files differdeleted file mode 100644 index d09e1e41996..00000000000 --- a/app/assets/images/emoji/ear_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/ear_tone2.png b/app/assets/images/emoji/ear_tone2.png Binary files differdeleted file mode 100644 index 300d60a9948..00000000000 --- a/app/assets/images/emoji/ear_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/ear_tone3.png b/app/assets/images/emoji/ear_tone3.png Binary files differdeleted file mode 100644 index 2a56eebe445..00000000000 --- a/app/assets/images/emoji/ear_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/ear_tone4.png b/app/assets/images/emoji/ear_tone4.png Binary files differdeleted file mode 100644 index bd270f7763e..00000000000 --- a/app/assets/images/emoji/ear_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/ear_tone5.png b/app/assets/images/emoji/ear_tone5.png Binary files differdeleted file mode 100644 index b96bb441dff..00000000000 --- a/app/assets/images/emoji/ear_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/earth_africa.png b/app/assets/images/emoji/earth_africa.png Binary files differdeleted file mode 100644 index 66c3348c23a..00000000000 --- a/app/assets/images/emoji/earth_africa.png +++ /dev/null diff --git a/app/assets/images/emoji/earth_americas.png b/app/assets/images/emoji/earth_americas.png Binary files differdeleted file mode 100644 index 538c3cddd68..00000000000 --- a/app/assets/images/emoji/earth_americas.png +++ /dev/null diff --git a/app/assets/images/emoji/earth_asia.png b/app/assets/images/emoji/earth_asia.png Binary files differdeleted file mode 100644 index d8df97fec3c..00000000000 --- a/app/assets/images/emoji/earth_asia.png +++ /dev/null diff --git a/app/assets/images/emoji/egg.png b/app/assets/images/emoji/egg.png Binary files differdeleted file mode 100644 index c171974d993..00000000000 --- a/app/assets/images/emoji/egg.png +++ /dev/null diff --git a/app/assets/images/emoji/eggplant.png b/app/assets/images/emoji/eggplant.png Binary files differdeleted file mode 100644 index fafd7c1a14c..00000000000 --- a/app/assets/images/emoji/eggplant.png +++ /dev/null diff --git a/app/assets/images/emoji/eight.png b/app/assets/images/emoji/eight.png Binary files differdeleted file mode 100644 index 8c95874d4c5..00000000000 --- a/app/assets/images/emoji/eight.png +++ /dev/null diff --git a/app/assets/images/emoji/eight_pointed_black_star.png b/app/assets/images/emoji/eight_pointed_black_star.png Binary files differdeleted file mode 100644 index 820179bda50..00000000000 --- a/app/assets/images/emoji/eight_pointed_black_star.png +++ /dev/null diff --git a/app/assets/images/emoji/eight_spoked_asterisk.png b/app/assets/images/emoji/eight_spoked_asterisk.png Binary files differdeleted file mode 100644 index 3307ffa62ee..00000000000 --- a/app/assets/images/emoji/eight_spoked_asterisk.png +++ /dev/null diff --git a/app/assets/images/emoji/eject.png b/app/assets/images/emoji/eject.png Binary files differdeleted file mode 100644 index ec5cfc48973..00000000000 --- a/app/assets/images/emoji/eject.png +++ /dev/null diff --git a/app/assets/images/emoji/electric_plug.png b/app/assets/images/emoji/electric_plug.png Binary files differdeleted file mode 100644 index 31d1eb215b4..00000000000 --- a/app/assets/images/emoji/electric_plug.png +++ /dev/null diff --git a/app/assets/images/emoji/elephant.png b/app/assets/images/emoji/elephant.png Binary files differdeleted file mode 100644 index b8a6d140595..00000000000 --- a/app/assets/images/emoji/elephant.png +++ /dev/null diff --git a/app/assets/images/emoji/end.png b/app/assets/images/emoji/end.png Binary files differdeleted file mode 100644 index ef3ccd5f367..00000000000 --- a/app/assets/images/emoji/end.png +++ /dev/null diff --git a/app/assets/images/emoji/envelope.png b/app/assets/images/emoji/envelope.png Binary files differdeleted file mode 100644 index ec77ac375a4..00000000000 --- a/app/assets/images/emoji/envelope.png +++ /dev/null diff --git a/app/assets/images/emoji/envelope_with_arrow.png b/app/assets/images/emoji/envelope_with_arrow.png Binary files differdeleted file mode 100644 index 7448a6b7673..00000000000 --- a/app/assets/images/emoji/envelope_with_arrow.png +++ /dev/null diff --git a/app/assets/images/emoji/euro.png b/app/assets/images/emoji/euro.png Binary files differdeleted file mode 100644 index a49020820e1..00000000000 --- a/app/assets/images/emoji/euro.png +++ /dev/null diff --git a/app/assets/images/emoji/european_castle.png b/app/assets/images/emoji/european_castle.png Binary files differdeleted file mode 100644 index 888d11332ce..00000000000 --- a/app/assets/images/emoji/european_castle.png +++ /dev/null diff --git a/app/assets/images/emoji/european_post_office.png b/app/assets/images/emoji/european_post_office.png Binary files differdeleted file mode 100644 index 3745aff8dd2..00000000000 --- a/app/assets/images/emoji/european_post_office.png +++ /dev/null diff --git a/app/assets/images/emoji/evergreen_tree.png b/app/assets/images/emoji/evergreen_tree.png Binary files differdeleted file mode 100644 index f679d8dd772..00000000000 --- a/app/assets/images/emoji/evergreen_tree.png +++ /dev/null diff --git a/app/assets/images/emoji/exclamation.png b/app/assets/images/emoji/exclamation.png Binary files differdeleted file mode 100644 index 2c14406422f..00000000000 --- a/app/assets/images/emoji/exclamation.png +++ /dev/null diff --git a/app/assets/images/emoji/expressionless.png b/app/assets/images/emoji/expressionless.png Binary files differdeleted file mode 100644 index 2954017f6c2..00000000000 --- a/app/assets/images/emoji/expressionless.png +++ /dev/null diff --git a/app/assets/images/emoji/eye.png b/app/assets/images/emoji/eye.png Binary files differdeleted file mode 100644 index 9d989cdd375..00000000000 --- a/app/assets/images/emoji/eye.png +++ /dev/null diff --git a/app/assets/images/emoji/eye_in_speech_bubble.png b/app/assets/images/emoji/eye_in_speech_bubble.png Binary files differdeleted file mode 100644 index 21bd22bbcce..00000000000 --- a/app/assets/images/emoji/eye_in_speech_bubble.png +++ /dev/null diff --git a/app/assets/images/emoji/eyeglasses.png b/app/assets/images/emoji/eyeglasses.png Binary files differdeleted file mode 100644 index 865d8274acf..00000000000 --- a/app/assets/images/emoji/eyeglasses.png +++ /dev/null diff --git a/app/assets/images/emoji/eyes.png b/app/assets/images/emoji/eyes.png Binary files differdeleted file mode 100644 index 2102ada7e09..00000000000 --- a/app/assets/images/emoji/eyes.png +++ /dev/null diff --git a/app/assets/images/emoji/face_palm.png b/app/assets/images/emoji/face_palm.png Binary files differdeleted file mode 100644 index defc796cf16..00000000000 --- a/app/assets/images/emoji/face_palm.png +++ /dev/null diff --git a/app/assets/images/emoji/face_palm_tone1.png b/app/assets/images/emoji/face_palm_tone1.png Binary files differdeleted file mode 100644 index 2f4b010bb40..00000000000 --- a/app/assets/images/emoji/face_palm_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/face_palm_tone2.png b/app/assets/images/emoji/face_palm_tone2.png Binary files differdeleted file mode 100644 index 97fb6831687..00000000000 --- a/app/assets/images/emoji/face_palm_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/face_palm_tone3.png b/app/assets/images/emoji/face_palm_tone3.png Binary files differdeleted file mode 100644 index b5b5c1e5306..00000000000 --- a/app/assets/images/emoji/face_palm_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/face_palm_tone4.png b/app/assets/images/emoji/face_palm_tone4.png Binary files differdeleted file mode 100644 index 2840b113483..00000000000 --- a/app/assets/images/emoji/face_palm_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/face_palm_tone5.png b/app/assets/images/emoji/face_palm_tone5.png Binary files differdeleted file mode 100644 index 6f070db98be..00000000000 --- a/app/assets/images/emoji/face_palm_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/factory.png b/app/assets/images/emoji/factory.png Binary files differdeleted file mode 100644 index e1d2ddf4a27..00000000000 --- a/app/assets/images/emoji/factory.png +++ /dev/null diff --git a/app/assets/images/emoji/fallen_leaf.png b/app/assets/images/emoji/fallen_leaf.png Binary files differdeleted file mode 100644 index 0d60e7bdf2d..00000000000 --- a/app/assets/images/emoji/fallen_leaf.png +++ /dev/null diff --git a/app/assets/images/emoji/family.png b/app/assets/images/emoji/family.png Binary files differdeleted file mode 100644 index 26421965791..00000000000 --- a/app/assets/images/emoji/family.png +++ /dev/null diff --git a/app/assets/images/emoji/family_mmb.png b/app/assets/images/emoji/family_mmb.png Binary files differdeleted file mode 100644 index 7a2e4e2c491..00000000000 --- a/app/assets/images/emoji/family_mmb.png +++ /dev/null diff --git a/app/assets/images/emoji/family_mmbb.png b/app/assets/images/emoji/family_mmbb.png Binary files differdeleted file mode 100644 index 81e6c0fc0ee..00000000000 --- a/app/assets/images/emoji/family_mmbb.png +++ /dev/null diff --git a/app/assets/images/emoji/family_mmg.png b/app/assets/images/emoji/family_mmg.png Binary files differdeleted file mode 100644 index 932a85e1fe5..00000000000 --- a/app/assets/images/emoji/family_mmg.png +++ /dev/null diff --git a/app/assets/images/emoji/family_mmgb.png b/app/assets/images/emoji/family_mmgb.png Binary files differdeleted file mode 100644 index 41e35166670..00000000000 --- a/app/assets/images/emoji/family_mmgb.png +++ /dev/null diff --git a/app/assets/images/emoji/family_mmgg.png b/app/assets/images/emoji/family_mmgg.png Binary files differdeleted file mode 100644 index 8e8ccfe6c7f..00000000000 --- a/app/assets/images/emoji/family_mmgg.png +++ /dev/null diff --git a/app/assets/images/emoji/family_mwbb.png b/app/assets/images/emoji/family_mwbb.png Binary files differdeleted file mode 100644 index b544fbe573f..00000000000 --- a/app/assets/images/emoji/family_mwbb.png +++ /dev/null diff --git a/app/assets/images/emoji/family_mwg.png b/app/assets/images/emoji/family_mwg.png Binary files differdeleted file mode 100644 index 71d2681c32a..00000000000 --- a/app/assets/images/emoji/family_mwg.png +++ /dev/null diff --git a/app/assets/images/emoji/family_mwgb.png b/app/assets/images/emoji/family_mwgb.png Binary files differdeleted file mode 100644 index 40dbf1f7a18..00000000000 --- a/app/assets/images/emoji/family_mwgb.png +++ /dev/null diff --git a/app/assets/images/emoji/family_mwgg.png b/app/assets/images/emoji/family_mwgg.png Binary files differdeleted file mode 100644 index bfefa4879cb..00000000000 --- a/app/assets/images/emoji/family_mwgg.png +++ /dev/null diff --git a/app/assets/images/emoji/family_wwb.png b/app/assets/images/emoji/family_wwb.png Binary files differdeleted file mode 100644 index 836feae7c78..00000000000 --- a/app/assets/images/emoji/family_wwb.png +++ /dev/null diff --git a/app/assets/images/emoji/family_wwbb.png b/app/assets/images/emoji/family_wwbb.png Binary files differdeleted file mode 100644 index 6c6ba45e7bb..00000000000 --- a/app/assets/images/emoji/family_wwbb.png +++ /dev/null diff --git a/app/assets/images/emoji/family_wwg.png b/app/assets/images/emoji/family_wwg.png Binary files differdeleted file mode 100644 index 41225c6fa5a..00000000000 --- a/app/assets/images/emoji/family_wwg.png +++ /dev/null diff --git a/app/assets/images/emoji/family_wwgb.png b/app/assets/images/emoji/family_wwgb.png Binary files differdeleted file mode 100644 index 284d29ab5da..00000000000 --- a/app/assets/images/emoji/family_wwgb.png +++ /dev/null diff --git a/app/assets/images/emoji/family_wwgg.png b/app/assets/images/emoji/family_wwgg.png Binary files differdeleted file mode 100644 index d8d3f49b85f..00000000000 --- a/app/assets/images/emoji/family_wwgg.png +++ /dev/null diff --git a/app/assets/images/emoji/fast_forward.png b/app/assets/images/emoji/fast_forward.png Binary files differdeleted file mode 100644 index c406fedfdb1..00000000000 --- a/app/assets/images/emoji/fast_forward.png +++ /dev/null diff --git a/app/assets/images/emoji/fax.png b/app/assets/images/emoji/fax.png Binary files differdeleted file mode 100644 index 6f929e294c2..00000000000 --- a/app/assets/images/emoji/fax.png +++ /dev/null diff --git a/app/assets/images/emoji/fearful.png b/app/assets/images/emoji/fearful.png Binary files differdeleted file mode 100644 index eb8b347cef9..00000000000 --- a/app/assets/images/emoji/fearful.png +++ /dev/null diff --git a/app/assets/images/emoji/feet.png b/app/assets/images/emoji/feet.png Binary files differdeleted file mode 100644 index 5fe568cee93..00000000000 --- a/app/assets/images/emoji/feet.png +++ /dev/null diff --git a/app/assets/images/emoji/fencer.png b/app/assets/images/emoji/fencer.png Binary files differdeleted file mode 100644 index 5288c920eb9..00000000000 --- a/app/assets/images/emoji/fencer.png +++ /dev/null diff --git a/app/assets/images/emoji/ferris_wheel.png b/app/assets/images/emoji/ferris_wheel.png Binary files differdeleted file mode 100644 index 55c8ff0475b..00000000000 --- a/app/assets/images/emoji/ferris_wheel.png +++ /dev/null diff --git a/app/assets/images/emoji/ferry.png b/app/assets/images/emoji/ferry.png Binary files differdeleted file mode 100644 index 41816b3ae34..00000000000 --- a/app/assets/images/emoji/ferry.png +++ /dev/null diff --git a/app/assets/images/emoji/field_hockey.png b/app/assets/images/emoji/field_hockey.png Binary files differdeleted file mode 100644 index 839637716ee..00000000000 --- a/app/assets/images/emoji/field_hockey.png +++ /dev/null diff --git a/app/assets/images/emoji/file_cabinet.png b/app/assets/images/emoji/file_cabinet.png Binary files differdeleted file mode 100644 index fddc65dde96..00000000000 --- a/app/assets/images/emoji/file_cabinet.png +++ /dev/null diff --git a/app/assets/images/emoji/file_folder.png b/app/assets/images/emoji/file_folder.png Binary files differdeleted file mode 100644 index addedaf0870..00000000000 --- a/app/assets/images/emoji/file_folder.png +++ /dev/null diff --git a/app/assets/images/emoji/film_frames.png b/app/assets/images/emoji/film_frames.png Binary files differdeleted file mode 100644 index 30143aedbe6..00000000000 --- a/app/assets/images/emoji/film_frames.png +++ /dev/null diff --git a/app/assets/images/emoji/fingers_crossed.png b/app/assets/images/emoji/fingers_crossed.png Binary files differdeleted file mode 100644 index 4cd18514ea3..00000000000 --- a/app/assets/images/emoji/fingers_crossed.png +++ /dev/null diff --git a/app/assets/images/emoji/fingers_crossed_tone1.png b/app/assets/images/emoji/fingers_crossed_tone1.png Binary files differdeleted file mode 100644 index dd2384a6cd5..00000000000 --- a/app/assets/images/emoji/fingers_crossed_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/fingers_crossed_tone2.png b/app/assets/images/emoji/fingers_crossed_tone2.png Binary files differdeleted file mode 100644 index 6228401befe..00000000000 --- a/app/assets/images/emoji/fingers_crossed_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/fingers_crossed_tone3.png b/app/assets/images/emoji/fingers_crossed_tone3.png Binary files differdeleted file mode 100644 index b1074da15f5..00000000000 --- a/app/assets/images/emoji/fingers_crossed_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/fingers_crossed_tone4.png b/app/assets/images/emoji/fingers_crossed_tone4.png Binary files differdeleted file mode 100644 index 75e05e4d332..00000000000 --- a/app/assets/images/emoji/fingers_crossed_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/fingers_crossed_tone5.png b/app/assets/images/emoji/fingers_crossed_tone5.png Binary files differdeleted file mode 100644 index 761aebdc30f..00000000000 --- a/app/assets/images/emoji/fingers_crossed_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/fire.png b/app/assets/images/emoji/fire.png Binary files differdeleted file mode 100644 index bd3775a460b..00000000000 --- a/app/assets/images/emoji/fire.png +++ /dev/null diff --git a/app/assets/images/emoji/fire_engine.png b/app/assets/images/emoji/fire_engine.png Binary files differdeleted file mode 100644 index 2cd45b7cf7e..00000000000 --- a/app/assets/images/emoji/fire_engine.png +++ /dev/null diff --git a/app/assets/images/emoji/fireworks.png b/app/assets/images/emoji/fireworks.png Binary files differdeleted file mode 100644 index 176c8b58265..00000000000 --- a/app/assets/images/emoji/fireworks.png +++ /dev/null diff --git a/app/assets/images/emoji/first_place.png b/app/assets/images/emoji/first_place.png Binary files differdeleted file mode 100644 index 15612b66492..00000000000 --- a/app/assets/images/emoji/first_place.png +++ /dev/null diff --git a/app/assets/images/emoji/first_quarter_moon.png b/app/assets/images/emoji/first_quarter_moon.png Binary files differdeleted file mode 100644 index 5dccaf72a4f..00000000000 --- a/app/assets/images/emoji/first_quarter_moon.png +++ /dev/null diff --git a/app/assets/images/emoji/first_quarter_moon_with_face.png b/app/assets/images/emoji/first_quarter_moon_with_face.png Binary files differdeleted file mode 100644 index cd8a3d7acd8..00000000000 --- a/app/assets/images/emoji/first_quarter_moon_with_face.png +++ /dev/null diff --git a/app/assets/images/emoji/fish.png b/app/assets/images/emoji/fish.png Binary files differdeleted file mode 100644 index c2d2faaacd4..00000000000 --- a/app/assets/images/emoji/fish.png +++ /dev/null diff --git a/app/assets/images/emoji/fish_cake.png b/app/assets/images/emoji/fish_cake.png Binary files differdeleted file mode 100644 index 157bded65db..00000000000 --- a/app/assets/images/emoji/fish_cake.png +++ /dev/null diff --git a/app/assets/images/emoji/fishing_pole_and_fish.png b/app/assets/images/emoji/fishing_pole_and_fish.png Binary files differdeleted file mode 100644 index dfcdf07eb50..00000000000 --- a/app/assets/images/emoji/fishing_pole_and_fish.png +++ /dev/null diff --git a/app/assets/images/emoji/fist.png b/app/assets/images/emoji/fist.png Binary files differdeleted file mode 100644 index de33592bf98..00000000000 --- a/app/assets/images/emoji/fist.png +++ /dev/null diff --git a/app/assets/images/emoji/fist_tone1.png b/app/assets/images/emoji/fist_tone1.png Binary files differdeleted file mode 100644 index 02809e2dd68..00000000000 --- a/app/assets/images/emoji/fist_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/fist_tone2.png b/app/assets/images/emoji/fist_tone2.png Binary files differdeleted file mode 100644 index 5de34810383..00000000000 --- a/app/assets/images/emoji/fist_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/fist_tone3.png b/app/assets/images/emoji/fist_tone3.png Binary files differdeleted file mode 100644 index 0d5240129b1..00000000000 --- a/app/assets/images/emoji/fist_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/fist_tone4.png b/app/assets/images/emoji/fist_tone4.png Binary files differdeleted file mode 100644 index a95c0dd634b..00000000000 --- a/app/assets/images/emoji/fist_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/fist_tone5.png b/app/assets/images/emoji/fist_tone5.png Binary files differdeleted file mode 100644 index a2f092fd8c7..00000000000 --- a/app/assets/images/emoji/fist_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/five.png b/app/assets/images/emoji/five.png Binary files differdeleted file mode 100644 index d14371f3f27..00000000000 --- a/app/assets/images/emoji/five.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ac.png b/app/assets/images/emoji/flag_ac.png Binary files differdeleted file mode 100644 index 286239920c7..00000000000 --- a/app/assets/images/emoji/flag_ac.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ad.png b/app/assets/images/emoji/flag_ad.png Binary files differdeleted file mode 100644 index 20f4b14e8ad..00000000000 --- a/app/assets/images/emoji/flag_ad.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ae.png b/app/assets/images/emoji/flag_ae.png Binary files differdeleted file mode 100644 index d16ffe4b862..00000000000 --- a/app/assets/images/emoji/flag_ae.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_af.png b/app/assets/images/emoji/flag_af.png Binary files differdeleted file mode 100644 index a51533b554d..00000000000 --- a/app/assets/images/emoji/flag_af.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ag.png b/app/assets/images/emoji/flag_ag.png Binary files differdeleted file mode 100644 index 07f2ce397d0..00000000000 --- a/app/assets/images/emoji/flag_ag.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ai.png b/app/assets/images/emoji/flag_ai.png Binary files differdeleted file mode 100644 index 500b5ab09fb..00000000000 --- a/app/assets/images/emoji/flag_ai.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_al.png b/app/assets/images/emoji/flag_al.png Binary files differdeleted file mode 100644 index 03a20132cc6..00000000000 --- a/app/assets/images/emoji/flag_al.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_am.png b/app/assets/images/emoji/flag_am.png Binary files differdeleted file mode 100644 index 2ad60a273ec..00000000000 --- a/app/assets/images/emoji/flag_am.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ao.png b/app/assets/images/emoji/flag_ao.png Binary files differdeleted file mode 100644 index cb46c31f862..00000000000 --- a/app/assets/images/emoji/flag_ao.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_aq.png b/app/assets/images/emoji/flag_aq.png Binary files differdeleted file mode 100644 index b272021d375..00000000000 --- a/app/assets/images/emoji/flag_aq.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ar.png b/app/assets/images/emoji/flag_ar.png Binary files differdeleted file mode 100644 index 73136caf3b7..00000000000 --- a/app/assets/images/emoji/flag_ar.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_as.png b/app/assets/images/emoji/flag_as.png Binary files differdeleted file mode 100644 index 3db45a0d9f3..00000000000 --- a/app/assets/images/emoji/flag_as.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_at.png b/app/assets/images/emoji/flag_at.png Binary files differdeleted file mode 100644 index c43769dcb19..00000000000 --- a/app/assets/images/emoji/flag_at.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_au.png b/app/assets/images/emoji/flag_au.png Binary files differdeleted file mode 100644 index 7794309c78c..00000000000 --- a/app/assets/images/emoji/flag_au.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_aw.png b/app/assets/images/emoji/flag_aw.png Binary files differdeleted file mode 100644 index 02c840d12c9..00000000000 --- a/app/assets/images/emoji/flag_aw.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ax.png b/app/assets/images/emoji/flag_ax.png Binary files differdeleted file mode 100644 index fc5466174bb..00000000000 --- a/app/assets/images/emoji/flag_ax.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_az.png b/app/assets/images/emoji/flag_az.png Binary files differdeleted file mode 100644 index 89d3d15fd9f..00000000000 --- a/app/assets/images/emoji/flag_az.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ba.png b/app/assets/images/emoji/flag_ba.png Binary files differdeleted file mode 100644 index 25fe407e13c..00000000000 --- a/app/assets/images/emoji/flag_ba.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bb.png b/app/assets/images/emoji/flag_bb.png Binary files differdeleted file mode 100644 index bccd8c5c9b0..00000000000 --- a/app/assets/images/emoji/flag_bb.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bd.png b/app/assets/images/emoji/flag_bd.png Binary files differdeleted file mode 100644 index b0597a3149b..00000000000 --- a/app/assets/images/emoji/flag_bd.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_be.png b/app/assets/images/emoji/flag_be.png Binary files differdeleted file mode 100644 index 551f086e3c4..00000000000 --- a/app/assets/images/emoji/flag_be.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bf.png b/app/assets/images/emoji/flag_bf.png Binary files differdeleted file mode 100644 index 444d4829f94..00000000000 --- a/app/assets/images/emoji/flag_bf.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bg.png b/app/assets/images/emoji/flag_bg.png Binary files differdeleted file mode 100644 index 821eee5e170..00000000000 --- a/app/assets/images/emoji/flag_bg.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bh.png b/app/assets/images/emoji/flag_bh.png Binary files differdeleted file mode 100644 index f33724249f0..00000000000 --- a/app/assets/images/emoji/flag_bh.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bi.png b/app/assets/images/emoji/flag_bi.png Binary files differdeleted file mode 100644 index ea20ac93211..00000000000 --- a/app/assets/images/emoji/flag_bi.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bj.png b/app/assets/images/emoji/flag_bj.png Binary files differdeleted file mode 100644 index 7cca4f80457..00000000000 --- a/app/assets/images/emoji/flag_bj.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bl.png b/app/assets/images/emoji/flag_bl.png Binary files differdeleted file mode 100644 index 1082e78999f..00000000000 --- a/app/assets/images/emoji/flag_bl.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_black.png b/app/assets/images/emoji/flag_black.png Binary files differdeleted file mode 100644 index 0e28d05d5ac..00000000000 --- a/app/assets/images/emoji/flag_black.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bm.png b/app/assets/images/emoji/flag_bm.png Binary files differdeleted file mode 100644 index ab8cafdac63..00000000000 --- a/app/assets/images/emoji/flag_bm.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bn.png b/app/assets/images/emoji/flag_bn.png Binary files differdeleted file mode 100644 index caa9329a896..00000000000 --- a/app/assets/images/emoji/flag_bn.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bo.png b/app/assets/images/emoji/flag_bo.png Binary files differdeleted file mode 100644 index 98af62b3da7..00000000000 --- a/app/assets/images/emoji/flag_bo.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bq.png b/app/assets/images/emoji/flag_bq.png Binary files differdeleted file mode 100644 index cb978ef9de9..00000000000 --- a/app/assets/images/emoji/flag_bq.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_br.png b/app/assets/images/emoji/flag_br.png Binary files differdeleted file mode 100644 index b139366a42b..00000000000 --- a/app/assets/images/emoji/flag_br.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bs.png b/app/assets/images/emoji/flag_bs.png Binary files differdeleted file mode 100644 index d36bcd2fb52..00000000000 --- a/app/assets/images/emoji/flag_bs.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bt.png b/app/assets/images/emoji/flag_bt.png Binary files differdeleted file mode 100644 index ed57aa0360e..00000000000 --- a/app/assets/images/emoji/flag_bt.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bv.png b/app/assets/images/emoji/flag_bv.png Binary files differdeleted file mode 100644 index 5884e648228..00000000000 --- a/app/assets/images/emoji/flag_bv.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bw.png b/app/assets/images/emoji/flag_bw.png Binary files differdeleted file mode 100644 index cb12f34739d..00000000000 --- a/app/assets/images/emoji/flag_bw.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_by.png b/app/assets/images/emoji/flag_by.png Binary files differdeleted file mode 100644 index 859c05beb13..00000000000 --- a/app/assets/images/emoji/flag_by.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_bz.png b/app/assets/images/emoji/flag_bz.png Binary files differdeleted file mode 100644 index 34761cd03d8..00000000000 --- a/app/assets/images/emoji/flag_bz.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ca.png b/app/assets/images/emoji/flag_ca.png Binary files differdeleted file mode 100644 index 7c5b390e85b..00000000000 --- a/app/assets/images/emoji/flag_ca.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_cc.png b/app/assets/images/emoji/flag_cc.png Binary files differdeleted file mode 100644 index b6555a23d83..00000000000 --- a/app/assets/images/emoji/flag_cc.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_cd.png b/app/assets/images/emoji/flag_cd.png Binary files differdeleted file mode 100644 index fa92009771d..00000000000 --- a/app/assets/images/emoji/flag_cd.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_cf.png b/app/assets/images/emoji/flag_cf.png Binary files differdeleted file mode 100644 index b969ae29ea9..00000000000 --- a/app/assets/images/emoji/flag_cf.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_cg.png b/app/assets/images/emoji/flag_cg.png Binary files differdeleted file mode 100644 index 3a38a40a95e..00000000000 --- a/app/assets/images/emoji/flag_cg.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ch.png b/app/assets/images/emoji/flag_ch.png Binary files differdeleted file mode 100644 index 5ff86b8a3b7..00000000000 --- a/app/assets/images/emoji/flag_ch.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ci.png b/app/assets/images/emoji/flag_ci.png Binary files differdeleted file mode 100644 index e3b4d15c7f1..00000000000 --- a/app/assets/images/emoji/flag_ci.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ck.png b/app/assets/images/emoji/flag_ck.png Binary files differdeleted file mode 100644 index b6b53dbc1c4..00000000000 --- a/app/assets/images/emoji/flag_ck.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_cl.png b/app/assets/images/emoji/flag_cl.png Binary files differdeleted file mode 100644 index c9390da5499..00000000000 --- a/app/assets/images/emoji/flag_cl.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_cm.png b/app/assets/images/emoji/flag_cm.png Binary files differdeleted file mode 100644 index 2d3f6ec4518..00000000000 --- a/app/assets/images/emoji/flag_cm.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_cn.png b/app/assets/images/emoji/flag_cn.png Binary files differdeleted file mode 100644 index 0a7f350a6d2..00000000000 --- a/app/assets/images/emoji/flag_cn.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_co.png b/app/assets/images/emoji/flag_co.png Binary files differdeleted file mode 100644 index 7e0f5e0dc3c..00000000000 --- a/app/assets/images/emoji/flag_co.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_cp.png b/app/assets/images/emoji/flag_cp.png Binary files differdeleted file mode 100644 index 70c761036bd..00000000000 --- a/app/assets/images/emoji/flag_cp.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_cr.png b/app/assets/images/emoji/flag_cr.png Binary files differdeleted file mode 100644 index a5fce126515..00000000000 --- a/app/assets/images/emoji/flag_cr.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_cu.png b/app/assets/images/emoji/flag_cu.png Binary files differdeleted file mode 100644 index 447328f7dfd..00000000000 --- a/app/assets/images/emoji/flag_cu.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_cv.png b/app/assets/images/emoji/flag_cv.png Binary files differdeleted file mode 100644 index 43faf4d64d5..00000000000 --- a/app/assets/images/emoji/flag_cv.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_cw.png b/app/assets/images/emoji/flag_cw.png Binary files differdeleted file mode 100644 index eb39e8d0078..00000000000 --- a/app/assets/images/emoji/flag_cw.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_cx.png b/app/assets/images/emoji/flag_cx.png Binary files differdeleted file mode 100644 index 09d21359f3a..00000000000 --- a/app/assets/images/emoji/flag_cx.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_cy.png b/app/assets/images/emoji/flag_cy.png Binary files differdeleted file mode 100644 index 154a7aa3176..00000000000 --- a/app/assets/images/emoji/flag_cy.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_cz.png b/app/assets/images/emoji/flag_cz.png Binary files differdeleted file mode 100644 index 9737ca223c7..00000000000 --- a/app/assets/images/emoji/flag_cz.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_de.png b/app/assets/images/emoji/flag_de.png Binary files differdeleted file mode 100644 index 98ed76b3bab..00000000000 --- a/app/assets/images/emoji/flag_de.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_dg.png b/app/assets/images/emoji/flag_dg.png Binary files differdeleted file mode 100644 index aae927d14b8..00000000000 --- a/app/assets/images/emoji/flag_dg.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_dj.png b/app/assets/images/emoji/flag_dj.png Binary files differdeleted file mode 100644 index 73c2a2acbd9..00000000000 --- a/app/assets/images/emoji/flag_dj.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_dk.png b/app/assets/images/emoji/flag_dk.png Binary files differdeleted file mode 100644 index e5a60b06256..00000000000 --- a/app/assets/images/emoji/flag_dk.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_dm.png b/app/assets/images/emoji/flag_dm.png Binary files differdeleted file mode 100644 index 50f8a53981d..00000000000 --- a/app/assets/images/emoji/flag_dm.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_do.png b/app/assets/images/emoji/flag_do.png Binary files differdeleted file mode 100644 index 037a45d7c26..00000000000 --- a/app/assets/images/emoji/flag_do.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_dz.png b/app/assets/images/emoji/flag_dz.png Binary files differdeleted file mode 100644 index 24945b10f2d..00000000000 --- a/app/assets/images/emoji/flag_dz.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ea.png b/app/assets/images/emoji/flag_ea.png Binary files differdeleted file mode 100644 index 356ff347838..00000000000 --- a/app/assets/images/emoji/flag_ea.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ec.png b/app/assets/images/emoji/flag_ec.png Binary files differdeleted file mode 100644 index 13814594619..00000000000 --- a/app/assets/images/emoji/flag_ec.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ee.png b/app/assets/images/emoji/flag_ee.png Binary files differdeleted file mode 100644 index 84f317e7747..00000000000 --- a/app/assets/images/emoji/flag_ee.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_eg.png b/app/assets/images/emoji/flag_eg.png Binary files differdeleted file mode 100644 index 57786064a95..00000000000 --- a/app/assets/images/emoji/flag_eg.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_eh.png b/app/assets/images/emoji/flag_eh.png Binary files differdeleted file mode 100644 index 4d7a76687f6..00000000000 --- a/app/assets/images/emoji/flag_eh.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_er.png b/app/assets/images/emoji/flag_er.png Binary files differdeleted file mode 100644 index 0c3c724c1fb..00000000000 --- a/app/assets/images/emoji/flag_er.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_es.png b/app/assets/images/emoji/flag_es.png Binary files differdeleted file mode 100644 index 3e73597a225..00000000000 --- a/app/assets/images/emoji/flag_es.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_et.png b/app/assets/images/emoji/flag_et.png Binary files differdeleted file mode 100644 index 9560a134c97..00000000000 --- a/app/assets/images/emoji/flag_et.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_eu.png b/app/assets/images/emoji/flag_eu.png Binary files differdeleted file mode 100644 index 0b456cf3330..00000000000 --- a/app/assets/images/emoji/flag_eu.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_fi.png b/app/assets/images/emoji/flag_fi.png Binary files differdeleted file mode 100644 index ebcf58abfc5..00000000000 --- a/app/assets/images/emoji/flag_fi.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_fj.png b/app/assets/images/emoji/flag_fj.png Binary files differdeleted file mode 100644 index 9cc8c37fe37..00000000000 --- a/app/assets/images/emoji/flag_fj.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_fk.png b/app/assets/images/emoji/flag_fk.png Binary files differdeleted file mode 100644 index 61372fd2549..00000000000 --- a/app/assets/images/emoji/flag_fk.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_fm.png b/app/assets/images/emoji/flag_fm.png Binary files differdeleted file mode 100644 index 0889825c8e1..00000000000 --- a/app/assets/images/emoji/flag_fm.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_fo.png b/app/assets/images/emoji/flag_fo.png Binary files differdeleted file mode 100644 index 9a4431b0831..00000000000 --- a/app/assets/images/emoji/flag_fo.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_fr.png b/app/assets/images/emoji/flag_fr.png Binary files differdeleted file mode 100644 index 62ca19c3fcf..00000000000 --- a/app/assets/images/emoji/flag_fr.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ga.png b/app/assets/images/emoji/flag_ga.png Binary files differdeleted file mode 100644 index 2e68e527a3e..00000000000 --- a/app/assets/images/emoji/flag_ga.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gb.png b/app/assets/images/emoji/flag_gb.png Binary files differdeleted file mode 100644 index 3ed10f62347..00000000000 --- a/app/assets/images/emoji/flag_gb.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gd.png b/app/assets/images/emoji/flag_gd.png Binary files differdeleted file mode 100644 index 527aad33807..00000000000 --- a/app/assets/images/emoji/flag_gd.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ge.png b/app/assets/images/emoji/flag_ge.png Binary files differdeleted file mode 100644 index a75d142480d..00000000000 --- a/app/assets/images/emoji/flag_ge.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gf.png b/app/assets/images/emoji/flag_gf.png Binary files differdeleted file mode 100644 index 0cf96f327c0..00000000000 --- a/app/assets/images/emoji/flag_gf.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gg.png b/app/assets/images/emoji/flag_gg.png Binary files differdeleted file mode 100644 index 970002c7f76..00000000000 --- a/app/assets/images/emoji/flag_gg.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gh.png b/app/assets/images/emoji/flag_gh.png Binary files differdeleted file mode 100644 index f31b5eb7b45..00000000000 --- a/app/assets/images/emoji/flag_gh.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gi.png b/app/assets/images/emoji/flag_gi.png Binary files differdeleted file mode 100644 index e554a2a1d0c..00000000000 --- a/app/assets/images/emoji/flag_gi.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gl.png b/app/assets/images/emoji/flag_gl.png Binary files differdeleted file mode 100644 index 2e795dd4e33..00000000000 --- a/app/assets/images/emoji/flag_gl.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gm.png b/app/assets/images/emoji/flag_gm.png Binary files differdeleted file mode 100644 index bb69c0975a3..00000000000 --- a/app/assets/images/emoji/flag_gm.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gn.png b/app/assets/images/emoji/flag_gn.png Binary files differdeleted file mode 100644 index 1981f61dbf5..00000000000 --- a/app/assets/images/emoji/flag_gn.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gp.png b/app/assets/images/emoji/flag_gp.png Binary files differdeleted file mode 100644 index 10e42e672bd..00000000000 --- a/app/assets/images/emoji/flag_gp.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gq.png b/app/assets/images/emoji/flag_gq.png Binary files differdeleted file mode 100644 index 11475e61eeb..00000000000 --- a/app/assets/images/emoji/flag_gq.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gr.png b/app/assets/images/emoji/flag_gr.png Binary files differdeleted file mode 100644 index 0f6bb1b6b94..00000000000 --- a/app/assets/images/emoji/flag_gr.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gs.png b/app/assets/images/emoji/flag_gs.png Binary files differdeleted file mode 100644 index 6fc92780453..00000000000 --- a/app/assets/images/emoji/flag_gs.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gt.png b/app/assets/images/emoji/flag_gt.png Binary files differdeleted file mode 100644 index 7213d4139ed..00000000000 --- a/app/assets/images/emoji/flag_gt.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gu.png b/app/assets/images/emoji/flag_gu.png Binary files differdeleted file mode 100644 index 4027549ca3c..00000000000 --- a/app/assets/images/emoji/flag_gu.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gw.png b/app/assets/images/emoji/flag_gw.png Binary files differdeleted file mode 100644 index 6357f6225f4..00000000000 --- a/app/assets/images/emoji/flag_gw.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_gy.png b/app/assets/images/emoji/flag_gy.png Binary files differdeleted file mode 100644 index 746e2fb7e44..00000000000 --- a/app/assets/images/emoji/flag_gy.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_hk.png b/app/assets/images/emoji/flag_hk.png Binary files differdeleted file mode 100644 index cf0c7151b56..00000000000 --- a/app/assets/images/emoji/flag_hk.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_hm.png b/app/assets/images/emoji/flag_hm.png Binary files differdeleted file mode 100644 index b613509e466..00000000000 --- a/app/assets/images/emoji/flag_hm.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_hn.png b/app/assets/images/emoji/flag_hn.png Binary files differdeleted file mode 100644 index 402cdcefdf8..00000000000 --- a/app/assets/images/emoji/flag_hn.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_hr.png b/app/assets/images/emoji/flag_hr.png Binary files differdeleted file mode 100644 index 46f4f06b4f2..00000000000 --- a/app/assets/images/emoji/flag_hr.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ht.png b/app/assets/images/emoji/flag_ht.png Binary files differdeleted file mode 100644 index d8d0c888498..00000000000 --- a/app/assets/images/emoji/flag_ht.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_hu.png b/app/assets/images/emoji/flag_hu.png Binary files differdeleted file mode 100644 index a898de636a5..00000000000 --- a/app/assets/images/emoji/flag_hu.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ic.png b/app/assets/images/emoji/flag_ic.png Binary files differdeleted file mode 100644 index 69fd990aa95..00000000000 --- a/app/assets/images/emoji/flag_ic.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_id.png b/app/assets/images/emoji/flag_id.png Binary files differdeleted file mode 100644 index 85b4c063a45..00000000000 --- a/app/assets/images/emoji/flag_id.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ie.png b/app/assets/images/emoji/flag_ie.png Binary files differdeleted file mode 100644 index a28295838cc..00000000000 --- a/app/assets/images/emoji/flag_ie.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_il.png b/app/assets/images/emoji/flag_il.png Binary files differdeleted file mode 100644 index 85c410d45fb..00000000000 --- a/app/assets/images/emoji/flag_il.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_im.png b/app/assets/images/emoji/flag_im.png Binary files differdeleted file mode 100644 index 60a2458e38e..00000000000 --- a/app/assets/images/emoji/flag_im.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_in.png b/app/assets/images/emoji/flag_in.png Binary files differdeleted file mode 100644 index feccc8952ce..00000000000 --- a/app/assets/images/emoji/flag_in.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_io.png b/app/assets/images/emoji/flag_io.png Binary files differdeleted file mode 100644 index aae927d14b8..00000000000 --- a/app/assets/images/emoji/flag_io.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_iq.png b/app/assets/images/emoji/flag_iq.png Binary files differdeleted file mode 100644 index 41fd1db6f86..00000000000 --- a/app/assets/images/emoji/flag_iq.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ir.png b/app/assets/images/emoji/flag_ir.png Binary files differdeleted file mode 100644 index ff7aaf62ba6..00000000000 --- a/app/assets/images/emoji/flag_ir.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_is.png b/app/assets/images/emoji/flag_is.png Binary files differdeleted file mode 100644 index ad8d4131dd2..00000000000 --- a/app/assets/images/emoji/flag_is.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_it.png b/app/assets/images/emoji/flag_it.png Binary files differdeleted file mode 100644 index f21563ec533..00000000000 --- a/app/assets/images/emoji/flag_it.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_je.png b/app/assets/images/emoji/flag_je.png Binary files differdeleted file mode 100644 index 198a918f6a4..00000000000 --- a/app/assets/images/emoji/flag_je.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_jm.png b/app/assets/images/emoji/flag_jm.png Binary files differdeleted file mode 100644 index f84e4f9e8db..00000000000 --- a/app/assets/images/emoji/flag_jm.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_jo.png b/app/assets/images/emoji/flag_jo.png Binary files differdeleted file mode 100644 index 20bfa147e3e..00000000000 --- a/app/assets/images/emoji/flag_jo.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_jp.png b/app/assets/images/emoji/flag_jp.png Binary files differdeleted file mode 100644 index 8d8838e4708..00000000000 --- a/app/assets/images/emoji/flag_jp.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ke.png b/app/assets/images/emoji/flag_ke.png Binary files differdeleted file mode 100644 index 9e417ab3009..00000000000 --- a/app/assets/images/emoji/flag_ke.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_kg.png b/app/assets/images/emoji/flag_kg.png Binary files differdeleted file mode 100644 index 2f2d848fe58..00000000000 --- a/app/assets/images/emoji/flag_kg.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_kh.png b/app/assets/images/emoji/flag_kh.png Binary files differdeleted file mode 100644 index 9a2877dd620..00000000000 --- a/app/assets/images/emoji/flag_kh.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ki.png b/app/assets/images/emoji/flag_ki.png Binary files differdeleted file mode 100644 index 10e507e3245..00000000000 --- a/app/assets/images/emoji/flag_ki.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_km.png b/app/assets/images/emoji/flag_km.png Binary files differdeleted file mode 100644 index bd5a0588e03..00000000000 --- a/app/assets/images/emoji/flag_km.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_kn.png b/app/assets/images/emoji/flag_kn.png Binary files differdeleted file mode 100644 index 776207c9605..00000000000 --- a/app/assets/images/emoji/flag_kn.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_kp.png b/app/assets/images/emoji/flag_kp.png Binary files differdeleted file mode 100644 index 6b3fd89eaaa..00000000000 --- a/app/assets/images/emoji/flag_kp.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_kr.png b/app/assets/images/emoji/flag_kr.png Binary files differdeleted file mode 100644 index 833a88116e1..00000000000 --- a/app/assets/images/emoji/flag_kr.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_kw.png b/app/assets/images/emoji/flag_kw.png Binary files differdeleted file mode 100644 index 4d19bfa6ca7..00000000000 --- a/app/assets/images/emoji/flag_kw.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ky.png b/app/assets/images/emoji/flag_ky.png Binary files differdeleted file mode 100644 index 40daa4da597..00000000000 --- a/app/assets/images/emoji/flag_ky.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_kz.png b/app/assets/images/emoji/flag_kz.png Binary files differdeleted file mode 100644 index 2f97a8fd3c6..00000000000 --- a/app/assets/images/emoji/flag_kz.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_la.png b/app/assets/images/emoji/flag_la.png Binary files differdeleted file mode 100644 index 4d4179f34f6..00000000000 --- a/app/assets/images/emoji/flag_la.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_lb.png b/app/assets/images/emoji/flag_lb.png Binary files differdeleted file mode 100644 index 3d594467011..00000000000 --- a/app/assets/images/emoji/flag_lb.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_lc.png b/app/assets/images/emoji/flag_lc.png Binary files differdeleted file mode 100644 index 45547b1e439..00000000000 --- a/app/assets/images/emoji/flag_lc.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_li.png b/app/assets/images/emoji/flag_li.png Binary files differdeleted file mode 100644 index 0eafa6a2215..00000000000 --- a/app/assets/images/emoji/flag_li.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_lk.png b/app/assets/images/emoji/flag_lk.png Binary files differdeleted file mode 100644 index ab4fe10c40c..00000000000 --- a/app/assets/images/emoji/flag_lk.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_lr.png b/app/assets/images/emoji/flag_lr.png Binary files differdeleted file mode 100644 index f66f267fea2..00000000000 --- a/app/assets/images/emoji/flag_lr.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ls.png b/app/assets/images/emoji/flag_ls.png Binary files differdeleted file mode 100644 index 24745631e3c..00000000000 --- a/app/assets/images/emoji/flag_ls.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_lt.png b/app/assets/images/emoji/flag_lt.png Binary files differdeleted file mode 100644 index d644b56d62a..00000000000 --- a/app/assets/images/emoji/flag_lt.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_lu.png b/app/assets/images/emoji/flag_lu.png Binary files differdeleted file mode 100644 index a2df9c92994..00000000000 --- a/app/assets/images/emoji/flag_lu.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_lv.png b/app/assets/images/emoji/flag_lv.png Binary files differdeleted file mode 100644 index ae680d5f0e3..00000000000 --- a/app/assets/images/emoji/flag_lv.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ly.png b/app/assets/images/emoji/flag_ly.png Binary files differdeleted file mode 100644 index f6e77b0f3ba..00000000000 --- a/app/assets/images/emoji/flag_ly.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ma.png b/app/assets/images/emoji/flag_ma.png Binary files differdeleted file mode 100644 index c4a056722cd..00000000000 --- a/app/assets/images/emoji/flag_ma.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mc.png b/app/assets/images/emoji/flag_mc.png Binary files differdeleted file mode 100644 index d479eab98cb..00000000000 --- a/app/assets/images/emoji/flag_mc.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_md.png b/app/assets/images/emoji/flag_md.png Binary files differdeleted file mode 100644 index a7a72539872..00000000000 --- a/app/assets/images/emoji/flag_md.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_me.png b/app/assets/images/emoji/flag_me.png Binary files differdeleted file mode 100644 index 7c771e7e120..00000000000 --- a/app/assets/images/emoji/flag_me.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mf.png b/app/assets/images/emoji/flag_mf.png Binary files differdeleted file mode 100644 index 70c761036bd..00000000000 --- a/app/assets/images/emoji/flag_mf.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mg.png b/app/assets/images/emoji/flag_mg.png Binary files differdeleted file mode 100644 index 2f3ccdda76f..00000000000 --- a/app/assets/images/emoji/flag_mg.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mh.png b/app/assets/images/emoji/flag_mh.png Binary files differdeleted file mode 100644 index 598016481c1..00000000000 --- a/app/assets/images/emoji/flag_mh.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mk.png b/app/assets/images/emoji/flag_mk.png Binary files differdeleted file mode 100644 index 7ba775ee75c..00000000000 --- a/app/assets/images/emoji/flag_mk.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ml.png b/app/assets/images/emoji/flag_ml.png Binary files differdeleted file mode 100644 index 68343785468..00000000000 --- a/app/assets/images/emoji/flag_ml.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mm.png b/app/assets/images/emoji/flag_mm.png Binary files differdeleted file mode 100644 index 37dc7d71591..00000000000 --- a/app/assets/images/emoji/flag_mm.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mn.png b/app/assets/images/emoji/flag_mn.png Binary files differdeleted file mode 100644 index 1f146bbcd1a..00000000000 --- a/app/assets/images/emoji/flag_mn.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mo.png b/app/assets/images/emoji/flag_mo.png Binary files differdeleted file mode 100644 index 7edde31f64b..00000000000 --- a/app/assets/images/emoji/flag_mo.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mp.png b/app/assets/images/emoji/flag_mp.png Binary files differdeleted file mode 100644 index 17ec1c441ed..00000000000 --- a/app/assets/images/emoji/flag_mp.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mq.png b/app/assets/images/emoji/flag_mq.png Binary files differdeleted file mode 100644 index 1e672dc9087..00000000000 --- a/app/assets/images/emoji/flag_mq.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mr.png b/app/assets/images/emoji/flag_mr.png Binary files differdeleted file mode 100644 index f87de46effe..00000000000 --- a/app/assets/images/emoji/flag_mr.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ms.png b/app/assets/images/emoji/flag_ms.png Binary files differdeleted file mode 100644 index 480b0d4ebda..00000000000 --- a/app/assets/images/emoji/flag_ms.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mt.png b/app/assets/images/emoji/flag_mt.png Binary files differdeleted file mode 100644 index c9e1dbdce82..00000000000 --- a/app/assets/images/emoji/flag_mt.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mu.png b/app/assets/images/emoji/flag_mu.png Binary files differdeleted file mode 100644 index 55b33cb7c33..00000000000 --- a/app/assets/images/emoji/flag_mu.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mv.png b/app/assets/images/emoji/flag_mv.png Binary files differdeleted file mode 100644 index ce5867126ae..00000000000 --- a/app/assets/images/emoji/flag_mv.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mw.png b/app/assets/images/emoji/flag_mw.png Binary files differdeleted file mode 100644 index 003d8548401..00000000000 --- a/app/assets/images/emoji/flag_mw.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mx.png b/app/assets/images/emoji/flag_mx.png Binary files differdeleted file mode 100644 index 42572bcd0ba..00000000000 --- a/app/assets/images/emoji/flag_mx.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_my.png b/app/assets/images/emoji/flag_my.png Binary files differdeleted file mode 100644 index 17526c26742..00000000000 --- a/app/assets/images/emoji/flag_my.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_mz.png b/app/assets/images/emoji/flag_mz.png Binary files differdeleted file mode 100644 index 2352a78e786..00000000000 --- a/app/assets/images/emoji/flag_mz.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_na.png b/app/assets/images/emoji/flag_na.png Binary files differdeleted file mode 100644 index ed31c3df04d..00000000000 --- a/app/assets/images/emoji/flag_na.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_nc.png b/app/assets/images/emoji/flag_nc.png Binary files differdeleted file mode 100644 index 90b3afebfa3..00000000000 --- a/app/assets/images/emoji/flag_nc.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ne.png b/app/assets/images/emoji/flag_ne.png Binary files differdeleted file mode 100644 index f98a1173c2a..00000000000 --- a/app/assets/images/emoji/flag_ne.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_nf.png b/app/assets/images/emoji/flag_nf.png Binary files differdeleted file mode 100644 index 9099e767420..00000000000 --- a/app/assets/images/emoji/flag_nf.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ng.png b/app/assets/images/emoji/flag_ng.png Binary files differdeleted file mode 100644 index ea0abeff1a1..00000000000 --- a/app/assets/images/emoji/flag_ng.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ni.png b/app/assets/images/emoji/flag_ni.png Binary files differdeleted file mode 100644 index 772920dfa10..00000000000 --- a/app/assets/images/emoji/flag_ni.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_nl.png b/app/assets/images/emoji/flag_nl.png Binary files differdeleted file mode 100644 index 83a0e817e41..00000000000 --- a/app/assets/images/emoji/flag_nl.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_no.png b/app/assets/images/emoji/flag_no.png Binary files differdeleted file mode 100644 index 99d3142eb7b..00000000000 --- a/app/assets/images/emoji/flag_no.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_np.png b/app/assets/images/emoji/flag_np.png Binary files differdeleted file mode 100644 index 87425a8dfef..00000000000 --- a/app/assets/images/emoji/flag_np.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_nr.png b/app/assets/images/emoji/flag_nr.png Binary files differdeleted file mode 100644 index b3e3a5d5621..00000000000 --- a/app/assets/images/emoji/flag_nr.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_nu.png b/app/assets/images/emoji/flag_nu.png Binary files differdeleted file mode 100644 index f03614443ee..00000000000 --- a/app/assets/images/emoji/flag_nu.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_nz.png b/app/assets/images/emoji/flag_nz.png Binary files differdeleted file mode 100644 index a4eeeab9cd9..00000000000 --- a/app/assets/images/emoji/flag_nz.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_om.png b/app/assets/images/emoji/flag_om.png Binary files differdeleted file mode 100644 index ea824ba31e7..00000000000 --- a/app/assets/images/emoji/flag_om.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_pa.png b/app/assets/images/emoji/flag_pa.png Binary files differdeleted file mode 100644 index c3091d89889..00000000000 --- a/app/assets/images/emoji/flag_pa.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_pe.png b/app/assets/images/emoji/flag_pe.png Binary files differdeleted file mode 100644 index 39223aa9dbb..00000000000 --- a/app/assets/images/emoji/flag_pe.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_pf.png b/app/assets/images/emoji/flag_pf.png Binary files differdeleted file mode 100644 index 113445f8f6e..00000000000 --- a/app/assets/images/emoji/flag_pf.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_pg.png b/app/assets/images/emoji/flag_pg.png Binary files differdeleted file mode 100644 index 825e9dcb762..00000000000 --- a/app/assets/images/emoji/flag_pg.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ph.png b/app/assets/images/emoji/flag_ph.png Binary files differdeleted file mode 100644 index 8260e15bd2c..00000000000 --- a/app/assets/images/emoji/flag_ph.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_pk.png b/app/assets/images/emoji/flag_pk.png Binary files differdeleted file mode 100644 index a7b6a1c5074..00000000000 --- a/app/assets/images/emoji/flag_pk.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_pl.png b/app/assets/images/emoji/flag_pl.png Binary files differdeleted file mode 100644 index 19de2edec11..00000000000 --- a/app/assets/images/emoji/flag_pl.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_pm.png b/app/assets/images/emoji/flag_pm.png Binary files differdeleted file mode 100644 index 2ca60554193..00000000000 --- a/app/assets/images/emoji/flag_pm.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_pn.png b/app/assets/images/emoji/flag_pn.png Binary files differdeleted file mode 100644 index f2263b154bc..00000000000 --- a/app/assets/images/emoji/flag_pn.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_pr.png b/app/assets/images/emoji/flag_pr.png Binary files differdeleted file mode 100644 index d0209cddb79..00000000000 --- a/app/assets/images/emoji/flag_pr.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ps.png b/app/assets/images/emoji/flag_ps.png Binary files differdeleted file mode 100644 index 7ccab09778b..00000000000 --- a/app/assets/images/emoji/flag_ps.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_pt.png b/app/assets/images/emoji/flag_pt.png Binary files differdeleted file mode 100644 index cc93f27c64b..00000000000 --- a/app/assets/images/emoji/flag_pt.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_pw.png b/app/assets/images/emoji/flag_pw.png Binary files differdeleted file mode 100644 index 154b2f12d3c..00000000000 --- a/app/assets/images/emoji/flag_pw.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_py.png b/app/assets/images/emoji/flag_py.png Binary files differdeleted file mode 100644 index 662ad2f6ff1..00000000000 --- a/app/assets/images/emoji/flag_py.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_qa.png b/app/assets/images/emoji/flag_qa.png Binary files differdeleted file mode 100644 index a01d8b05cc7..00000000000 --- a/app/assets/images/emoji/flag_qa.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_re.png b/app/assets/images/emoji/flag_re.png Binary files differdeleted file mode 100644 index 57f2bbe9df8..00000000000 --- a/app/assets/images/emoji/flag_re.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ro.png b/app/assets/images/emoji/flag_ro.png Binary files differdeleted file mode 100644 index 3e48c447706..00000000000 --- a/app/assets/images/emoji/flag_ro.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_rs.png b/app/assets/images/emoji/flag_rs.png Binary files differdeleted file mode 100644 index 9df6c9a5235..00000000000 --- a/app/assets/images/emoji/flag_rs.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ru.png b/app/assets/images/emoji/flag_ru.png Binary files differdeleted file mode 100644 index e50c9db90e7..00000000000 --- a/app/assets/images/emoji/flag_ru.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_rw.png b/app/assets/images/emoji/flag_rw.png Binary files differdeleted file mode 100644 index c238c874e1d..00000000000 --- a/app/assets/images/emoji/flag_rw.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_sa.png b/app/assets/images/emoji/flag_sa.png Binary files differdeleted file mode 100644 index 4941be7d198..00000000000 --- a/app/assets/images/emoji/flag_sa.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_sb.png b/app/assets/images/emoji/flag_sb.png Binary files differdeleted file mode 100644 index 7d8f1ac6130..00000000000 --- a/app/assets/images/emoji/flag_sb.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_sc.png b/app/assets/images/emoji/flag_sc.png Binary files differdeleted file mode 100644 index 6ae4d90765e..00000000000 --- a/app/assets/images/emoji/flag_sc.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_sd.png b/app/assets/images/emoji/flag_sd.png Binary files differdeleted file mode 100644 index 963be1b36fb..00000000000 --- a/app/assets/images/emoji/flag_sd.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_se.png b/app/assets/images/emoji/flag_se.png Binary files differdeleted file mode 100644 index fc0d0e0ce89..00000000000 --- a/app/assets/images/emoji/flag_se.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_sg.png b/app/assets/images/emoji/flag_sg.png Binary files differdeleted file mode 100644 index de3c7737c42..00000000000 --- a/app/assets/images/emoji/flag_sg.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_sh.png b/app/assets/images/emoji/flag_sh.png Binary files differdeleted file mode 100644 index 40cd9e44e96..00000000000 --- a/app/assets/images/emoji/flag_sh.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_si.png b/app/assets/images/emoji/flag_si.png Binary files differdeleted file mode 100644 index e308999dba2..00000000000 --- a/app/assets/images/emoji/flag_si.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_sj.png b/app/assets/images/emoji/flag_sj.png Binary files differdeleted file mode 100644 index 5884e648228..00000000000 --- a/app/assets/images/emoji/flag_sj.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_sk.png b/app/assets/images/emoji/flag_sk.png Binary files differdeleted file mode 100644 index 4259d0e1418..00000000000 --- a/app/assets/images/emoji/flag_sk.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_sl.png b/app/assets/images/emoji/flag_sl.png Binary files differdeleted file mode 100644 index d2cc68830ab..00000000000 --- a/app/assets/images/emoji/flag_sl.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_sm.png b/app/assets/images/emoji/flag_sm.png Binary files differdeleted file mode 100644 index 03b8708754e..00000000000 --- a/app/assets/images/emoji/flag_sm.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_sn.png b/app/assets/images/emoji/flag_sn.png Binary files differdeleted file mode 100644 index 5368bbe93df..00000000000 --- a/app/assets/images/emoji/flag_sn.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_so.png b/app/assets/images/emoji/flag_so.png Binary files differdeleted file mode 100644 index 68a0597365a..00000000000 --- a/app/assets/images/emoji/flag_so.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_sr.png b/app/assets/images/emoji/flag_sr.png Binary files differdeleted file mode 100644 index d3251327035..00000000000 --- a/app/assets/images/emoji/flag_sr.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ss.png b/app/assets/images/emoji/flag_ss.png Binary files differdeleted file mode 100644 index 122977e798f..00000000000 --- a/app/assets/images/emoji/flag_ss.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_st.png b/app/assets/images/emoji/flag_st.png Binary files differdeleted file mode 100644 index f83a863d612..00000000000 --- a/app/assets/images/emoji/flag_st.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_sv.png b/app/assets/images/emoji/flag_sv.png Binary files differdeleted file mode 100644 index efb83e2f253..00000000000 --- a/app/assets/images/emoji/flag_sv.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_sx.png b/app/assets/images/emoji/flag_sx.png Binary files differdeleted file mode 100644 index 94b760fbedf..00000000000 --- a/app/assets/images/emoji/flag_sx.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_sy.png b/app/assets/images/emoji/flag_sy.png Binary files differdeleted file mode 100644 index 09a8ee8f78c..00000000000 --- a/app/assets/images/emoji/flag_sy.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_sz.png b/app/assets/images/emoji/flag_sz.png Binary files differdeleted file mode 100644 index f74e82ea1fd..00000000000 --- a/app/assets/images/emoji/flag_sz.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ta.png b/app/assets/images/emoji/flag_ta.png Binary files differdeleted file mode 100644 index b44283e90e2..00000000000 --- a/app/assets/images/emoji/flag_ta.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_tc.png b/app/assets/images/emoji/flag_tc.png Binary files differdeleted file mode 100644 index 156b33d1ba6..00000000000 --- a/app/assets/images/emoji/flag_tc.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_td.png b/app/assets/images/emoji/flag_td.png Binary files differdeleted file mode 100644 index ebe7f592828..00000000000 --- a/app/assets/images/emoji/flag_td.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_tf.png b/app/assets/images/emoji/flag_tf.png Binary files differdeleted file mode 100644 index a1a3ad68ee2..00000000000 --- a/app/assets/images/emoji/flag_tf.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_tg.png b/app/assets/images/emoji/flag_tg.png Binary files differdeleted file mode 100644 index 826b73c9ac5..00000000000 --- a/app/assets/images/emoji/flag_tg.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_th.png b/app/assets/images/emoji/flag_th.png Binary files differdeleted file mode 100644 index 93ff542c5a6..00000000000 --- a/app/assets/images/emoji/flag_th.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_tj.png b/app/assets/images/emoji/flag_tj.png Binary files differdeleted file mode 100644 index 7a8a0b6190a..00000000000 --- a/app/assets/images/emoji/flag_tj.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_tk.png b/app/assets/images/emoji/flag_tk.png Binary files differdeleted file mode 100644 index 2fa5a21b1bb..00000000000 --- a/app/assets/images/emoji/flag_tk.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_tl.png b/app/assets/images/emoji/flag_tl.png Binary files differdeleted file mode 100644 index 5b120eccc6f..00000000000 --- a/app/assets/images/emoji/flag_tl.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_tm.png b/app/assets/images/emoji/flag_tm.png Binary files differdeleted file mode 100644 index c3c4f532302..00000000000 --- a/app/assets/images/emoji/flag_tm.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_tn.png b/app/assets/images/emoji/flag_tn.png Binary files differdeleted file mode 100644 index 58ef161229f..00000000000 --- a/app/assets/images/emoji/flag_tn.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_to.png b/app/assets/images/emoji/flag_to.png Binary files differdeleted file mode 100644 index 1ffa7bb9d19..00000000000 --- a/app/assets/images/emoji/flag_to.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_tr.png b/app/assets/images/emoji/flag_tr.png Binary files differdeleted file mode 100644 index 325251fae88..00000000000 --- a/app/assets/images/emoji/flag_tr.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_tt.png b/app/assets/images/emoji/flag_tt.png Binary files differdeleted file mode 100644 index ed3bb39a300..00000000000 --- a/app/assets/images/emoji/flag_tt.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_tv.png b/app/assets/images/emoji/flag_tv.png Binary files differdeleted file mode 100644 index e82c65c7bb9..00000000000 --- a/app/assets/images/emoji/flag_tv.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_tw.png b/app/assets/images/emoji/flag_tw.png Binary files differdeleted file mode 100644 index 3a8f00b5928..00000000000 --- a/app/assets/images/emoji/flag_tw.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_tz.png b/app/assets/images/emoji/flag_tz.png Binary files differdeleted file mode 100644 index 2a020853d4e..00000000000 --- a/app/assets/images/emoji/flag_tz.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ua.png b/app/assets/images/emoji/flag_ua.png Binary files differdeleted file mode 100644 index cd84d1bbd36..00000000000 --- a/app/assets/images/emoji/flag_ua.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ug.png b/app/assets/images/emoji/flag_ug.png Binary files differdeleted file mode 100644 index dc97690eb55..00000000000 --- a/app/assets/images/emoji/flag_ug.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_um.png b/app/assets/images/emoji/flag_um.png Binary files differdeleted file mode 100644 index 4a7ee3cdf13..00000000000 --- a/app/assets/images/emoji/flag_um.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_us.png b/app/assets/images/emoji/flag_us.png Binary files differdeleted file mode 100644 index 9f730305860..00000000000 --- a/app/assets/images/emoji/flag_us.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_uy.png b/app/assets/images/emoji/flag_uy.png Binary files differdeleted file mode 100644 index b8002a697a6..00000000000 --- a/app/assets/images/emoji/flag_uy.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_uz.png b/app/assets/images/emoji/flag_uz.png Binary files differdeleted file mode 100644 index d56ca9bc424..00000000000 --- a/app/assets/images/emoji/flag_uz.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_va.png b/app/assets/images/emoji/flag_va.png Binary files differdeleted file mode 100644 index ddaf5e3141b..00000000000 --- a/app/assets/images/emoji/flag_va.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_vc.png b/app/assets/images/emoji/flag_vc.png Binary files differdeleted file mode 100644 index 43703c62a71..00000000000 --- a/app/assets/images/emoji/flag_vc.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ve.png b/app/assets/images/emoji/flag_ve.png Binary files differdeleted file mode 100644 index 1b62796824e..00000000000 --- a/app/assets/images/emoji/flag_ve.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_vg.png b/app/assets/images/emoji/flag_vg.png Binary files differdeleted file mode 100644 index 536f780f1c0..00000000000 --- a/app/assets/images/emoji/flag_vg.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_vi.png b/app/assets/images/emoji/flag_vi.png Binary files differdeleted file mode 100644 index 64102012cfe..00000000000 --- a/app/assets/images/emoji/flag_vi.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_vn.png b/app/assets/images/emoji/flag_vn.png Binary files differdeleted file mode 100644 index 427036046b6..00000000000 --- a/app/assets/images/emoji/flag_vn.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_vu.png b/app/assets/images/emoji/flag_vu.png Binary files differdeleted file mode 100644 index 706eba44070..00000000000 --- a/app/assets/images/emoji/flag_vu.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_wf.png b/app/assets/images/emoji/flag_wf.png Binary files differdeleted file mode 100644 index 70c761036bd..00000000000 --- a/app/assets/images/emoji/flag_wf.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_white.png b/app/assets/images/emoji/flag_white.png Binary files differdeleted file mode 100644 index 86d6e96d5e9..00000000000 --- a/app/assets/images/emoji/flag_white.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ws.png b/app/assets/images/emoji/flag_ws.png Binary files differdeleted file mode 100644 index a1ea0703141..00000000000 --- a/app/assets/images/emoji/flag_ws.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_xk.png b/app/assets/images/emoji/flag_xk.png Binary files differdeleted file mode 100644 index e587a446632..00000000000 --- a/app/assets/images/emoji/flag_xk.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_ye.png b/app/assets/images/emoji/flag_ye.png Binary files differdeleted file mode 100644 index eadfebd5f67..00000000000 --- a/app/assets/images/emoji/flag_ye.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_yt.png b/app/assets/images/emoji/flag_yt.png Binary files differdeleted file mode 100644 index c81fa6d886e..00000000000 --- a/app/assets/images/emoji/flag_yt.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_za.png b/app/assets/images/emoji/flag_za.png Binary files differdeleted file mode 100644 index f397ef5072f..00000000000 --- a/app/assets/images/emoji/flag_za.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_zm.png b/app/assets/images/emoji/flag_zm.png Binary files differdeleted file mode 100644 index 2494a31f662..00000000000 --- a/app/assets/images/emoji/flag_zm.png +++ /dev/null diff --git a/app/assets/images/emoji/flag_zw.png b/app/assets/images/emoji/flag_zw.png Binary files differdeleted file mode 100644 index e09b9652be6..00000000000 --- a/app/assets/images/emoji/flag_zw.png +++ /dev/null diff --git a/app/assets/images/emoji/flags.png b/app/assets/images/emoji/flags.png Binary files differdeleted file mode 100644 index 3b451035a3a..00000000000 --- a/app/assets/images/emoji/flags.png +++ /dev/null diff --git a/app/assets/images/emoji/flashlight.png b/app/assets/images/emoji/flashlight.png Binary files differdeleted file mode 100644 index eee36c25067..00000000000 --- a/app/assets/images/emoji/flashlight.png +++ /dev/null diff --git a/app/assets/images/emoji/fleur-de-lis.png b/app/assets/images/emoji/fleur-de-lis.png Binary files differdeleted file mode 100644 index c9250d27fa7..00000000000 --- a/app/assets/images/emoji/fleur-de-lis.png +++ /dev/null diff --git a/app/assets/images/emoji/floppy_disk.png b/app/assets/images/emoji/floppy_disk.png Binary files differdeleted file mode 100644 index 072a76d3c13..00000000000 --- a/app/assets/images/emoji/floppy_disk.png +++ /dev/null diff --git a/app/assets/images/emoji/flower_playing_cards.png b/app/assets/images/emoji/flower_playing_cards.png Binary files differdeleted file mode 100644 index 6766b044d95..00000000000 --- a/app/assets/images/emoji/flower_playing_cards.png +++ /dev/null diff --git a/app/assets/images/emoji/flushed.png b/app/assets/images/emoji/flushed.png Binary files differdeleted file mode 100644 index 829220bc470..00000000000 --- a/app/assets/images/emoji/flushed.png +++ /dev/null diff --git a/app/assets/images/emoji/fog.png b/app/assets/images/emoji/fog.png Binary files differdeleted file mode 100644 index 4e73c2de272..00000000000 --- a/app/assets/images/emoji/fog.png +++ /dev/null diff --git a/app/assets/images/emoji/foggy.png b/app/assets/images/emoji/foggy.png Binary files differdeleted file mode 100644 index 57702d8d3ac..00000000000 --- a/app/assets/images/emoji/foggy.png +++ /dev/null diff --git a/app/assets/images/emoji/football.png b/app/assets/images/emoji/football.png Binary files differdeleted file mode 100644 index 10366f41fce..00000000000 --- a/app/assets/images/emoji/football.png +++ /dev/null diff --git a/app/assets/images/emoji/footprints.png b/app/assets/images/emoji/footprints.png Binary files differdeleted file mode 100644 index b2673c5a1a8..00000000000 --- a/app/assets/images/emoji/footprints.png +++ /dev/null diff --git a/app/assets/images/emoji/fork_and_knife.png b/app/assets/images/emoji/fork_and_knife.png Binary files differdeleted file mode 100644 index 09f1feaea1c..00000000000 --- a/app/assets/images/emoji/fork_and_knife.png +++ /dev/null diff --git a/app/assets/images/emoji/fork_knife_plate.png b/app/assets/images/emoji/fork_knife_plate.png Binary files differdeleted file mode 100644 index 7411755f708..00000000000 --- a/app/assets/images/emoji/fork_knife_plate.png +++ /dev/null diff --git a/app/assets/images/emoji/fountain.png b/app/assets/images/emoji/fountain.png Binary files differdeleted file mode 100644 index 293f5d91c0f..00000000000 --- a/app/assets/images/emoji/fountain.png +++ /dev/null diff --git a/app/assets/images/emoji/four.png b/app/assets/images/emoji/four.png Binary files differdeleted file mode 100644 index b0e914aac45..00000000000 --- a/app/assets/images/emoji/four.png +++ /dev/null diff --git a/app/assets/images/emoji/four_leaf_clover.png b/app/assets/images/emoji/four_leaf_clover.png Binary files differdeleted file mode 100644 index fdedfcc2b4e..00000000000 --- a/app/assets/images/emoji/four_leaf_clover.png +++ /dev/null diff --git a/app/assets/images/emoji/fox.png b/app/assets/images/emoji/fox.png Binary files differdeleted file mode 100644 index 1ab339bf054..00000000000 --- a/app/assets/images/emoji/fox.png +++ /dev/null diff --git a/app/assets/images/emoji/frame_photo.png b/app/assets/images/emoji/frame_photo.png Binary files differdeleted file mode 100644 index 9fe84607bfd..00000000000 --- a/app/assets/images/emoji/frame_photo.png +++ /dev/null diff --git a/app/assets/images/emoji/free.png b/app/assets/images/emoji/free.png Binary files differdeleted file mode 100644 index b71956eb48a..00000000000 --- a/app/assets/images/emoji/free.png +++ /dev/null diff --git a/app/assets/images/emoji/french_bread.png b/app/assets/images/emoji/french_bread.png Binary files differdeleted file mode 100644 index 4c2c5639822..00000000000 --- a/app/assets/images/emoji/french_bread.png +++ /dev/null diff --git a/app/assets/images/emoji/fried_shrimp.png b/app/assets/images/emoji/fried_shrimp.png Binary files differdeleted file mode 100644 index 752ba7f1398..00000000000 --- a/app/assets/images/emoji/fried_shrimp.png +++ /dev/null diff --git a/app/assets/images/emoji/fries.png b/app/assets/images/emoji/fries.png Binary files differdeleted file mode 100644 index 4e2a4caacef..00000000000 --- a/app/assets/images/emoji/fries.png +++ /dev/null diff --git a/app/assets/images/emoji/frog.png b/app/assets/images/emoji/frog.png Binary files differdeleted file mode 100644 index 8825d1ad577..00000000000 --- a/app/assets/images/emoji/frog.png +++ /dev/null diff --git a/app/assets/images/emoji/frowning.png b/app/assets/images/emoji/frowning.png Binary files differdeleted file mode 100644 index 43ab6b0a1c1..00000000000 --- a/app/assets/images/emoji/frowning.png +++ /dev/null diff --git a/app/assets/images/emoji/frowning2.png b/app/assets/images/emoji/frowning2.png Binary files differdeleted file mode 100644 index 6ae71f233b9..00000000000 --- a/app/assets/images/emoji/frowning2.png +++ /dev/null diff --git a/app/assets/images/emoji/fuelpump.png b/app/assets/images/emoji/fuelpump.png Binary files differdeleted file mode 100644 index 05b18794474..00000000000 --- a/app/assets/images/emoji/fuelpump.png +++ /dev/null diff --git a/app/assets/images/emoji/full_moon.png b/app/assets/images/emoji/full_moon.png Binary files differdeleted file mode 100644 index c9a2d6aa7c9..00000000000 --- a/app/assets/images/emoji/full_moon.png +++ /dev/null diff --git a/app/assets/images/emoji/full_moon_with_face.png b/app/assets/images/emoji/full_moon_with_face.png Binary files differdeleted file mode 100644 index a5c25bbaf64..00000000000 --- a/app/assets/images/emoji/full_moon_with_face.png +++ /dev/null diff --git a/app/assets/images/emoji/game_die.png b/app/assets/images/emoji/game_die.png Binary files differdeleted file mode 100644 index ad3626fe5e5..00000000000 --- a/app/assets/images/emoji/game_die.png +++ /dev/null diff --git a/app/assets/images/emoji/gay_pride_flag.png b/app/assets/images/emoji/gay_pride_flag.png Binary files differdeleted file mode 100644 index 1bec5f2ffd7..00000000000 --- a/app/assets/images/emoji/gay_pride_flag.png +++ /dev/null diff --git a/app/assets/images/emoji/gear.png b/app/assets/images/emoji/gear.png Binary files differdeleted file mode 100644 index 2a1cc2c0ff4..00000000000 --- a/app/assets/images/emoji/gear.png +++ /dev/null diff --git a/app/assets/images/emoji/gem.png b/app/assets/images/emoji/gem.png Binary files differdeleted file mode 100644 index db122d26a19..00000000000 --- a/app/assets/images/emoji/gem.png +++ /dev/null diff --git a/app/assets/images/emoji/gemini.png b/app/assets/images/emoji/gemini.png Binary files differdeleted file mode 100644 index 1a09698cf00..00000000000 --- a/app/assets/images/emoji/gemini.png +++ /dev/null diff --git a/app/assets/images/emoji/ghost.png b/app/assets/images/emoji/ghost.png Binary files differdeleted file mode 100644 index 5650bc0ed18..00000000000 --- a/app/assets/images/emoji/ghost.png +++ /dev/null diff --git a/app/assets/images/emoji/gift.png b/app/assets/images/emoji/gift.png Binary files differdeleted file mode 100644 index 844e2164560..00000000000 --- a/app/assets/images/emoji/gift.png +++ /dev/null diff --git a/app/assets/images/emoji/gift_heart.png b/app/assets/images/emoji/gift_heart.png Binary files differdeleted file mode 100644 index 902ceafe4d1..00000000000 --- a/app/assets/images/emoji/gift_heart.png +++ /dev/null diff --git a/app/assets/images/emoji/girl.png b/app/assets/images/emoji/girl.png Binary files differdeleted file mode 100644 index dc1d4d08b39..00000000000 --- a/app/assets/images/emoji/girl.png +++ /dev/null diff --git a/app/assets/images/emoji/girl_tone1.png b/app/assets/images/emoji/girl_tone1.png Binary files differdeleted file mode 100644 index bb667e88651..00000000000 --- a/app/assets/images/emoji/girl_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/girl_tone2.png b/app/assets/images/emoji/girl_tone2.png Binary files differdeleted file mode 100644 index a59ed4a3f0d..00000000000 --- a/app/assets/images/emoji/girl_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/girl_tone3.png b/app/assets/images/emoji/girl_tone3.png Binary files differdeleted file mode 100644 index 517e7f2a7b0..00000000000 --- a/app/assets/images/emoji/girl_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/girl_tone4.png b/app/assets/images/emoji/girl_tone4.png Binary files differdeleted file mode 100644 index 542d96c8487..00000000000 --- a/app/assets/images/emoji/girl_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/girl_tone5.png b/app/assets/images/emoji/girl_tone5.png Binary files differdeleted file mode 100644 index 66b7c28c2df..00000000000 --- a/app/assets/images/emoji/girl_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/globe_with_meridians.png b/app/assets/images/emoji/globe_with_meridians.png Binary files differdeleted file mode 100644 index 82450c1a4ba..00000000000 --- a/app/assets/images/emoji/globe_with_meridians.png +++ /dev/null diff --git a/app/assets/images/emoji/goal.png b/app/assets/images/emoji/goal.png Binary files differdeleted file mode 100644 index df3a53da0fb..00000000000 --- a/app/assets/images/emoji/goal.png +++ /dev/null diff --git a/app/assets/images/emoji/goat.png b/app/assets/images/emoji/goat.png Binary files differdeleted file mode 100644 index f9d9e38a128..00000000000 --- a/app/assets/images/emoji/goat.png +++ /dev/null diff --git a/app/assets/images/emoji/golf.png b/app/assets/images/emoji/golf.png Binary files differdeleted file mode 100644 index f65a21d8a46..00000000000 --- a/app/assets/images/emoji/golf.png +++ /dev/null diff --git a/app/assets/images/emoji/golfer.png b/app/assets/images/emoji/golfer.png Binary files differdeleted file mode 100644 index 39c552de86d..00000000000 --- a/app/assets/images/emoji/golfer.png +++ /dev/null diff --git a/app/assets/images/emoji/gorilla.png b/app/assets/images/emoji/gorilla.png Binary files differdeleted file mode 100644 index acc51e13622..00000000000 --- a/app/assets/images/emoji/gorilla.png +++ /dev/null diff --git a/app/assets/images/emoji/grapes.png b/app/assets/images/emoji/grapes.png Binary files differdeleted file mode 100644 index 30d22218896..00000000000 --- a/app/assets/images/emoji/grapes.png +++ /dev/null diff --git a/app/assets/images/emoji/green_apple.png b/app/assets/images/emoji/green_apple.png Binary files differdeleted file mode 100644 index 5fd51bd3915..00000000000 --- a/app/assets/images/emoji/green_apple.png +++ /dev/null diff --git a/app/assets/images/emoji/green_book.png b/app/assets/images/emoji/green_book.png Binary files differdeleted file mode 100644 index e5e411cf3b5..00000000000 --- a/app/assets/images/emoji/green_book.png +++ /dev/null diff --git a/app/assets/images/emoji/green_heart.png b/app/assets/images/emoji/green_heart.png Binary files differdeleted file mode 100644 index c52d60a58be..00000000000 --- a/app/assets/images/emoji/green_heart.png +++ /dev/null diff --git a/app/assets/images/emoji/grey_exclamation.png b/app/assets/images/emoji/grey_exclamation.png Binary files differdeleted file mode 100644 index 9b64da8bf7f..00000000000 --- a/app/assets/images/emoji/grey_exclamation.png +++ /dev/null diff --git a/app/assets/images/emoji/grey_question.png b/app/assets/images/emoji/grey_question.png Binary files differdeleted file mode 100644 index 6e7824c75f6..00000000000 --- a/app/assets/images/emoji/grey_question.png +++ /dev/null diff --git a/app/assets/images/emoji/grimacing.png b/app/assets/images/emoji/grimacing.png Binary files differdeleted file mode 100644 index 871b2f071c9..00000000000 --- a/app/assets/images/emoji/grimacing.png +++ /dev/null diff --git a/app/assets/images/emoji/grin.png b/app/assets/images/emoji/grin.png Binary files differdeleted file mode 100644 index 418d94c811b..00000000000 --- a/app/assets/images/emoji/grin.png +++ /dev/null diff --git a/app/assets/images/emoji/grinning.png b/app/assets/images/emoji/grinning.png Binary files differdeleted file mode 100644 index 3e8e0dab78c..00000000000 --- a/app/assets/images/emoji/grinning.png +++ /dev/null diff --git a/app/assets/images/emoji/guardsman.png b/app/assets/images/emoji/guardsman.png Binary files differdeleted file mode 100644 index 8d7ab3c473c..00000000000 --- a/app/assets/images/emoji/guardsman.png +++ /dev/null diff --git a/app/assets/images/emoji/guardsman_tone1.png b/app/assets/images/emoji/guardsman_tone1.png Binary files differdeleted file mode 100644 index cea9ba27468..00000000000 --- a/app/assets/images/emoji/guardsman_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/guardsman_tone2.png b/app/assets/images/emoji/guardsman_tone2.png Binary files differdeleted file mode 100644 index 037464e4028..00000000000 --- a/app/assets/images/emoji/guardsman_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/guardsman_tone3.png b/app/assets/images/emoji/guardsman_tone3.png Binary files differdeleted file mode 100644 index 0f6726fbe87..00000000000 --- a/app/assets/images/emoji/guardsman_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/guardsman_tone4.png b/app/assets/images/emoji/guardsman_tone4.png Binary files differdeleted file mode 100644 index 85fcf9a3b97..00000000000 --- a/app/assets/images/emoji/guardsman_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/guardsman_tone5.png b/app/assets/images/emoji/guardsman_tone5.png Binary files differdeleted file mode 100644 index e5f9ca7d5a2..00000000000 --- a/app/assets/images/emoji/guardsman_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/guitar.png b/app/assets/images/emoji/guitar.png Binary files differdeleted file mode 100644 index 43d752f1e3d..00000000000 --- a/app/assets/images/emoji/guitar.png +++ /dev/null diff --git a/app/assets/images/emoji/gun.png b/app/assets/images/emoji/gun.png Binary files differdeleted file mode 100644 index 89c5c244c7b..00000000000 --- a/app/assets/images/emoji/gun.png +++ /dev/null diff --git a/app/assets/images/emoji/haircut.png b/app/assets/images/emoji/haircut.png Binary files differdeleted file mode 100644 index 91266b12930..00000000000 --- a/app/assets/images/emoji/haircut.png +++ /dev/null diff --git a/app/assets/images/emoji/haircut_tone1.png b/app/assets/images/emoji/haircut_tone1.png Binary files differdeleted file mode 100644 index c743b74abeb..00000000000 --- a/app/assets/images/emoji/haircut_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/haircut_tone2.png b/app/assets/images/emoji/haircut_tone2.png Binary files differdeleted file mode 100644 index f144f8e55ce..00000000000 --- a/app/assets/images/emoji/haircut_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/haircut_tone3.png b/app/assets/images/emoji/haircut_tone3.png Binary files differdeleted file mode 100644 index d5ad19563ac..00000000000 --- a/app/assets/images/emoji/haircut_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/haircut_tone4.png b/app/assets/images/emoji/haircut_tone4.png Binary files differdeleted file mode 100644 index 244fd3af008..00000000000 --- a/app/assets/images/emoji/haircut_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/haircut_tone5.png b/app/assets/images/emoji/haircut_tone5.png Binary files differdeleted file mode 100644 index 20a94a88623..00000000000 --- a/app/assets/images/emoji/haircut_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/hamburger.png b/app/assets/images/emoji/hamburger.png Binary files differdeleted file mode 100644 index 3573b28a1fd..00000000000 --- a/app/assets/images/emoji/hamburger.png +++ /dev/null diff --git a/app/assets/images/emoji/hammer.png b/app/assets/images/emoji/hammer.png Binary files differdeleted file mode 100644 index 00736cce47d..00000000000 --- a/app/assets/images/emoji/hammer.png +++ /dev/null diff --git a/app/assets/images/emoji/hammer_pick.png b/app/assets/images/emoji/hammer_pick.png Binary files differdeleted file mode 100644 index 3bee30ec588..00000000000 --- a/app/assets/images/emoji/hammer_pick.png +++ /dev/null diff --git a/app/assets/images/emoji/hamster.png b/app/assets/images/emoji/hamster.png Binary files differdeleted file mode 100644 index 9a04388e4e7..00000000000 --- a/app/assets/images/emoji/hamster.png +++ /dev/null diff --git a/app/assets/images/emoji/hand_splayed.png b/app/assets/images/emoji/hand_splayed.png Binary files differdeleted file mode 100644 index fb5ae8ebb5a..00000000000 --- a/app/assets/images/emoji/hand_splayed.png +++ /dev/null diff --git a/app/assets/images/emoji/hand_splayed_tone1.png b/app/assets/images/emoji/hand_splayed_tone1.png Binary files differdeleted file mode 100644 index a7888e6bd23..00000000000 --- a/app/assets/images/emoji/hand_splayed_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/hand_splayed_tone2.png b/app/assets/images/emoji/hand_splayed_tone2.png Binary files differdeleted file mode 100644 index cc10fbc272d..00000000000 --- a/app/assets/images/emoji/hand_splayed_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/hand_splayed_tone3.png b/app/assets/images/emoji/hand_splayed_tone3.png Binary files differdeleted file mode 100644 index 707236ae8a4..00000000000 --- a/app/assets/images/emoji/hand_splayed_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/hand_splayed_tone4.png b/app/assets/images/emoji/hand_splayed_tone4.png Binary files differdeleted file mode 100644 index 1430df9c61f..00000000000 --- a/app/assets/images/emoji/hand_splayed_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/hand_splayed_tone5.png b/app/assets/images/emoji/hand_splayed_tone5.png Binary files differdeleted file mode 100644 index 80bec971b6b..00000000000 --- a/app/assets/images/emoji/hand_splayed_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/handbag.png b/app/assets/images/emoji/handbag.png Binary files differdeleted file mode 100644 index cbf75c5d25e..00000000000 --- a/app/assets/images/emoji/handbag.png +++ /dev/null diff --git a/app/assets/images/emoji/handball.png b/app/assets/images/emoji/handball.png Binary files differdeleted file mode 100644 index 1152f1344c7..00000000000 --- a/app/assets/images/emoji/handball.png +++ /dev/null diff --git a/app/assets/images/emoji/handball_tone1.png b/app/assets/images/emoji/handball_tone1.png Binary files differdeleted file mode 100644 index c26cac2df98..00000000000 --- a/app/assets/images/emoji/handball_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/handball_tone2.png b/app/assets/images/emoji/handball_tone2.png Binary files differdeleted file mode 100644 index 7baaf95a9a2..00000000000 --- a/app/assets/images/emoji/handball_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/handball_tone3.png b/app/assets/images/emoji/handball_tone3.png Binary files differdeleted file mode 100644 index 0e3a37c3d40..00000000000 --- a/app/assets/images/emoji/handball_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/handball_tone4.png b/app/assets/images/emoji/handball_tone4.png Binary files differdeleted file mode 100644 index e1233f38266..00000000000 --- a/app/assets/images/emoji/handball_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/handball_tone5.png b/app/assets/images/emoji/handball_tone5.png Binary files differdeleted file mode 100644 index 6b1eb9b64b0..00000000000 --- a/app/assets/images/emoji/handball_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/handshake.png b/app/assets/images/emoji/handshake.png Binary files differdeleted file mode 100644 index c5d35fd8138..00000000000 --- a/app/assets/images/emoji/handshake.png +++ /dev/null diff --git a/app/assets/images/emoji/handshake_tone1.png b/app/assets/images/emoji/handshake_tone1.png Binary files differdeleted file mode 100644 index 8f8fbb9bdca..00000000000 --- a/app/assets/images/emoji/handshake_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/handshake_tone2.png b/app/assets/images/emoji/handshake_tone2.png Binary files differdeleted file mode 100644 index 336a77a6d78..00000000000 --- a/app/assets/images/emoji/handshake_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/handshake_tone3.png b/app/assets/images/emoji/handshake_tone3.png Binary files differdeleted file mode 100644 index 95f62d4fecd..00000000000 --- a/app/assets/images/emoji/handshake_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/handshake_tone4.png b/app/assets/images/emoji/handshake_tone4.png Binary files differdeleted file mode 100644 index 2b0a6433886..00000000000 --- a/app/assets/images/emoji/handshake_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/handshake_tone5.png b/app/assets/images/emoji/handshake_tone5.png Binary files differdeleted file mode 100644 index 40189ee68e4..00000000000 --- a/app/assets/images/emoji/handshake_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/hash.png b/app/assets/images/emoji/hash.png Binary files differdeleted file mode 100644 index 6e26f0070b0..00000000000 --- a/app/assets/images/emoji/hash.png +++ /dev/null diff --git a/app/assets/images/emoji/hatched_chick.png b/app/assets/images/emoji/hatched_chick.png Binary files differdeleted file mode 100644 index 31dfb511e0e..00000000000 --- a/app/assets/images/emoji/hatched_chick.png +++ /dev/null diff --git a/app/assets/images/emoji/hatching_chick.png b/app/assets/images/emoji/hatching_chick.png Binary files differdeleted file mode 100644 index c5b0e8f3bcc..00000000000 --- a/app/assets/images/emoji/hatching_chick.png +++ /dev/null diff --git a/app/assets/images/emoji/head_bandage.png b/app/assets/images/emoji/head_bandage.png Binary files differdeleted file mode 100644 index 0be723085e0..00000000000 --- a/app/assets/images/emoji/head_bandage.png +++ /dev/null diff --git a/app/assets/images/emoji/headphones.png b/app/assets/images/emoji/headphones.png Binary files differdeleted file mode 100644 index e9fd34041d8..00000000000 --- a/app/assets/images/emoji/headphones.png +++ /dev/null diff --git a/app/assets/images/emoji/hear_no_evil.png b/app/assets/images/emoji/hear_no_evil.png Binary files differdeleted file mode 100644 index 74b6be0c6c5..00000000000 --- a/app/assets/images/emoji/hear_no_evil.png +++ /dev/null diff --git a/app/assets/images/emoji/heart.png b/app/assets/images/emoji/heart.png Binary files differdeleted file mode 100644 index 638cb72dc4e..00000000000 --- a/app/assets/images/emoji/heart.png +++ /dev/null diff --git a/app/assets/images/emoji/heart_decoration.png b/app/assets/images/emoji/heart_decoration.png Binary files differdeleted file mode 100644 index 5443f60bc63..00000000000 --- a/app/assets/images/emoji/heart_decoration.png +++ /dev/null diff --git a/app/assets/images/emoji/heart_exclamation.png b/app/assets/images/emoji/heart_exclamation.png Binary files differdeleted file mode 100644 index 91b520be40b..00000000000 --- a/app/assets/images/emoji/heart_exclamation.png +++ /dev/null diff --git a/app/assets/images/emoji/heart_eyes.png b/app/assets/images/emoji/heart_eyes.png Binary files differdeleted file mode 100644 index 73fbee29d4e..00000000000 --- a/app/assets/images/emoji/heart_eyes.png +++ /dev/null diff --git a/app/assets/images/emoji/heart_eyes_cat.png b/app/assets/images/emoji/heart_eyes_cat.png Binary files differdeleted file mode 100644 index bc5a833f9a1..00000000000 --- a/app/assets/images/emoji/heart_eyes_cat.png +++ /dev/null diff --git a/app/assets/images/emoji/heartbeat.png b/app/assets/images/emoji/heartbeat.png Binary files differdeleted file mode 100644 index 0bcf2d1d567..00000000000 --- a/app/assets/images/emoji/heartbeat.png +++ /dev/null diff --git a/app/assets/images/emoji/heartpulse.png b/app/assets/images/emoji/heartpulse.png Binary files differdeleted file mode 100644 index d6e694e972f..00000000000 --- a/app/assets/images/emoji/heartpulse.png +++ /dev/null diff --git a/app/assets/images/emoji/hearts.png b/app/assets/images/emoji/hearts.png Binary files differdeleted file mode 100644 index 393c3ed5267..00000000000 --- a/app/assets/images/emoji/hearts.png +++ /dev/null diff --git a/app/assets/images/emoji/heavy_check_mark.png b/app/assets/images/emoji/heavy_check_mark.png Binary files differdeleted file mode 100644 index 03bd695377e..00000000000 --- a/app/assets/images/emoji/heavy_check_mark.png +++ /dev/null diff --git a/app/assets/images/emoji/heavy_division_sign.png b/app/assets/images/emoji/heavy_division_sign.png Binary files differdeleted file mode 100644 index df32ab21bea..00000000000 --- a/app/assets/images/emoji/heavy_division_sign.png +++ /dev/null diff --git a/app/assets/images/emoji/heavy_dollar_sign.png b/app/assets/images/emoji/heavy_dollar_sign.png Binary files differdeleted file mode 100644 index ef2c2e20590..00000000000 --- a/app/assets/images/emoji/heavy_dollar_sign.png +++ /dev/null diff --git a/app/assets/images/emoji/heavy_minus_sign.png b/app/assets/images/emoji/heavy_minus_sign.png Binary files differdeleted file mode 100644 index 054211caf12..00000000000 --- a/app/assets/images/emoji/heavy_minus_sign.png +++ /dev/null diff --git a/app/assets/images/emoji/heavy_multiplication_x.png b/app/assets/images/emoji/heavy_multiplication_x.png Binary files differdeleted file mode 100644 index e47cc1b685d..00000000000 --- a/app/assets/images/emoji/heavy_multiplication_x.png +++ /dev/null diff --git a/app/assets/images/emoji/heavy_plus_sign.png b/app/assets/images/emoji/heavy_plus_sign.png Binary files differdeleted file mode 100644 index 40799798aaf..00000000000 --- a/app/assets/images/emoji/heavy_plus_sign.png +++ /dev/null diff --git a/app/assets/images/emoji/helicopter.png b/app/assets/images/emoji/helicopter.png Binary files differdeleted file mode 100644 index 7ec5f39a51a..00000000000 --- a/app/assets/images/emoji/helicopter.png +++ /dev/null diff --git a/app/assets/images/emoji/helmet_with_cross.png b/app/assets/images/emoji/helmet_with_cross.png Binary files differdeleted file mode 100644 index 7140a676038..00000000000 --- a/app/assets/images/emoji/helmet_with_cross.png +++ /dev/null diff --git a/app/assets/images/emoji/herb.png b/app/assets/images/emoji/herb.png Binary files differdeleted file mode 100644 index d984d1562bb..00000000000 --- a/app/assets/images/emoji/herb.png +++ /dev/null diff --git a/app/assets/images/emoji/hibiscus.png b/app/assets/images/emoji/hibiscus.png Binary files differdeleted file mode 100644 index 39dd3524233..00000000000 --- a/app/assets/images/emoji/hibiscus.png +++ /dev/null diff --git a/app/assets/images/emoji/high_brightness.png b/app/assets/images/emoji/high_brightness.png Binary files differdeleted file mode 100644 index c41f2d5fd50..00000000000 --- a/app/assets/images/emoji/high_brightness.png +++ /dev/null diff --git a/app/assets/images/emoji/high_heel.png b/app/assets/images/emoji/high_heel.png Binary files differdeleted file mode 100644 index b331cbccc9d..00000000000 --- a/app/assets/images/emoji/high_heel.png +++ /dev/null diff --git a/app/assets/images/emoji/hockey.png b/app/assets/images/emoji/hockey.png Binary files differdeleted file mode 100644 index be94e9cbf73..00000000000 --- a/app/assets/images/emoji/hockey.png +++ /dev/null diff --git a/app/assets/images/emoji/hole.png b/app/assets/images/emoji/hole.png Binary files differdeleted file mode 100644 index 517d2ae0deb..00000000000 --- a/app/assets/images/emoji/hole.png +++ /dev/null diff --git a/app/assets/images/emoji/homes.png b/app/assets/images/emoji/homes.png Binary files differdeleted file mode 100644 index 6ab4a2a2651..00000000000 --- a/app/assets/images/emoji/homes.png +++ /dev/null diff --git a/app/assets/images/emoji/honey_pot.png b/app/assets/images/emoji/honey_pot.png Binary files differdeleted file mode 100644 index 9d8f592955e..00000000000 --- a/app/assets/images/emoji/honey_pot.png +++ /dev/null diff --git a/app/assets/images/emoji/horse.png b/app/assets/images/emoji/horse.png Binary files differdeleted file mode 100644 index 7cb1172f4e4..00000000000 --- a/app/assets/images/emoji/horse.png +++ /dev/null diff --git a/app/assets/images/emoji/horse_racing.png b/app/assets/images/emoji/horse_racing.png Binary files differdeleted file mode 100644 index addf9edac56..00000000000 --- a/app/assets/images/emoji/horse_racing.png +++ /dev/null diff --git a/app/assets/images/emoji/horse_racing_tone1.png b/app/assets/images/emoji/horse_racing_tone1.png Binary files differdeleted file mode 100644 index e9bf4092e98..00000000000 --- a/app/assets/images/emoji/horse_racing_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/horse_racing_tone2.png b/app/assets/images/emoji/horse_racing_tone2.png Binary files differdeleted file mode 100644 index 031bbc3d867..00000000000 --- a/app/assets/images/emoji/horse_racing_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/horse_racing_tone3.png b/app/assets/images/emoji/horse_racing_tone3.png Binary files differdeleted file mode 100644 index b40ef891f9b..00000000000 --- a/app/assets/images/emoji/horse_racing_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/horse_racing_tone4.png b/app/assets/images/emoji/horse_racing_tone4.png Binary files differdeleted file mode 100644 index e286cb85065..00000000000 --- a/app/assets/images/emoji/horse_racing_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/horse_racing_tone5.png b/app/assets/images/emoji/horse_racing_tone5.png Binary files differdeleted file mode 100644 index 453c51c6007..00000000000 --- a/app/assets/images/emoji/horse_racing_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/hospital.png b/app/assets/images/emoji/hospital.png Binary files differdeleted file mode 100644 index 1cbce4ae767..00000000000 --- a/app/assets/images/emoji/hospital.png +++ /dev/null diff --git a/app/assets/images/emoji/hot_pepper.png b/app/assets/images/emoji/hot_pepper.png Binary files differdeleted file mode 100644 index 266675bd577..00000000000 --- a/app/assets/images/emoji/hot_pepper.png +++ /dev/null diff --git a/app/assets/images/emoji/hotdog.png b/app/assets/images/emoji/hotdog.png Binary files differdeleted file mode 100644 index 3c3354d94cb..00000000000 --- a/app/assets/images/emoji/hotdog.png +++ /dev/null diff --git a/app/assets/images/emoji/hotel.png b/app/assets/images/emoji/hotel.png Binary files differdeleted file mode 100644 index ea8f4c4979a..00000000000 --- a/app/assets/images/emoji/hotel.png +++ /dev/null diff --git a/app/assets/images/emoji/hotsprings.png b/app/assets/images/emoji/hotsprings.png Binary files differdeleted file mode 100644 index 3d9df2d9475..00000000000 --- a/app/assets/images/emoji/hotsprings.png +++ /dev/null diff --git a/app/assets/images/emoji/hourglass.png b/app/assets/images/emoji/hourglass.png Binary files differdeleted file mode 100644 index a5db2d1d3f4..00000000000 --- a/app/assets/images/emoji/hourglass.png +++ /dev/null diff --git a/app/assets/images/emoji/hourglass_flowing_sand.png b/app/assets/images/emoji/hourglass_flowing_sand.png Binary files differdeleted file mode 100644 index b93b15ed6d8..00000000000 --- a/app/assets/images/emoji/hourglass_flowing_sand.png +++ /dev/null diff --git a/app/assets/images/emoji/house.png b/app/assets/images/emoji/house.png Binary files differdeleted file mode 100644 index 01c98a0ba92..00000000000 --- a/app/assets/images/emoji/house.png +++ /dev/null diff --git a/app/assets/images/emoji/house_abandoned.png b/app/assets/images/emoji/house_abandoned.png Binary files differdeleted file mode 100644 index c55e81de990..00000000000 --- a/app/assets/images/emoji/house_abandoned.png +++ /dev/null diff --git a/app/assets/images/emoji/house_with_garden.png b/app/assets/images/emoji/house_with_garden.png Binary files differdeleted file mode 100644 index 0aae41598ef..00000000000 --- a/app/assets/images/emoji/house_with_garden.png +++ /dev/null diff --git a/app/assets/images/emoji/hugging.png b/app/assets/images/emoji/hugging.png Binary files differdeleted file mode 100644 index 5bba6dc6d51..00000000000 --- a/app/assets/images/emoji/hugging.png +++ /dev/null diff --git a/app/assets/images/emoji/hushed.png b/app/assets/images/emoji/hushed.png Binary files differdeleted file mode 100644 index cad0e23132e..00000000000 --- a/app/assets/images/emoji/hushed.png +++ /dev/null diff --git a/app/assets/images/emoji/ice_cream.png b/app/assets/images/emoji/ice_cream.png Binary files differdeleted file mode 100644 index 94267b9c434..00000000000 --- a/app/assets/images/emoji/ice_cream.png +++ /dev/null diff --git a/app/assets/images/emoji/ice_skate.png b/app/assets/images/emoji/ice_skate.png Binary files differdeleted file mode 100644 index 8c449b0c039..00000000000 --- a/app/assets/images/emoji/ice_skate.png +++ /dev/null diff --git a/app/assets/images/emoji/icecream.png b/app/assets/images/emoji/icecream.png Binary files differdeleted file mode 100644 index 8f6546e31a5..00000000000 --- a/app/assets/images/emoji/icecream.png +++ /dev/null diff --git a/app/assets/images/emoji/id.png b/app/assets/images/emoji/id.png Binary files differdeleted file mode 100644 index 5bf69bf7ba8..00000000000 --- a/app/assets/images/emoji/id.png +++ /dev/null diff --git a/app/assets/images/emoji/ideograph_advantage.png b/app/assets/images/emoji/ideograph_advantage.png Binary files differdeleted file mode 100644 index 0c0d589caf0..00000000000 --- a/app/assets/images/emoji/ideograph_advantage.png +++ /dev/null diff --git a/app/assets/images/emoji/imp.png b/app/assets/images/emoji/imp.png Binary files differdeleted file mode 100644 index 9f9a9605539..00000000000 --- a/app/assets/images/emoji/imp.png +++ /dev/null diff --git a/app/assets/images/emoji/inbox_tray.png b/app/assets/images/emoji/inbox_tray.png Binary files differdeleted file mode 100644 index 41a6be2b0ee..00000000000 --- a/app/assets/images/emoji/inbox_tray.png +++ /dev/null diff --git a/app/assets/images/emoji/incoming_envelope.png b/app/assets/images/emoji/incoming_envelope.png Binary files differdeleted file mode 100644 index fd22e88182e..00000000000 --- a/app/assets/images/emoji/incoming_envelope.png +++ /dev/null diff --git a/app/assets/images/emoji/information_desk_person.png b/app/assets/images/emoji/information_desk_person.png Binary files differdeleted file mode 100644 index 55fc6294d25..00000000000 --- a/app/assets/images/emoji/information_desk_person.png +++ /dev/null diff --git a/app/assets/images/emoji/information_desk_person_tone1.png b/app/assets/images/emoji/information_desk_person_tone1.png Binary files differdeleted file mode 100644 index 3d9e2247940..00000000000 --- a/app/assets/images/emoji/information_desk_person_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/information_desk_person_tone2.png b/app/assets/images/emoji/information_desk_person_tone2.png Binary files differdeleted file mode 100644 index 879e8b7966d..00000000000 --- a/app/assets/images/emoji/information_desk_person_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/information_desk_person_tone3.png b/app/assets/images/emoji/information_desk_person_tone3.png Binary files differdeleted file mode 100644 index 307514eab67..00000000000 --- a/app/assets/images/emoji/information_desk_person_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/information_desk_person_tone4.png b/app/assets/images/emoji/information_desk_person_tone4.png Binary files differdeleted file mode 100644 index 297395dcb3f..00000000000 --- a/app/assets/images/emoji/information_desk_person_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/information_desk_person_tone5.png b/app/assets/images/emoji/information_desk_person_tone5.png Binary files differdeleted file mode 100644 index 26f8f22b28b..00000000000 --- a/app/assets/images/emoji/information_desk_person_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/information_source.png b/app/assets/images/emoji/information_source.png Binary files differdeleted file mode 100644 index 871f2db9314..00000000000 --- a/app/assets/images/emoji/information_source.png +++ /dev/null diff --git a/app/assets/images/emoji/innocent.png b/app/assets/images/emoji/innocent.png Binary files differdeleted file mode 100644 index 57f5151124f..00000000000 --- a/app/assets/images/emoji/innocent.png +++ /dev/null diff --git a/app/assets/images/emoji/interrobang.png b/app/assets/images/emoji/interrobang.png Binary files differdeleted file mode 100644 index 509813e9bb2..00000000000 --- a/app/assets/images/emoji/interrobang.png +++ /dev/null diff --git a/app/assets/images/emoji/iphone.png b/app/assets/images/emoji/iphone.png Binary files differdeleted file mode 100644 index fd377acf872..00000000000 --- a/app/assets/images/emoji/iphone.png +++ /dev/null diff --git a/app/assets/images/emoji/island.png b/app/assets/images/emoji/island.png Binary files differdeleted file mode 100644 index 7fd834389b7..00000000000 --- a/app/assets/images/emoji/island.png +++ /dev/null diff --git a/app/assets/images/emoji/izakaya_lantern.png b/app/assets/images/emoji/izakaya_lantern.png Binary files differdeleted file mode 100644 index dfd933f6f36..00000000000 --- a/app/assets/images/emoji/izakaya_lantern.png +++ /dev/null diff --git a/app/assets/images/emoji/jack_o_lantern.png b/app/assets/images/emoji/jack_o_lantern.png Binary files differdeleted file mode 100644 index 44c3fc0aec9..00000000000 --- a/app/assets/images/emoji/jack_o_lantern.png +++ /dev/null diff --git a/app/assets/images/emoji/japan.png b/app/assets/images/emoji/japan.png Binary files differdeleted file mode 100644 index d86d0a59e12..00000000000 --- a/app/assets/images/emoji/japan.png +++ /dev/null diff --git a/app/assets/images/emoji/japanese_castle.png b/app/assets/images/emoji/japanese_castle.png Binary files differdeleted file mode 100644 index 64b4e33a1ae..00000000000 --- a/app/assets/images/emoji/japanese_castle.png +++ /dev/null diff --git a/app/assets/images/emoji/japanese_goblin.png b/app/assets/images/emoji/japanese_goblin.png Binary files differdeleted file mode 100644 index 515c6a2250e..00000000000 --- a/app/assets/images/emoji/japanese_goblin.png +++ /dev/null diff --git a/app/assets/images/emoji/japanese_ogre.png b/app/assets/images/emoji/japanese_ogre.png Binary files differdeleted file mode 100644 index fe8670fdaf1..00000000000 --- a/app/assets/images/emoji/japanese_ogre.png +++ /dev/null diff --git a/app/assets/images/emoji/jeans.png b/app/assets/images/emoji/jeans.png Binary files differdeleted file mode 100644 index 2a6869d674c..00000000000 --- a/app/assets/images/emoji/jeans.png +++ /dev/null diff --git a/app/assets/images/emoji/joy.png b/app/assets/images/emoji/joy.png Binary files differdeleted file mode 100644 index 0ba3b1859d8..00000000000 --- a/app/assets/images/emoji/joy.png +++ /dev/null diff --git a/app/assets/images/emoji/joy_cat.png b/app/assets/images/emoji/joy_cat.png Binary files differdeleted file mode 100644 index aac353179aa..00000000000 --- a/app/assets/images/emoji/joy_cat.png +++ /dev/null diff --git a/app/assets/images/emoji/joystick.png b/app/assets/images/emoji/joystick.png Binary files differdeleted file mode 100644 index 1ee1905434e..00000000000 --- a/app/assets/images/emoji/joystick.png +++ /dev/null diff --git a/app/assets/images/emoji/juggling.png b/app/assets/images/emoji/juggling.png Binary files differdeleted file mode 100644 index a37f6224a42..00000000000 --- a/app/assets/images/emoji/juggling.png +++ /dev/null diff --git a/app/assets/images/emoji/juggling_tone1.png b/app/assets/images/emoji/juggling_tone1.png Binary files differdeleted file mode 100644 index c18eda40031..00000000000 --- a/app/assets/images/emoji/juggling_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/juggling_tone2.png b/app/assets/images/emoji/juggling_tone2.png Binary files differdeleted file mode 100644 index de3b7a555b6..00000000000 --- a/app/assets/images/emoji/juggling_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/juggling_tone3.png b/app/assets/images/emoji/juggling_tone3.png Binary files differdeleted file mode 100644 index 74ab6d85458..00000000000 --- a/app/assets/images/emoji/juggling_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/juggling_tone4.png b/app/assets/images/emoji/juggling_tone4.png Binary files differdeleted file mode 100644 index 1c57823203f..00000000000 --- a/app/assets/images/emoji/juggling_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/juggling_tone5.png b/app/assets/images/emoji/juggling_tone5.png Binary files differdeleted file mode 100644 index c343d6ee98a..00000000000 --- a/app/assets/images/emoji/juggling_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/kaaba.png b/app/assets/images/emoji/kaaba.png Binary files differdeleted file mode 100644 index 1778c1138e4..00000000000 --- a/app/assets/images/emoji/kaaba.png +++ /dev/null diff --git a/app/assets/images/emoji/key.png b/app/assets/images/emoji/key.png Binary files differdeleted file mode 100644 index 319cd1b884c..00000000000 --- a/app/assets/images/emoji/key.png +++ /dev/null diff --git a/app/assets/images/emoji/key2.png b/app/assets/images/emoji/key2.png Binary files differdeleted file mode 100644 index e11d706c6c8..00000000000 --- a/app/assets/images/emoji/key2.png +++ /dev/null diff --git a/app/assets/images/emoji/keyboard.png b/app/assets/images/emoji/keyboard.png Binary files differdeleted file mode 100644 index 75027cb9af7..00000000000 --- a/app/assets/images/emoji/keyboard.png +++ /dev/null diff --git a/app/assets/images/emoji/kimono.png b/app/assets/images/emoji/kimono.png Binary files differdeleted file mode 100644 index abe851115d1..00000000000 --- a/app/assets/images/emoji/kimono.png +++ /dev/null diff --git a/app/assets/images/emoji/kiss.png b/app/assets/images/emoji/kiss.png Binary files differdeleted file mode 100644 index 85e6dcfc4e8..00000000000 --- a/app/assets/images/emoji/kiss.png +++ /dev/null diff --git a/app/assets/images/emoji/kiss_mm.png b/app/assets/images/emoji/kiss_mm.png Binary files differdeleted file mode 100644 index a9a0edae17c..00000000000 --- a/app/assets/images/emoji/kiss_mm.png +++ /dev/null diff --git a/app/assets/images/emoji/kiss_ww.png b/app/assets/images/emoji/kiss_ww.png Binary files differdeleted file mode 100644 index fdac73cbb1d..00000000000 --- a/app/assets/images/emoji/kiss_ww.png +++ /dev/null diff --git a/app/assets/images/emoji/kissing.png b/app/assets/images/emoji/kissing.png Binary files differdeleted file mode 100644 index 39d325fd8e3..00000000000 --- a/app/assets/images/emoji/kissing.png +++ /dev/null diff --git a/app/assets/images/emoji/kissing_cat.png b/app/assets/images/emoji/kissing_cat.png Binary files differdeleted file mode 100644 index 6e0bcc77540..00000000000 --- a/app/assets/images/emoji/kissing_cat.png +++ /dev/null diff --git a/app/assets/images/emoji/kissing_closed_eyes.png b/app/assets/images/emoji/kissing_closed_eyes.png Binary files differdeleted file mode 100644 index b684d7d4d6c..00000000000 --- a/app/assets/images/emoji/kissing_closed_eyes.png +++ /dev/null diff --git a/app/assets/images/emoji/kissing_heart.png b/app/assets/images/emoji/kissing_heart.png Binary files differdeleted file mode 100644 index 0ff808fd614..00000000000 --- a/app/assets/images/emoji/kissing_heart.png +++ /dev/null diff --git a/app/assets/images/emoji/kissing_smiling_eyes.png b/app/assets/images/emoji/kissing_smiling_eyes.png Binary files differdeleted file mode 100644 index e181f17099d..00000000000 --- a/app/assets/images/emoji/kissing_smiling_eyes.png +++ /dev/null diff --git a/app/assets/images/emoji/kiwi.png b/app/assets/images/emoji/kiwi.png Binary files differdeleted file mode 100644 index dfbd8258074..00000000000 --- a/app/assets/images/emoji/kiwi.png +++ /dev/null diff --git a/app/assets/images/emoji/knife.png b/app/assets/images/emoji/knife.png Binary files differdeleted file mode 100644 index 1acb9f3077b..00000000000 --- a/app/assets/images/emoji/knife.png +++ /dev/null diff --git a/app/assets/images/emoji/koala.png b/app/assets/images/emoji/koala.png Binary files differdeleted file mode 100644 index a0aa437a98c..00000000000 --- a/app/assets/images/emoji/koala.png +++ /dev/null diff --git a/app/assets/images/emoji/koko.png b/app/assets/images/emoji/koko.png Binary files differdeleted file mode 100644 index 6450eb44d90..00000000000 --- a/app/assets/images/emoji/koko.png +++ /dev/null diff --git a/app/assets/images/emoji/label.png b/app/assets/images/emoji/label.png Binary files differdeleted file mode 100644 index d41c9b4f1e1..00000000000 --- a/app/assets/images/emoji/label.png +++ /dev/null diff --git a/app/assets/images/emoji/large_blue_circle.png b/app/assets/images/emoji/large_blue_circle.png Binary files differdeleted file mode 100644 index 84078ef3127..00000000000 --- a/app/assets/images/emoji/large_blue_circle.png +++ /dev/null diff --git a/app/assets/images/emoji/large_blue_diamond.png b/app/assets/images/emoji/large_blue_diamond.png Binary files differdeleted file mode 100644 index 416a58bd5a8..00000000000 --- a/app/assets/images/emoji/large_blue_diamond.png +++ /dev/null diff --git a/app/assets/images/emoji/large_orange_diamond.png b/app/assets/images/emoji/large_orange_diamond.png Binary files differdeleted file mode 100644 index 73ff0ac36c8..00000000000 --- a/app/assets/images/emoji/large_orange_diamond.png +++ /dev/null diff --git a/app/assets/images/emoji/last_quarter_moon.png b/app/assets/images/emoji/last_quarter_moon.png Binary files differdeleted file mode 100644 index 0842a0dd408..00000000000 --- a/app/assets/images/emoji/last_quarter_moon.png +++ /dev/null diff --git a/app/assets/images/emoji/last_quarter_moon_with_face.png b/app/assets/images/emoji/last_quarter_moon_with_face.png Binary files differdeleted file mode 100644 index 94099343c5d..00000000000 --- a/app/assets/images/emoji/last_quarter_moon_with_face.png +++ /dev/null diff --git a/app/assets/images/emoji/laughing.png b/app/assets/images/emoji/laughing.png Binary files differdeleted file mode 100644 index d94e9505ba1..00000000000 --- a/app/assets/images/emoji/laughing.png +++ /dev/null diff --git a/app/assets/images/emoji/leaves.png b/app/assets/images/emoji/leaves.png Binary files differdeleted file mode 100644 index 1e43e1af820..00000000000 --- a/app/assets/images/emoji/leaves.png +++ /dev/null diff --git a/app/assets/images/emoji/ledger.png b/app/assets/images/emoji/ledger.png Binary files differdeleted file mode 100644 index 13e7561a4bd..00000000000 --- a/app/assets/images/emoji/ledger.png +++ /dev/null diff --git a/app/assets/images/emoji/left_facing_fist.png b/app/assets/images/emoji/left_facing_fist.png Binary files differdeleted file mode 100644 index a9d9fd8d59c..00000000000 --- a/app/assets/images/emoji/left_facing_fist.png +++ /dev/null diff --git a/app/assets/images/emoji/left_facing_fist_tone1.png b/app/assets/images/emoji/left_facing_fist_tone1.png Binary files differdeleted file mode 100644 index 1262a6b4b69..00000000000 --- a/app/assets/images/emoji/left_facing_fist_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/left_facing_fist_tone2.png b/app/assets/images/emoji/left_facing_fist_tone2.png Binary files differdeleted file mode 100644 index 40bf70b82b2..00000000000 --- a/app/assets/images/emoji/left_facing_fist_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/left_facing_fist_tone3.png b/app/assets/images/emoji/left_facing_fist_tone3.png Binary files differdeleted file mode 100644 index 93f58145111..00000000000 --- a/app/assets/images/emoji/left_facing_fist_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/left_facing_fist_tone4.png b/app/assets/images/emoji/left_facing_fist_tone4.png Binary files differdeleted file mode 100644 index d82b5ec91f0..00000000000 --- a/app/assets/images/emoji/left_facing_fist_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/left_facing_fist_tone5.png b/app/assets/images/emoji/left_facing_fist_tone5.png Binary files differdeleted file mode 100644 index 09ae4cd492b..00000000000 --- a/app/assets/images/emoji/left_facing_fist_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/left_luggage.png b/app/assets/images/emoji/left_luggage.png Binary files differdeleted file mode 100644 index 887b23f3f25..00000000000 --- a/app/assets/images/emoji/left_luggage.png +++ /dev/null diff --git a/app/assets/images/emoji/left_right_arrow.png b/app/assets/images/emoji/left_right_arrow.png Binary files differdeleted file mode 100644 index 7937f24f2ac..00000000000 --- a/app/assets/images/emoji/left_right_arrow.png +++ /dev/null diff --git a/app/assets/images/emoji/leftwards_arrow_with_hook.png b/app/assets/images/emoji/leftwards_arrow_with_hook.png Binary files differdeleted file mode 100644 index ba45c2ad9e9..00000000000 --- a/app/assets/images/emoji/leftwards_arrow_with_hook.png +++ /dev/null diff --git a/app/assets/images/emoji/lemon.png b/app/assets/images/emoji/lemon.png Binary files differdeleted file mode 100644 index 9a7d95ca220..00000000000 --- a/app/assets/images/emoji/lemon.png +++ /dev/null diff --git a/app/assets/images/emoji/leo.png b/app/assets/images/emoji/leo.png Binary files differdeleted file mode 100644 index 30158d34de9..00000000000 --- a/app/assets/images/emoji/leo.png +++ /dev/null diff --git a/app/assets/images/emoji/leopard.png b/app/assets/images/emoji/leopard.png Binary files differdeleted file mode 100644 index 8aac3d49448..00000000000 --- a/app/assets/images/emoji/leopard.png +++ /dev/null diff --git a/app/assets/images/emoji/level_slider.png b/app/assets/images/emoji/level_slider.png Binary files differdeleted file mode 100644 index 720a3b34119..00000000000 --- a/app/assets/images/emoji/level_slider.png +++ /dev/null diff --git a/app/assets/images/emoji/levitate.png b/app/assets/images/emoji/levitate.png Binary files differdeleted file mode 100644 index 3dc315a3d91..00000000000 --- a/app/assets/images/emoji/levitate.png +++ /dev/null diff --git a/app/assets/images/emoji/libra.png b/app/assets/images/emoji/libra.png Binary files differdeleted file mode 100644 index 8fd133a357c..00000000000 --- a/app/assets/images/emoji/libra.png +++ /dev/null diff --git a/app/assets/images/emoji/lifter.png b/app/assets/images/emoji/lifter.png Binary files differdeleted file mode 100644 index afdeaa476af..00000000000 --- a/app/assets/images/emoji/lifter.png +++ /dev/null diff --git a/app/assets/images/emoji/lifter_tone1.png b/app/assets/images/emoji/lifter_tone1.png Binary files differdeleted file mode 100644 index febaad123ec..00000000000 --- a/app/assets/images/emoji/lifter_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/lifter_tone2.png b/app/assets/images/emoji/lifter_tone2.png Binary files differdeleted file mode 100644 index 27ae794a18e..00000000000 --- a/app/assets/images/emoji/lifter_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/lifter_tone3.png b/app/assets/images/emoji/lifter_tone3.png Binary files differdeleted file mode 100644 index 45c4c22c709..00000000000 --- a/app/assets/images/emoji/lifter_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/lifter_tone4.png b/app/assets/images/emoji/lifter_tone4.png Binary files differdeleted file mode 100644 index 67dd21d2464..00000000000 --- a/app/assets/images/emoji/lifter_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/lifter_tone5.png b/app/assets/images/emoji/lifter_tone5.png Binary files differdeleted file mode 100644 index fa0152038b6..00000000000 --- a/app/assets/images/emoji/lifter_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/light_rail.png b/app/assets/images/emoji/light_rail.png Binary files differdeleted file mode 100644 index a64829f5078..00000000000 --- a/app/assets/images/emoji/light_rail.png +++ /dev/null diff --git a/app/assets/images/emoji/link.png b/app/assets/images/emoji/link.png Binary files differdeleted file mode 100644 index ae20f0f8eec..00000000000 --- a/app/assets/images/emoji/link.png +++ /dev/null diff --git a/app/assets/images/emoji/lion_face.png b/app/assets/images/emoji/lion_face.png Binary files differdeleted file mode 100644 index 5062ab47ecf..00000000000 --- a/app/assets/images/emoji/lion_face.png +++ /dev/null diff --git a/app/assets/images/emoji/lips.png b/app/assets/images/emoji/lips.png Binary files differdeleted file mode 100644 index 35f3cc2006f..00000000000 --- a/app/assets/images/emoji/lips.png +++ /dev/null diff --git a/app/assets/images/emoji/lipstick.png b/app/assets/images/emoji/lipstick.png Binary files differdeleted file mode 100644 index 61a0c084c99..00000000000 --- a/app/assets/images/emoji/lipstick.png +++ /dev/null diff --git a/app/assets/images/emoji/lizard.png b/app/assets/images/emoji/lizard.png Binary files differdeleted file mode 100644 index 8363876050e..00000000000 --- a/app/assets/images/emoji/lizard.png +++ /dev/null diff --git a/app/assets/images/emoji/lock.png b/app/assets/images/emoji/lock.png Binary files differdeleted file mode 100644 index 5a739c46644..00000000000 --- a/app/assets/images/emoji/lock.png +++ /dev/null diff --git a/app/assets/images/emoji/lock_with_ink_pen.png b/app/assets/images/emoji/lock_with_ink_pen.png Binary files differdeleted file mode 100644 index 19a07d162fb..00000000000 --- a/app/assets/images/emoji/lock_with_ink_pen.png +++ /dev/null diff --git a/app/assets/images/emoji/lollipop.png b/app/assets/images/emoji/lollipop.png Binary files differdeleted file mode 100644 index ad76d7bf916..00000000000 --- a/app/assets/images/emoji/lollipop.png +++ /dev/null diff --git a/app/assets/images/emoji/loop.png b/app/assets/images/emoji/loop.png Binary files differdeleted file mode 100644 index 0b82c8fe315..00000000000 --- a/app/assets/images/emoji/loop.png +++ /dev/null diff --git a/app/assets/images/emoji/loud_sound.png b/app/assets/images/emoji/loud_sound.png Binary files differdeleted file mode 100644 index 8370033a539..00000000000 --- a/app/assets/images/emoji/loud_sound.png +++ /dev/null diff --git a/app/assets/images/emoji/loudspeaker.png b/app/assets/images/emoji/loudspeaker.png Binary files differdeleted file mode 100644 index 5fd76a95b82..00000000000 --- a/app/assets/images/emoji/loudspeaker.png +++ /dev/null diff --git a/app/assets/images/emoji/love_hotel.png b/app/assets/images/emoji/love_hotel.png Binary files differdeleted file mode 100644 index 5e136be6f8b..00000000000 --- a/app/assets/images/emoji/love_hotel.png +++ /dev/null diff --git a/app/assets/images/emoji/love_letter.png b/app/assets/images/emoji/love_letter.png Binary files differdeleted file mode 100644 index 3c3c767e784..00000000000 --- a/app/assets/images/emoji/love_letter.png +++ /dev/null diff --git a/app/assets/images/emoji/low_brightness.png b/app/assets/images/emoji/low_brightness.png Binary files differdeleted file mode 100644 index 543011d3961..00000000000 --- a/app/assets/images/emoji/low_brightness.png +++ /dev/null diff --git a/app/assets/images/emoji/lying_face.png b/app/assets/images/emoji/lying_face.png Binary files differdeleted file mode 100644 index 02827e2628b..00000000000 --- a/app/assets/images/emoji/lying_face.png +++ /dev/null diff --git a/app/assets/images/emoji/m.png b/app/assets/images/emoji/m.png Binary files differdeleted file mode 100644 index 8a3506fc1d7..00000000000 --- a/app/assets/images/emoji/m.png +++ /dev/null diff --git a/app/assets/images/emoji/mag.png b/app/assets/images/emoji/mag.png Binary files differdeleted file mode 100644 index 55487156ac6..00000000000 --- a/app/assets/images/emoji/mag.png +++ /dev/null diff --git a/app/assets/images/emoji/mag_right.png b/app/assets/images/emoji/mag_right.png Binary files differdeleted file mode 100644 index 0f4b1bca876..00000000000 --- a/app/assets/images/emoji/mag_right.png +++ /dev/null diff --git a/app/assets/images/emoji/mahjong.png b/app/assets/images/emoji/mahjong.png Binary files differdeleted file mode 100644 index 66fd32025b2..00000000000 --- a/app/assets/images/emoji/mahjong.png +++ /dev/null diff --git a/app/assets/images/emoji/mailbox.png b/app/assets/images/emoji/mailbox.png Binary files differdeleted file mode 100644 index ef5174e40dd..00000000000 --- a/app/assets/images/emoji/mailbox.png +++ /dev/null diff --git a/app/assets/images/emoji/mailbox_closed.png b/app/assets/images/emoji/mailbox_closed.png Binary files differdeleted file mode 100644 index ddc705db0d8..00000000000 --- a/app/assets/images/emoji/mailbox_closed.png +++ /dev/null diff --git a/app/assets/images/emoji/mailbox_with_mail.png b/app/assets/images/emoji/mailbox_with_mail.png Binary files differdeleted file mode 100644 index 5460616a5b1..00000000000 --- a/app/assets/images/emoji/mailbox_with_mail.png +++ /dev/null diff --git a/app/assets/images/emoji/mailbox_with_no_mail.png b/app/assets/images/emoji/mailbox_with_no_mail.png Binary files differdeleted file mode 100644 index f9aeee6b15a..00000000000 --- a/app/assets/images/emoji/mailbox_with_no_mail.png +++ /dev/null diff --git a/app/assets/images/emoji/man.png b/app/assets/images/emoji/man.png Binary files differdeleted file mode 100644 index 857a02e5146..00000000000 --- a/app/assets/images/emoji/man.png +++ /dev/null diff --git a/app/assets/images/emoji/man_dancing.png b/app/assets/images/emoji/man_dancing.png Binary files differdeleted file mode 100644 index ccff3bede5a..00000000000 --- a/app/assets/images/emoji/man_dancing.png +++ /dev/null diff --git a/app/assets/images/emoji/man_dancing_tone1.png b/app/assets/images/emoji/man_dancing_tone1.png Binary files differdeleted file mode 100644 index e0b9f82d905..00000000000 --- a/app/assets/images/emoji/man_dancing_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/man_dancing_tone2.png b/app/assets/images/emoji/man_dancing_tone2.png Binary files differdeleted file mode 100644 index a5beed56e2e..00000000000 --- a/app/assets/images/emoji/man_dancing_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/man_dancing_tone3.png b/app/assets/images/emoji/man_dancing_tone3.png Binary files differdeleted file mode 100644 index 2fa20180a6e..00000000000 --- a/app/assets/images/emoji/man_dancing_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/man_dancing_tone4.png b/app/assets/images/emoji/man_dancing_tone4.png Binary files differdeleted file mode 100644 index bd3528c83ba..00000000000 --- a/app/assets/images/emoji/man_dancing_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/man_dancing_tone5.png b/app/assets/images/emoji/man_dancing_tone5.png Binary files differdeleted file mode 100644 index 41fd4f880c9..00000000000 --- a/app/assets/images/emoji/man_dancing_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/man_in_tuxedo.png b/app/assets/images/emoji/man_in_tuxedo.png Binary files differdeleted file mode 100644 index 5f7e9303f89..00000000000 --- a/app/assets/images/emoji/man_in_tuxedo.png +++ /dev/null diff --git a/app/assets/images/emoji/man_in_tuxedo_tone1.png b/app/assets/images/emoji/man_in_tuxedo_tone1.png Binary files differdeleted file mode 100644 index 7b6b3acd99b..00000000000 --- a/app/assets/images/emoji/man_in_tuxedo_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/man_in_tuxedo_tone2.png b/app/assets/images/emoji/man_in_tuxedo_tone2.png Binary files differdeleted file mode 100644 index 7975191b360..00000000000 --- a/app/assets/images/emoji/man_in_tuxedo_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/man_in_tuxedo_tone3.png b/app/assets/images/emoji/man_in_tuxedo_tone3.png Binary files differdeleted file mode 100644 index a2816f600ae..00000000000 --- a/app/assets/images/emoji/man_in_tuxedo_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/man_in_tuxedo_tone4.png b/app/assets/images/emoji/man_in_tuxedo_tone4.png Binary files differdeleted file mode 100644 index ea8291760f9..00000000000 --- a/app/assets/images/emoji/man_in_tuxedo_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/man_in_tuxedo_tone5.png b/app/assets/images/emoji/man_in_tuxedo_tone5.png Binary files differdeleted file mode 100644 index c743e05fc5e..00000000000 --- a/app/assets/images/emoji/man_in_tuxedo_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/man_tone1.png b/app/assets/images/emoji/man_tone1.png Binary files differdeleted file mode 100644 index bb86e963a80..00000000000 --- a/app/assets/images/emoji/man_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/man_tone2.png b/app/assets/images/emoji/man_tone2.png Binary files differdeleted file mode 100644 index fdeeaff46f5..00000000000 --- a/app/assets/images/emoji/man_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/man_tone3.png b/app/assets/images/emoji/man_tone3.png Binary files differdeleted file mode 100644 index 7ae0b5df9cf..00000000000 --- a/app/assets/images/emoji/man_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/man_tone4.png b/app/assets/images/emoji/man_tone4.png Binary files differdeleted file mode 100644 index db14cde99b8..00000000000 --- a/app/assets/images/emoji/man_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/man_tone5.png b/app/assets/images/emoji/man_tone5.png Binary files differdeleted file mode 100644 index 7c67a70529c..00000000000 --- a/app/assets/images/emoji/man_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/man_with_gua_pi_mao.png b/app/assets/images/emoji/man_with_gua_pi_mao.png Binary files differdeleted file mode 100644 index 7841e13608d..00000000000 --- a/app/assets/images/emoji/man_with_gua_pi_mao.png +++ /dev/null diff --git a/app/assets/images/emoji/man_with_gua_pi_mao_tone1.png b/app/assets/images/emoji/man_with_gua_pi_mao_tone1.png Binary files differdeleted file mode 100644 index 5b7b3def19c..00000000000 --- a/app/assets/images/emoji/man_with_gua_pi_mao_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/man_with_gua_pi_mao_tone2.png b/app/assets/images/emoji/man_with_gua_pi_mao_tone2.png Binary files differdeleted file mode 100644 index c8b9cf87f4b..00000000000 --- a/app/assets/images/emoji/man_with_gua_pi_mao_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/man_with_gua_pi_mao_tone3.png b/app/assets/images/emoji/man_with_gua_pi_mao_tone3.png Binary files differdeleted file mode 100644 index effdd0c4c84..00000000000 --- a/app/assets/images/emoji/man_with_gua_pi_mao_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/man_with_gua_pi_mao_tone4.png b/app/assets/images/emoji/man_with_gua_pi_mao_tone4.png Binary files differdeleted file mode 100644 index f885ff46fa1..00000000000 --- a/app/assets/images/emoji/man_with_gua_pi_mao_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/man_with_gua_pi_mao_tone5.png b/app/assets/images/emoji/man_with_gua_pi_mao_tone5.png Binary files differdeleted file mode 100644 index a6d55ca1380..00000000000 --- a/app/assets/images/emoji/man_with_gua_pi_mao_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/man_with_turban.png b/app/assets/images/emoji/man_with_turban.png Binary files differdeleted file mode 100644 index 51cf047f966..00000000000 --- a/app/assets/images/emoji/man_with_turban.png +++ /dev/null diff --git a/app/assets/images/emoji/man_with_turban_tone1.png b/app/assets/images/emoji/man_with_turban_tone1.png Binary files differdeleted file mode 100644 index 1e12ee4b231..00000000000 --- a/app/assets/images/emoji/man_with_turban_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/man_with_turban_tone2.png b/app/assets/images/emoji/man_with_turban_tone2.png Binary files differdeleted file mode 100644 index 37de4cceb23..00000000000 --- a/app/assets/images/emoji/man_with_turban_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/man_with_turban_tone3.png b/app/assets/images/emoji/man_with_turban_tone3.png Binary files differdeleted file mode 100644 index f607afd3450..00000000000 --- a/app/assets/images/emoji/man_with_turban_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/man_with_turban_tone4.png b/app/assets/images/emoji/man_with_turban_tone4.png Binary files differdeleted file mode 100644 index c05695888af..00000000000 --- a/app/assets/images/emoji/man_with_turban_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/man_with_turban_tone5.png b/app/assets/images/emoji/man_with_turban_tone5.png Binary files differdeleted file mode 100644 index 4b4ff64720b..00000000000 --- a/app/assets/images/emoji/man_with_turban_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/mans_shoe.png b/app/assets/images/emoji/mans_shoe.png Binary files differdeleted file mode 100644 index 4bf7541032c..00000000000 --- a/app/assets/images/emoji/mans_shoe.png +++ /dev/null diff --git a/app/assets/images/emoji/map.png b/app/assets/images/emoji/map.png Binary files differdeleted file mode 100644 index 15efe32c798..00000000000 --- a/app/assets/images/emoji/map.png +++ /dev/null diff --git a/app/assets/images/emoji/maple_leaf.png b/app/assets/images/emoji/maple_leaf.png Binary files differdeleted file mode 100644 index c49acea67f7..00000000000 --- a/app/assets/images/emoji/maple_leaf.png +++ /dev/null diff --git a/app/assets/images/emoji/martial_arts_uniform.png b/app/assets/images/emoji/martial_arts_uniform.png Binary files differdeleted file mode 100644 index 8d6114761f6..00000000000 --- a/app/assets/images/emoji/martial_arts_uniform.png +++ /dev/null diff --git a/app/assets/images/emoji/mask.png b/app/assets/images/emoji/mask.png Binary files differdeleted file mode 100644 index 1e800acd1c0..00000000000 --- a/app/assets/images/emoji/mask.png +++ /dev/null diff --git a/app/assets/images/emoji/massage.png b/app/assets/images/emoji/massage.png Binary files differdeleted file mode 100644 index b91d845e374..00000000000 --- a/app/assets/images/emoji/massage.png +++ /dev/null diff --git a/app/assets/images/emoji/massage_tone1.png b/app/assets/images/emoji/massage_tone1.png Binary files differdeleted file mode 100644 index e0f415d3186..00000000000 --- a/app/assets/images/emoji/massage_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/massage_tone2.png b/app/assets/images/emoji/massage_tone2.png Binary files differdeleted file mode 100644 index 0bb244a270b..00000000000 --- a/app/assets/images/emoji/massage_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/massage_tone3.png b/app/assets/images/emoji/massage_tone3.png Binary files differdeleted file mode 100644 index a117ee81a22..00000000000 --- a/app/assets/images/emoji/massage_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/massage_tone4.png b/app/assets/images/emoji/massage_tone4.png Binary files differdeleted file mode 100644 index 6f42ab017f4..00000000000 --- a/app/assets/images/emoji/massage_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/massage_tone5.png b/app/assets/images/emoji/massage_tone5.png Binary files differdeleted file mode 100644 index 6a388c0d0b5..00000000000 --- a/app/assets/images/emoji/massage_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/meat_on_bone.png b/app/assets/images/emoji/meat_on_bone.png Binary files differdeleted file mode 100644 index b20a59d1690..00000000000 --- a/app/assets/images/emoji/meat_on_bone.png +++ /dev/null diff --git a/app/assets/images/emoji/medal.png b/app/assets/images/emoji/medal.png Binary files differdeleted file mode 100644 index b85896b14da..00000000000 --- a/app/assets/images/emoji/medal.png +++ /dev/null diff --git a/app/assets/images/emoji/mega.png b/app/assets/images/emoji/mega.png Binary files differdeleted file mode 100644 index 4e6735188e3..00000000000 --- a/app/assets/images/emoji/mega.png +++ /dev/null diff --git a/app/assets/images/emoji/melon.png b/app/assets/images/emoji/melon.png Binary files differdeleted file mode 100644 index c01232d419d..00000000000 --- a/app/assets/images/emoji/melon.png +++ /dev/null diff --git a/app/assets/images/emoji/menorah.png b/app/assets/images/emoji/menorah.png Binary files differdeleted file mode 100644 index b4297362869..00000000000 --- a/app/assets/images/emoji/menorah.png +++ /dev/null diff --git a/app/assets/images/emoji/mens.png b/app/assets/images/emoji/mens.png Binary files differdeleted file mode 100644 index f5a1e1ba0cd..00000000000 --- a/app/assets/images/emoji/mens.png +++ /dev/null diff --git a/app/assets/images/emoji/metal.png b/app/assets/images/emoji/metal.png Binary files differdeleted file mode 100644 index 4aa6e7e0a44..00000000000 --- a/app/assets/images/emoji/metal.png +++ /dev/null diff --git a/app/assets/images/emoji/metal_tone1.png b/app/assets/images/emoji/metal_tone1.png Binary files differdeleted file mode 100644 index c080d2addbd..00000000000 --- a/app/assets/images/emoji/metal_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/metal_tone2.png b/app/assets/images/emoji/metal_tone2.png Binary files differdeleted file mode 100644 index 12313529bcf..00000000000 --- a/app/assets/images/emoji/metal_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/metal_tone3.png b/app/assets/images/emoji/metal_tone3.png Binary files differdeleted file mode 100644 index ca9be6ae67b..00000000000 --- a/app/assets/images/emoji/metal_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/metal_tone4.png b/app/assets/images/emoji/metal_tone4.png Binary files differdeleted file mode 100644 index abe28cbf890..00000000000 --- a/app/assets/images/emoji/metal_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/metal_tone5.png b/app/assets/images/emoji/metal_tone5.png Binary files differdeleted file mode 100644 index 0c6b5dd34ed..00000000000 --- a/app/assets/images/emoji/metal_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/metro.png b/app/assets/images/emoji/metro.png Binary files differdeleted file mode 100644 index 1de8f0551f3..00000000000 --- a/app/assets/images/emoji/metro.png +++ /dev/null diff --git a/app/assets/images/emoji/microphone.png b/app/assets/images/emoji/microphone.png Binary files differdeleted file mode 100644 index d4e6b0def25..00000000000 --- a/app/assets/images/emoji/microphone.png +++ /dev/null diff --git a/app/assets/images/emoji/microphone2.png b/app/assets/images/emoji/microphone2.png Binary files differdeleted file mode 100644 index cd9167654ff..00000000000 --- a/app/assets/images/emoji/microphone2.png +++ /dev/null diff --git a/app/assets/images/emoji/microscope.png b/app/assets/images/emoji/microscope.png Binary files differdeleted file mode 100644 index 90f5acf6a78..00000000000 --- a/app/assets/images/emoji/microscope.png +++ /dev/null diff --git a/app/assets/images/emoji/middle_finger.png b/app/assets/images/emoji/middle_finger.png Binary files differdeleted file mode 100644 index 697f7a25eb2..00000000000 --- a/app/assets/images/emoji/middle_finger.png +++ /dev/null diff --git a/app/assets/images/emoji/middle_finger_tone1.png b/app/assets/images/emoji/middle_finger_tone1.png Binary files differdeleted file mode 100644 index 61ef12a1548..00000000000 --- a/app/assets/images/emoji/middle_finger_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/middle_finger_tone2.png b/app/assets/images/emoji/middle_finger_tone2.png Binary files differdeleted file mode 100644 index c31a69be9af..00000000000 --- a/app/assets/images/emoji/middle_finger_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/middle_finger_tone3.png b/app/assets/images/emoji/middle_finger_tone3.png Binary files differdeleted file mode 100644 index 73ac216ce63..00000000000 --- a/app/assets/images/emoji/middle_finger_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/middle_finger_tone4.png b/app/assets/images/emoji/middle_finger_tone4.png Binary files differdeleted file mode 100644 index 80b8ab7706d..00000000000 --- a/app/assets/images/emoji/middle_finger_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/middle_finger_tone5.png b/app/assets/images/emoji/middle_finger_tone5.png Binary files differdeleted file mode 100644 index a8826b196e8..00000000000 --- a/app/assets/images/emoji/middle_finger_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/military_medal.png b/app/assets/images/emoji/military_medal.png Binary files differdeleted file mode 100644 index ecd3fb03584..00000000000 --- a/app/assets/images/emoji/military_medal.png +++ /dev/null diff --git a/app/assets/images/emoji/milk.png b/app/assets/images/emoji/milk.png Binary files differdeleted file mode 100644 index e4fcf2e64f3..00000000000 --- a/app/assets/images/emoji/milk.png +++ /dev/null diff --git a/app/assets/images/emoji/milky_way.png b/app/assets/images/emoji/milky_way.png Binary files differdeleted file mode 100644 index b2b8ac59c5e..00000000000 --- a/app/assets/images/emoji/milky_way.png +++ /dev/null diff --git a/app/assets/images/emoji/minibus.png b/app/assets/images/emoji/minibus.png Binary files differdeleted file mode 100644 index c60dd8f47ab..00000000000 --- a/app/assets/images/emoji/minibus.png +++ /dev/null diff --git a/app/assets/images/emoji/minidisc.png b/app/assets/images/emoji/minidisc.png Binary files differdeleted file mode 100644 index 9fa94cfbe74..00000000000 --- a/app/assets/images/emoji/minidisc.png +++ /dev/null diff --git a/app/assets/images/emoji/mobile_phone_off.png b/app/assets/images/emoji/mobile_phone_off.png Binary files differdeleted file mode 100644 index 8b661ec1c94..00000000000 --- a/app/assets/images/emoji/mobile_phone_off.png +++ /dev/null diff --git a/app/assets/images/emoji/money_mouth.png b/app/assets/images/emoji/money_mouth.png Binary files differdeleted file mode 100644 index 75fd1e90cb0..00000000000 --- a/app/assets/images/emoji/money_mouth.png +++ /dev/null diff --git a/app/assets/images/emoji/money_with_wings.png b/app/assets/images/emoji/money_with_wings.png Binary files differdeleted file mode 100644 index f022b04b3c2..00000000000 --- a/app/assets/images/emoji/money_with_wings.png +++ /dev/null diff --git a/app/assets/images/emoji/moneybag.png b/app/assets/images/emoji/moneybag.png Binary files differdeleted file mode 100644 index b9296be0902..00000000000 --- a/app/assets/images/emoji/moneybag.png +++ /dev/null diff --git a/app/assets/images/emoji/monkey.png b/app/assets/images/emoji/monkey.png Binary files differdeleted file mode 100644 index 9fae29448e3..00000000000 --- a/app/assets/images/emoji/monkey.png +++ /dev/null diff --git a/app/assets/images/emoji/monkey_face.png b/app/assets/images/emoji/monkey_face.png Binary files differdeleted file mode 100644 index 7cab9b91a82..00000000000 --- a/app/assets/images/emoji/monkey_face.png +++ /dev/null diff --git a/app/assets/images/emoji/monorail.png b/app/assets/images/emoji/monorail.png Binary files differdeleted file mode 100644 index 11eb1f574bf..00000000000 --- a/app/assets/images/emoji/monorail.png +++ /dev/null diff --git a/app/assets/images/emoji/mortar_board.png b/app/assets/images/emoji/mortar_board.png Binary files differdeleted file mode 100644 index 8b17ddd9d00..00000000000 --- a/app/assets/images/emoji/mortar_board.png +++ /dev/null diff --git a/app/assets/images/emoji/mosque.png b/app/assets/images/emoji/mosque.png Binary files differdeleted file mode 100644 index ef770b26d96..00000000000 --- a/app/assets/images/emoji/mosque.png +++ /dev/null diff --git a/app/assets/images/emoji/motor_scooter.png b/app/assets/images/emoji/motor_scooter.png Binary files differdeleted file mode 100644 index c5afa72d807..00000000000 --- a/app/assets/images/emoji/motor_scooter.png +++ /dev/null diff --git a/app/assets/images/emoji/motorboat.png b/app/assets/images/emoji/motorboat.png Binary files differdeleted file mode 100644 index 0506db1a40f..00000000000 --- a/app/assets/images/emoji/motorboat.png +++ /dev/null diff --git a/app/assets/images/emoji/motorcycle.png b/app/assets/images/emoji/motorcycle.png Binary files differdeleted file mode 100644 index 3d1d567e8ec..00000000000 --- a/app/assets/images/emoji/motorcycle.png +++ /dev/null diff --git a/app/assets/images/emoji/motorway.png b/app/assets/images/emoji/motorway.png Binary files differdeleted file mode 100644 index 8c3d3d03e3f..00000000000 --- a/app/assets/images/emoji/motorway.png +++ /dev/null diff --git a/app/assets/images/emoji/mount_fuji.png b/app/assets/images/emoji/mount_fuji.png Binary files differdeleted file mode 100644 index 88a54752458..00000000000 --- a/app/assets/images/emoji/mount_fuji.png +++ /dev/null diff --git a/app/assets/images/emoji/mountain.png b/app/assets/images/emoji/mountain.png Binary files differdeleted file mode 100644 index 6722ebdd294..00000000000 --- a/app/assets/images/emoji/mountain.png +++ /dev/null diff --git a/app/assets/images/emoji/mountain_bicyclist.png b/app/assets/images/emoji/mountain_bicyclist.png Binary files differdeleted file mode 100644 index 41d3dc3ac6f..00000000000 --- a/app/assets/images/emoji/mountain_bicyclist.png +++ /dev/null diff --git a/app/assets/images/emoji/mountain_bicyclist_tone1.png b/app/assets/images/emoji/mountain_bicyclist_tone1.png Binary files differdeleted file mode 100644 index e9f1daf5e40..00000000000 --- a/app/assets/images/emoji/mountain_bicyclist_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/mountain_bicyclist_tone2.png b/app/assets/images/emoji/mountain_bicyclist_tone2.png Binary files differdeleted file mode 100644 index 555b9e29d4d..00000000000 --- a/app/assets/images/emoji/mountain_bicyclist_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/mountain_bicyclist_tone3.png b/app/assets/images/emoji/mountain_bicyclist_tone3.png Binary files differdeleted file mode 100644 index 7df5508ec8c..00000000000 --- a/app/assets/images/emoji/mountain_bicyclist_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/mountain_bicyclist_tone4.png b/app/assets/images/emoji/mountain_bicyclist_tone4.png Binary files differdeleted file mode 100644 index f94b3450697..00000000000 --- a/app/assets/images/emoji/mountain_bicyclist_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/mountain_bicyclist_tone5.png b/app/assets/images/emoji/mountain_bicyclist_tone5.png Binary files differdeleted file mode 100644 index 16a45861e1f..00000000000 --- a/app/assets/images/emoji/mountain_bicyclist_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/mountain_cableway.png b/app/assets/images/emoji/mountain_cableway.png Binary files differdeleted file mode 100644 index 1dea73ca53b..00000000000 --- a/app/assets/images/emoji/mountain_cableway.png +++ /dev/null diff --git a/app/assets/images/emoji/mountain_railway.png b/app/assets/images/emoji/mountain_railway.png Binary files differdeleted file mode 100644 index ade2218e469..00000000000 --- a/app/assets/images/emoji/mountain_railway.png +++ /dev/null diff --git a/app/assets/images/emoji/mountain_snow.png b/app/assets/images/emoji/mountain_snow.png Binary files differdeleted file mode 100644 index 76e1cfd8313..00000000000 --- a/app/assets/images/emoji/mountain_snow.png +++ /dev/null diff --git a/app/assets/images/emoji/mouse.png b/app/assets/images/emoji/mouse.png Binary files differdeleted file mode 100644 index 50afcd3262e..00000000000 --- a/app/assets/images/emoji/mouse.png +++ /dev/null diff --git a/app/assets/images/emoji/mouse2.png b/app/assets/images/emoji/mouse2.png Binary files differdeleted file mode 100644 index 20fb041f09f..00000000000 --- a/app/assets/images/emoji/mouse2.png +++ /dev/null diff --git a/app/assets/images/emoji/mouse_three_button.png b/app/assets/images/emoji/mouse_three_button.png Binary files differdeleted file mode 100644 index e84e96ff6e8..00000000000 --- a/app/assets/images/emoji/mouse_three_button.png +++ /dev/null diff --git a/app/assets/images/emoji/movie_camera.png b/app/assets/images/emoji/movie_camera.png Binary files differdeleted file mode 100644 index 4e73b130155..00000000000 --- a/app/assets/images/emoji/movie_camera.png +++ /dev/null diff --git a/app/assets/images/emoji/moyai.png b/app/assets/images/emoji/moyai.png Binary files differdeleted file mode 100644 index e6a7779c45b..00000000000 --- a/app/assets/images/emoji/moyai.png +++ /dev/null diff --git a/app/assets/images/emoji/mrs_claus.png b/app/assets/images/emoji/mrs_claus.png Binary files differdeleted file mode 100644 index 9cf2458df1a..00000000000 --- a/app/assets/images/emoji/mrs_claus.png +++ /dev/null diff --git a/app/assets/images/emoji/mrs_claus_tone1.png b/app/assets/images/emoji/mrs_claus_tone1.png Binary files differdeleted file mode 100644 index d8a695d7035..00000000000 --- a/app/assets/images/emoji/mrs_claus_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/mrs_claus_tone2.png b/app/assets/images/emoji/mrs_claus_tone2.png Binary files differdeleted file mode 100644 index 0e17e8c51f3..00000000000 --- a/app/assets/images/emoji/mrs_claus_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/mrs_claus_tone3.png b/app/assets/images/emoji/mrs_claus_tone3.png Binary files differdeleted file mode 100644 index c3ee4d1dfae..00000000000 --- a/app/assets/images/emoji/mrs_claus_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/mrs_claus_tone4.png b/app/assets/images/emoji/mrs_claus_tone4.png Binary files differdeleted file mode 100644 index 68a556da2fe..00000000000 --- a/app/assets/images/emoji/mrs_claus_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/mrs_claus_tone5.png b/app/assets/images/emoji/mrs_claus_tone5.png Binary files differdeleted file mode 100644 index ccab3c40ff2..00000000000 --- a/app/assets/images/emoji/mrs_claus_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/muscle.png b/app/assets/images/emoji/muscle.png Binary files differdeleted file mode 100644 index 7e67c1880f7..00000000000 --- a/app/assets/images/emoji/muscle.png +++ /dev/null diff --git a/app/assets/images/emoji/muscle_tone1.png b/app/assets/images/emoji/muscle_tone1.png Binary files differdeleted file mode 100644 index 1522942ce51..00000000000 --- a/app/assets/images/emoji/muscle_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/muscle_tone2.png b/app/assets/images/emoji/muscle_tone2.png Binary files differdeleted file mode 100644 index 569c6e832ca..00000000000 --- a/app/assets/images/emoji/muscle_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/muscle_tone3.png b/app/assets/images/emoji/muscle_tone3.png Binary files differdeleted file mode 100644 index 0a76b00fa89..00000000000 --- a/app/assets/images/emoji/muscle_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/muscle_tone4.png b/app/assets/images/emoji/muscle_tone4.png Binary files differdeleted file mode 100644 index f0cf31328e0..00000000000 --- a/app/assets/images/emoji/muscle_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/muscle_tone5.png b/app/assets/images/emoji/muscle_tone5.png Binary files differdeleted file mode 100644 index 4fda92460e8..00000000000 --- a/app/assets/images/emoji/muscle_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/mushroom.png b/app/assets/images/emoji/mushroom.png Binary files differdeleted file mode 100644 index dd85742ba2c..00000000000 --- a/app/assets/images/emoji/mushroom.png +++ /dev/null diff --git a/app/assets/images/emoji/musical_keyboard.png b/app/assets/images/emoji/musical_keyboard.png Binary files differdeleted file mode 100644 index 442b7456842..00000000000 --- a/app/assets/images/emoji/musical_keyboard.png +++ /dev/null diff --git a/app/assets/images/emoji/musical_note.png b/app/assets/images/emoji/musical_note.png Binary files differdeleted file mode 100644 index 06691ef61bb..00000000000 --- a/app/assets/images/emoji/musical_note.png +++ /dev/null diff --git a/app/assets/images/emoji/musical_score.png b/app/assets/images/emoji/musical_score.png Binary files differdeleted file mode 100644 index 47dc05a8ef5..00000000000 --- a/app/assets/images/emoji/musical_score.png +++ /dev/null diff --git a/app/assets/images/emoji/mute.png b/app/assets/images/emoji/mute.png Binary files differdeleted file mode 100644 index 7c1788e5075..00000000000 --- a/app/assets/images/emoji/mute.png +++ /dev/null diff --git a/app/assets/images/emoji/nail_care.png b/app/assets/images/emoji/nail_care.png Binary files differdeleted file mode 100644 index aa52af7050d..00000000000 --- a/app/assets/images/emoji/nail_care.png +++ /dev/null diff --git a/app/assets/images/emoji/nail_care_tone1.png b/app/assets/images/emoji/nail_care_tone1.png Binary files differdeleted file mode 100644 index 26e883dd244..00000000000 --- a/app/assets/images/emoji/nail_care_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/nail_care_tone2.png b/app/assets/images/emoji/nail_care_tone2.png Binary files differdeleted file mode 100644 index 61257b47ea3..00000000000 --- a/app/assets/images/emoji/nail_care_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/nail_care_tone3.png b/app/assets/images/emoji/nail_care_tone3.png Binary files differdeleted file mode 100644 index 29871b05f62..00000000000 --- a/app/assets/images/emoji/nail_care_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/nail_care_tone4.png b/app/assets/images/emoji/nail_care_tone4.png Binary files differdeleted file mode 100644 index 2881de0b17d..00000000000 --- a/app/assets/images/emoji/nail_care_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/nail_care_tone5.png b/app/assets/images/emoji/nail_care_tone5.png Binary files differdeleted file mode 100644 index a0b7c0a45a6..00000000000 --- a/app/assets/images/emoji/nail_care_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/name_badge.png b/app/assets/images/emoji/name_badge.png Binary files differdeleted file mode 100644 index ec5ee213e20..00000000000 --- a/app/assets/images/emoji/name_badge.png +++ /dev/null diff --git a/app/assets/images/emoji/nauseated_face.png b/app/assets/images/emoji/nauseated_face.png Binary files differdeleted file mode 100644 index a566c109c28..00000000000 --- a/app/assets/images/emoji/nauseated_face.png +++ /dev/null diff --git a/app/assets/images/emoji/necktie.png b/app/assets/images/emoji/necktie.png Binary files differdeleted file mode 100644 index 1804e7f3ff3..00000000000 --- a/app/assets/images/emoji/necktie.png +++ /dev/null diff --git a/app/assets/images/emoji/negative_squared_cross_mark.png b/app/assets/images/emoji/negative_squared_cross_mark.png Binary files differdeleted file mode 100644 index dae487f1f98..00000000000 --- a/app/assets/images/emoji/negative_squared_cross_mark.png +++ /dev/null diff --git a/app/assets/images/emoji/nerd.png b/app/assets/images/emoji/nerd.png Binary files differdeleted file mode 100644 index 7820bd581dc..00000000000 --- a/app/assets/images/emoji/nerd.png +++ /dev/null diff --git a/app/assets/images/emoji/neutral_face.png b/app/assets/images/emoji/neutral_face.png Binary files differdeleted file mode 100644 index 065d193afe4..00000000000 --- a/app/assets/images/emoji/neutral_face.png +++ /dev/null diff --git a/app/assets/images/emoji/new.png b/app/assets/images/emoji/new.png Binary files differdeleted file mode 100644 index b4f85488d1a..00000000000 --- a/app/assets/images/emoji/new.png +++ /dev/null diff --git a/app/assets/images/emoji/new_moon.png b/app/assets/images/emoji/new_moon.png Binary files differdeleted file mode 100644 index ecff72caa42..00000000000 --- a/app/assets/images/emoji/new_moon.png +++ /dev/null diff --git a/app/assets/images/emoji/new_moon_with_face.png b/app/assets/images/emoji/new_moon_with_face.png Binary files differdeleted file mode 100644 index 150dd12400c..00000000000 --- a/app/assets/images/emoji/new_moon_with_face.png +++ /dev/null diff --git a/app/assets/images/emoji/newspaper.png b/app/assets/images/emoji/newspaper.png Binary files differdeleted file mode 100644 index 2aa8f060bde..00000000000 --- a/app/assets/images/emoji/newspaper.png +++ /dev/null diff --git a/app/assets/images/emoji/newspaper2.png b/app/assets/images/emoji/newspaper2.png Binary files differdeleted file mode 100644 index f64748df2b2..00000000000 --- a/app/assets/images/emoji/newspaper2.png +++ /dev/null diff --git a/app/assets/images/emoji/ng.png b/app/assets/images/emoji/ng.png Binary files differdeleted file mode 100644 index ee8d20f5ebc..00000000000 --- a/app/assets/images/emoji/ng.png +++ /dev/null diff --git a/app/assets/images/emoji/night_with_stars.png b/app/assets/images/emoji/night_with_stars.png Binary files differdeleted file mode 100644 index ca2018f456d..00000000000 --- a/app/assets/images/emoji/night_with_stars.png +++ /dev/null diff --git a/app/assets/images/emoji/nine.png b/app/assets/images/emoji/nine.png Binary files differdeleted file mode 100644 index 9fce3d1eca9..00000000000 --- a/app/assets/images/emoji/nine.png +++ /dev/null diff --git a/app/assets/images/emoji/no_bell.png b/app/assets/images/emoji/no_bell.png Binary files differdeleted file mode 100644 index 15cb38dd1e7..00000000000 --- a/app/assets/images/emoji/no_bell.png +++ /dev/null diff --git a/app/assets/images/emoji/no_bicycles.png b/app/assets/images/emoji/no_bicycles.png Binary files differdeleted file mode 100644 index 19c85421ce9..00000000000 --- a/app/assets/images/emoji/no_bicycles.png +++ /dev/null diff --git a/app/assets/images/emoji/no_entry.png b/app/assets/images/emoji/no_entry.png Binary files differdeleted file mode 100644 index 476800fc5c6..00000000000 --- a/app/assets/images/emoji/no_entry.png +++ /dev/null diff --git a/app/assets/images/emoji/no_entry_sign.png b/app/assets/images/emoji/no_entry_sign.png Binary files differdeleted file mode 100644 index d2efd65e74b..00000000000 --- a/app/assets/images/emoji/no_entry_sign.png +++ /dev/null diff --git a/app/assets/images/emoji/no_good.png b/app/assets/images/emoji/no_good.png Binary files differdeleted file mode 100644 index ed577100322..00000000000 --- a/app/assets/images/emoji/no_good.png +++ /dev/null diff --git a/app/assets/images/emoji/no_good_tone1.png b/app/assets/images/emoji/no_good_tone1.png Binary files differdeleted file mode 100644 index 5c1a3cbb884..00000000000 --- a/app/assets/images/emoji/no_good_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/no_good_tone2.png b/app/assets/images/emoji/no_good_tone2.png Binary files differdeleted file mode 100644 index 80d8021f8fe..00000000000 --- a/app/assets/images/emoji/no_good_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/no_good_tone3.png b/app/assets/images/emoji/no_good_tone3.png Binary files differdeleted file mode 100644 index 635e6a00815..00000000000 --- a/app/assets/images/emoji/no_good_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/no_good_tone4.png b/app/assets/images/emoji/no_good_tone4.png Binary files differdeleted file mode 100644 index b96e412a374..00000000000 --- a/app/assets/images/emoji/no_good_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/no_good_tone5.png b/app/assets/images/emoji/no_good_tone5.png Binary files differdeleted file mode 100644 index 9a7084afa0a..00000000000 --- a/app/assets/images/emoji/no_good_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/no_mobile_phones.png b/app/assets/images/emoji/no_mobile_phones.png Binary files differdeleted file mode 100644 index 7b1ae6ea579..00000000000 --- a/app/assets/images/emoji/no_mobile_phones.png +++ /dev/null diff --git a/app/assets/images/emoji/no_mouth.png b/app/assets/images/emoji/no_mouth.png Binary files differdeleted file mode 100644 index b642f6c1172..00000000000 --- a/app/assets/images/emoji/no_mouth.png +++ /dev/null diff --git a/app/assets/images/emoji/no_pedestrians.png b/app/assets/images/emoji/no_pedestrians.png Binary files differdeleted file mode 100644 index 286aa577a23..00000000000 --- a/app/assets/images/emoji/no_pedestrians.png +++ /dev/null diff --git a/app/assets/images/emoji/no_smoking.png b/app/assets/images/emoji/no_smoking.png Binary files differdeleted file mode 100644 index 586b8d29d05..00000000000 --- a/app/assets/images/emoji/no_smoking.png +++ /dev/null diff --git a/app/assets/images/emoji/non-potable_water.png b/app/assets/images/emoji/non-potable_water.png Binary files differdeleted file mode 100644 index 827d4193f4e..00000000000 --- a/app/assets/images/emoji/non-potable_water.png +++ /dev/null diff --git a/app/assets/images/emoji/nose.png b/app/assets/images/emoji/nose.png Binary files differdeleted file mode 100644 index 2f04ac5f98f..00000000000 --- a/app/assets/images/emoji/nose.png +++ /dev/null diff --git a/app/assets/images/emoji/nose_tone1.png b/app/assets/images/emoji/nose_tone1.png Binary files differdeleted file mode 100644 index 8008d17506e..00000000000 --- a/app/assets/images/emoji/nose_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/nose_tone2.png b/app/assets/images/emoji/nose_tone2.png Binary files differdeleted file mode 100644 index ac17f26e827..00000000000 --- a/app/assets/images/emoji/nose_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/nose_tone3.png b/app/assets/images/emoji/nose_tone3.png Binary files differdeleted file mode 100644 index d8b6cbe0f8e..00000000000 --- a/app/assets/images/emoji/nose_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/nose_tone4.png b/app/assets/images/emoji/nose_tone4.png Binary files differdeleted file mode 100644 index 004b2631e2e..00000000000 --- a/app/assets/images/emoji/nose_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/nose_tone5.png b/app/assets/images/emoji/nose_tone5.png Binary files differdeleted file mode 100644 index 7b33821f6c9..00000000000 --- a/app/assets/images/emoji/nose_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/notebook.png b/app/assets/images/emoji/notebook.png Binary files differdeleted file mode 100644 index f6c28b4915d..00000000000 --- a/app/assets/images/emoji/notebook.png +++ /dev/null diff --git a/app/assets/images/emoji/notebook_with_decorative_cover.png b/app/assets/images/emoji/notebook_with_decorative_cover.png Binary files differdeleted file mode 100644 index 03f566b6d2c..00000000000 --- a/app/assets/images/emoji/notebook_with_decorative_cover.png +++ /dev/null diff --git a/app/assets/images/emoji/notepad_spiral.png b/app/assets/images/emoji/notepad_spiral.png Binary files differdeleted file mode 100644 index 85faa10d8ea..00000000000 --- a/app/assets/images/emoji/notepad_spiral.png +++ /dev/null diff --git a/app/assets/images/emoji/notes.png b/app/assets/images/emoji/notes.png Binary files differdeleted file mode 100644 index 57d499aa181..00000000000 --- a/app/assets/images/emoji/notes.png +++ /dev/null diff --git a/app/assets/images/emoji/nut_and_bolt.png b/app/assets/images/emoji/nut_and_bolt.png Binary files differdeleted file mode 100644 index 4b9ae155319..00000000000 --- a/app/assets/images/emoji/nut_and_bolt.png +++ /dev/null diff --git a/app/assets/images/emoji/o.png b/app/assets/images/emoji/o.png Binary files differdeleted file mode 100644 index 3fe75ce4675..00000000000 --- a/app/assets/images/emoji/o.png +++ /dev/null diff --git a/app/assets/images/emoji/o2.png b/app/assets/images/emoji/o2.png Binary files differdeleted file mode 100644 index 73278ba194a..00000000000 --- a/app/assets/images/emoji/o2.png +++ /dev/null diff --git a/app/assets/images/emoji/ocean.png b/app/assets/images/emoji/ocean.png Binary files differdeleted file mode 100644 index 45ff1e87703..00000000000 --- a/app/assets/images/emoji/ocean.png +++ /dev/null diff --git a/app/assets/images/emoji/octagonal_sign.png b/app/assets/images/emoji/octagonal_sign.png Binary files differdeleted file mode 100644 index 5ed61004045..00000000000 --- a/app/assets/images/emoji/octagonal_sign.png +++ /dev/null diff --git a/app/assets/images/emoji/octopus.png b/app/assets/images/emoji/octopus.png Binary files differdeleted file mode 100644 index 72c84074aac..00000000000 --- a/app/assets/images/emoji/octopus.png +++ /dev/null diff --git a/app/assets/images/emoji/oden.png b/app/assets/images/emoji/oden.png Binary files differdeleted file mode 100644 index d38a849fece..00000000000 --- a/app/assets/images/emoji/oden.png +++ /dev/null diff --git a/app/assets/images/emoji/office.png b/app/assets/images/emoji/office.png Binary files differdeleted file mode 100644 index 7eee927d1b0..00000000000 --- a/app/assets/images/emoji/office.png +++ /dev/null diff --git a/app/assets/images/emoji/oil.png b/app/assets/images/emoji/oil.png Binary files differdeleted file mode 100644 index c4c4d42da8b..00000000000 --- a/app/assets/images/emoji/oil.png +++ /dev/null diff --git a/app/assets/images/emoji/ok.png b/app/assets/images/emoji/ok.png Binary files differdeleted file mode 100644 index d0d775532ff..00000000000 --- a/app/assets/images/emoji/ok.png +++ /dev/null diff --git a/app/assets/images/emoji/ok_hand.png b/app/assets/images/emoji/ok_hand.png Binary files differdeleted file mode 100644 index 028d69b0de3..00000000000 --- a/app/assets/images/emoji/ok_hand.png +++ /dev/null diff --git a/app/assets/images/emoji/ok_hand_tone1.png b/app/assets/images/emoji/ok_hand_tone1.png Binary files differdeleted file mode 100644 index cecf7b2ab5a..00000000000 --- a/app/assets/images/emoji/ok_hand_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/ok_hand_tone2.png b/app/assets/images/emoji/ok_hand_tone2.png Binary files differdeleted file mode 100644 index c19239bcd3d..00000000000 --- a/app/assets/images/emoji/ok_hand_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/ok_hand_tone3.png b/app/assets/images/emoji/ok_hand_tone3.png Binary files differdeleted file mode 100644 index 94b65b03ecd..00000000000 --- a/app/assets/images/emoji/ok_hand_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/ok_hand_tone4.png b/app/assets/images/emoji/ok_hand_tone4.png Binary files differdeleted file mode 100644 index 03d26f08e6a..00000000000 --- a/app/assets/images/emoji/ok_hand_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/ok_hand_tone5.png b/app/assets/images/emoji/ok_hand_tone5.png Binary files differdeleted file mode 100644 index d4b24086364..00000000000 --- a/app/assets/images/emoji/ok_hand_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/ok_woman.png b/app/assets/images/emoji/ok_woman.png Binary files differdeleted file mode 100644 index 90a2c7469c4..00000000000 --- a/app/assets/images/emoji/ok_woman.png +++ /dev/null diff --git a/app/assets/images/emoji/ok_woman_tone1.png b/app/assets/images/emoji/ok_woman_tone1.png Binary files differdeleted file mode 100644 index c99543e785b..00000000000 --- a/app/assets/images/emoji/ok_woman_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/ok_woman_tone2.png b/app/assets/images/emoji/ok_woman_tone2.png Binary files differdeleted file mode 100644 index ad5fae813db..00000000000 --- a/app/assets/images/emoji/ok_woman_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/ok_woman_tone3.png b/app/assets/images/emoji/ok_woman_tone3.png Binary files differdeleted file mode 100644 index 51bf4fab406..00000000000 --- a/app/assets/images/emoji/ok_woman_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/ok_woman_tone4.png b/app/assets/images/emoji/ok_woman_tone4.png Binary files differdeleted file mode 100644 index ee3f9dc640a..00000000000 --- a/app/assets/images/emoji/ok_woman_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/ok_woman_tone5.png b/app/assets/images/emoji/ok_woman_tone5.png Binary files differdeleted file mode 100644 index 62a9d9237f7..00000000000 --- a/app/assets/images/emoji/ok_woman_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/older_man.png b/app/assets/images/emoji/older_man.png Binary files differdeleted file mode 100644 index 4ace4e6f308..00000000000 --- a/app/assets/images/emoji/older_man.png +++ /dev/null diff --git a/app/assets/images/emoji/older_man_tone1.png b/app/assets/images/emoji/older_man_tone1.png Binary files differdeleted file mode 100644 index ab459baace8..00000000000 --- a/app/assets/images/emoji/older_man_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/older_man_tone2.png b/app/assets/images/emoji/older_man_tone2.png Binary files differdeleted file mode 100644 index f4dfc7694ea..00000000000 --- a/app/assets/images/emoji/older_man_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/older_man_tone3.png b/app/assets/images/emoji/older_man_tone3.png Binary files differdeleted file mode 100644 index 5ffd11792f4..00000000000 --- a/app/assets/images/emoji/older_man_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/older_man_tone4.png b/app/assets/images/emoji/older_man_tone4.png Binary files differdeleted file mode 100644 index b350a764bfd..00000000000 --- a/app/assets/images/emoji/older_man_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/older_man_tone5.png b/app/assets/images/emoji/older_man_tone5.png Binary files differdeleted file mode 100644 index 05fe24a1708..00000000000 --- a/app/assets/images/emoji/older_man_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/older_woman.png b/app/assets/images/emoji/older_woman.png Binary files differdeleted file mode 100644 index 52dc4987143..00000000000 --- a/app/assets/images/emoji/older_woman.png +++ /dev/null diff --git a/app/assets/images/emoji/older_woman_tone1.png b/app/assets/images/emoji/older_woman_tone1.png Binary files differdeleted file mode 100644 index b49e821402c..00000000000 --- a/app/assets/images/emoji/older_woman_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/older_woman_tone2.png b/app/assets/images/emoji/older_woman_tone2.png Binary files differdeleted file mode 100644 index e86bf5ab3b7..00000000000 --- a/app/assets/images/emoji/older_woman_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/older_woman_tone3.png b/app/assets/images/emoji/older_woman_tone3.png Binary files differdeleted file mode 100644 index 83fc14b0874..00000000000 --- a/app/assets/images/emoji/older_woman_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/older_woman_tone4.png b/app/assets/images/emoji/older_woman_tone4.png Binary files differdeleted file mode 100644 index e4aa8a424d4..00000000000 --- a/app/assets/images/emoji/older_woman_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/older_woman_tone5.png b/app/assets/images/emoji/older_woman_tone5.png Binary files differdeleted file mode 100644 index 4009012bb0a..00000000000 --- a/app/assets/images/emoji/older_woman_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/om_symbol.png b/app/assets/images/emoji/om_symbol.png Binary files differdeleted file mode 100644 index a35c63c459c..00000000000 --- a/app/assets/images/emoji/om_symbol.png +++ /dev/null diff --git a/app/assets/images/emoji/on.png b/app/assets/images/emoji/on.png Binary files differdeleted file mode 100644 index a0c371ae21e..00000000000 --- a/app/assets/images/emoji/on.png +++ /dev/null diff --git a/app/assets/images/emoji/oncoming_automobile.png b/app/assets/images/emoji/oncoming_automobile.png Binary files differdeleted file mode 100644 index 3c7e1d52e63..00000000000 --- a/app/assets/images/emoji/oncoming_automobile.png +++ /dev/null diff --git a/app/assets/images/emoji/oncoming_bus.png b/app/assets/images/emoji/oncoming_bus.png Binary files differdeleted file mode 100644 index ad91e256c7f..00000000000 --- a/app/assets/images/emoji/oncoming_bus.png +++ /dev/null diff --git a/app/assets/images/emoji/oncoming_police_car.png b/app/assets/images/emoji/oncoming_police_car.png Binary files differdeleted file mode 100644 index c9109c85b5d..00000000000 --- a/app/assets/images/emoji/oncoming_police_car.png +++ /dev/null diff --git a/app/assets/images/emoji/oncoming_taxi.png b/app/assets/images/emoji/oncoming_taxi.png Binary files differdeleted file mode 100644 index fea14e45846..00000000000 --- a/app/assets/images/emoji/oncoming_taxi.png +++ /dev/null diff --git a/app/assets/images/emoji/one.png b/app/assets/images/emoji/one.png Binary files differdeleted file mode 100644 index e6d84b80128..00000000000 --- a/app/assets/images/emoji/one.png +++ /dev/null diff --git a/app/assets/images/emoji/open_file_folder.png b/app/assets/images/emoji/open_file_folder.png Binary files differdeleted file mode 100644 index 3993b09222f..00000000000 --- a/app/assets/images/emoji/open_file_folder.png +++ /dev/null diff --git a/app/assets/images/emoji/open_hands.png b/app/assets/images/emoji/open_hands.png Binary files differdeleted file mode 100644 index 1cf75c9101e..00000000000 --- a/app/assets/images/emoji/open_hands.png +++ /dev/null diff --git a/app/assets/images/emoji/open_hands_tone1.png b/app/assets/images/emoji/open_hands_tone1.png Binary files differdeleted file mode 100644 index 352d2614f11..00000000000 --- a/app/assets/images/emoji/open_hands_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/open_hands_tone2.png b/app/assets/images/emoji/open_hands_tone2.png Binary files differdeleted file mode 100644 index 70824a50c73..00000000000 --- a/app/assets/images/emoji/open_hands_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/open_hands_tone3.png b/app/assets/images/emoji/open_hands_tone3.png Binary files differdeleted file mode 100644 index d7d136bd3db..00000000000 --- a/app/assets/images/emoji/open_hands_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/open_hands_tone4.png b/app/assets/images/emoji/open_hands_tone4.png Binary files differdeleted file mode 100644 index df4eaa711e7..00000000000 --- a/app/assets/images/emoji/open_hands_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/open_hands_tone5.png b/app/assets/images/emoji/open_hands_tone5.png Binary files differdeleted file mode 100644 index 7dc04eaebd8..00000000000 --- a/app/assets/images/emoji/open_hands_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/open_mouth.png b/app/assets/images/emoji/open_mouth.png Binary files differdeleted file mode 100644 index a62cd27e148..00000000000 --- a/app/assets/images/emoji/open_mouth.png +++ /dev/null diff --git a/app/assets/images/emoji/ophiuchus.png b/app/assets/images/emoji/ophiuchus.png Binary files differdeleted file mode 100644 index 0a780a700da..00000000000 --- a/app/assets/images/emoji/ophiuchus.png +++ /dev/null diff --git a/app/assets/images/emoji/orange_book.png b/app/assets/images/emoji/orange_book.png Binary files differdeleted file mode 100644 index ab40e6ae6a2..00000000000 --- a/app/assets/images/emoji/orange_book.png +++ /dev/null diff --git a/app/assets/images/emoji/orthodox_cross.png b/app/assets/images/emoji/orthodox_cross.png Binary files differdeleted file mode 100644 index 0530e33a4d4..00000000000 --- a/app/assets/images/emoji/orthodox_cross.png +++ /dev/null diff --git a/app/assets/images/emoji/outbox_tray.png b/app/assets/images/emoji/outbox_tray.png Binary files differdeleted file mode 100644 index 46493ed5b2c..00000000000 --- a/app/assets/images/emoji/outbox_tray.png +++ /dev/null diff --git a/app/assets/images/emoji/owl.png b/app/assets/images/emoji/owl.png Binary files differdeleted file mode 100644 index fa6815480c3..00000000000 --- a/app/assets/images/emoji/owl.png +++ /dev/null diff --git a/app/assets/images/emoji/ox.png b/app/assets/images/emoji/ox.png Binary files differdeleted file mode 100644 index badf5708f2f..00000000000 --- a/app/assets/images/emoji/ox.png +++ /dev/null diff --git a/app/assets/images/emoji/package.png b/app/assets/images/emoji/package.png Binary files differdeleted file mode 100644 index 85431756ad8..00000000000 --- a/app/assets/images/emoji/package.png +++ /dev/null diff --git a/app/assets/images/emoji/page_facing_up.png b/app/assets/images/emoji/page_facing_up.png Binary files differdeleted file mode 100644 index ba4ed757e01..00000000000 --- a/app/assets/images/emoji/page_facing_up.png +++ /dev/null diff --git a/app/assets/images/emoji/page_with_curl.png b/app/assets/images/emoji/page_with_curl.png Binary files differdeleted file mode 100644 index 06355319c74..00000000000 --- a/app/assets/images/emoji/page_with_curl.png +++ /dev/null diff --git a/app/assets/images/emoji/pager.png b/app/assets/images/emoji/pager.png Binary files differdeleted file mode 100644 index b24b99306a2..00000000000 --- a/app/assets/images/emoji/pager.png +++ /dev/null diff --git a/app/assets/images/emoji/paintbrush.png b/app/assets/images/emoji/paintbrush.png Binary files differdeleted file mode 100644 index 28bffbaa3c9..00000000000 --- a/app/assets/images/emoji/paintbrush.png +++ /dev/null diff --git a/app/assets/images/emoji/palm_tree.png b/app/assets/images/emoji/palm_tree.png Binary files differdeleted file mode 100644 index 4bbb10f4f19..00000000000 --- a/app/assets/images/emoji/palm_tree.png +++ /dev/null diff --git a/app/assets/images/emoji/pancakes.png b/app/assets/images/emoji/pancakes.png Binary files differdeleted file mode 100644 index 6223d1a28e9..00000000000 --- a/app/assets/images/emoji/pancakes.png +++ /dev/null diff --git a/app/assets/images/emoji/panda_face.png b/app/assets/images/emoji/panda_face.png Binary files differdeleted file mode 100644 index 978382775ce..00000000000 --- a/app/assets/images/emoji/panda_face.png +++ /dev/null diff --git a/app/assets/images/emoji/paperclip.png b/app/assets/images/emoji/paperclip.png Binary files differdeleted file mode 100644 index 8cd8d4f8750..00000000000 --- a/app/assets/images/emoji/paperclip.png +++ /dev/null diff --git a/app/assets/images/emoji/paperclips.png b/app/assets/images/emoji/paperclips.png Binary files differdeleted file mode 100644 index 76021e8c705..00000000000 --- a/app/assets/images/emoji/paperclips.png +++ /dev/null diff --git a/app/assets/images/emoji/park.png b/app/assets/images/emoji/park.png Binary files differdeleted file mode 100644 index 63ec7016301..00000000000 --- a/app/assets/images/emoji/park.png +++ /dev/null diff --git a/app/assets/images/emoji/parking.png b/app/assets/images/emoji/parking.png Binary files differdeleted file mode 100644 index 7be7dac27e8..00000000000 --- a/app/assets/images/emoji/parking.png +++ /dev/null diff --git a/app/assets/images/emoji/part_alternation_mark.png b/app/assets/images/emoji/part_alternation_mark.png Binary files differdeleted file mode 100644 index 70453d41528..00000000000 --- a/app/assets/images/emoji/part_alternation_mark.png +++ /dev/null diff --git a/app/assets/images/emoji/partly_sunny.png b/app/assets/images/emoji/partly_sunny.png Binary files differdeleted file mode 100644 index a55e59c344c..00000000000 --- a/app/assets/images/emoji/partly_sunny.png +++ /dev/null diff --git a/app/assets/images/emoji/passport_control.png b/app/assets/images/emoji/passport_control.png Binary files differdeleted file mode 100644 index 079e34ee4d4..00000000000 --- a/app/assets/images/emoji/passport_control.png +++ /dev/null diff --git a/app/assets/images/emoji/pause_button.png b/app/assets/images/emoji/pause_button.png Binary files differdeleted file mode 100644 index 4f07e7ebfd7..00000000000 --- a/app/assets/images/emoji/pause_button.png +++ /dev/null diff --git a/app/assets/images/emoji/peace.png b/app/assets/images/emoji/peace.png Binary files differdeleted file mode 100644 index 86033faf477..00000000000 --- a/app/assets/images/emoji/peace.png +++ /dev/null diff --git a/app/assets/images/emoji/peach.png b/app/assets/images/emoji/peach.png Binary files differdeleted file mode 100644 index 9ab57cbb758..00000000000 --- a/app/assets/images/emoji/peach.png +++ /dev/null diff --git a/app/assets/images/emoji/peanuts.png b/app/assets/images/emoji/peanuts.png Binary files differdeleted file mode 100644 index b64fadad010..00000000000 --- a/app/assets/images/emoji/peanuts.png +++ /dev/null diff --git a/app/assets/images/emoji/pear.png b/app/assets/images/emoji/pear.png Binary files differdeleted file mode 100644 index 3869f718bcf..00000000000 --- a/app/assets/images/emoji/pear.png +++ /dev/null diff --git a/app/assets/images/emoji/pen_ballpoint.png b/app/assets/images/emoji/pen_ballpoint.png Binary files differdeleted file mode 100644 index 6ef7a342433..00000000000 --- a/app/assets/images/emoji/pen_ballpoint.png +++ /dev/null diff --git a/app/assets/images/emoji/pen_fountain.png b/app/assets/images/emoji/pen_fountain.png Binary files differdeleted file mode 100644 index 3ca4bd2c231..00000000000 --- a/app/assets/images/emoji/pen_fountain.png +++ /dev/null diff --git a/app/assets/images/emoji/pencil.png b/app/assets/images/emoji/pencil.png Binary files differdeleted file mode 100644 index edc6155e168..00000000000 --- a/app/assets/images/emoji/pencil.png +++ /dev/null diff --git a/app/assets/images/emoji/pencil2.png b/app/assets/images/emoji/pencil2.png Binary files differdeleted file mode 100644 index 3833d590fa2..00000000000 --- a/app/assets/images/emoji/pencil2.png +++ /dev/null diff --git a/app/assets/images/emoji/penguin.png b/app/assets/images/emoji/penguin.png Binary files differdeleted file mode 100644 index c0064fb9734..00000000000 --- a/app/assets/images/emoji/penguin.png +++ /dev/null diff --git a/app/assets/images/emoji/pensive.png b/app/assets/images/emoji/pensive.png Binary files differdeleted file mode 100644 index 490fb566954..00000000000 --- a/app/assets/images/emoji/pensive.png +++ /dev/null diff --git a/app/assets/images/emoji/performing_arts.png b/app/assets/images/emoji/performing_arts.png Binary files differdeleted file mode 100644 index 685441fdaa1..00000000000 --- a/app/assets/images/emoji/performing_arts.png +++ /dev/null diff --git a/app/assets/images/emoji/persevere.png b/app/assets/images/emoji/persevere.png Binary files differdeleted file mode 100644 index 646a05fe908..00000000000 --- a/app/assets/images/emoji/persevere.png +++ /dev/null diff --git a/app/assets/images/emoji/person_frowning.png b/app/assets/images/emoji/person_frowning.png Binary files differdeleted file mode 100644 index 579324959a1..00000000000 --- a/app/assets/images/emoji/person_frowning.png +++ /dev/null diff --git a/app/assets/images/emoji/person_frowning_tone1.png b/app/assets/images/emoji/person_frowning_tone1.png Binary files differdeleted file mode 100644 index 21d3bb43923..00000000000 --- a/app/assets/images/emoji/person_frowning_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/person_frowning_tone2.png b/app/assets/images/emoji/person_frowning_tone2.png Binary files differdeleted file mode 100644 index 973f5fc8382..00000000000 --- a/app/assets/images/emoji/person_frowning_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/person_frowning_tone3.png b/app/assets/images/emoji/person_frowning_tone3.png Binary files differdeleted file mode 100644 index 41fbcc78816..00000000000 --- a/app/assets/images/emoji/person_frowning_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/person_frowning_tone4.png b/app/assets/images/emoji/person_frowning_tone4.png Binary files differdeleted file mode 100644 index 5a37c741030..00000000000 --- a/app/assets/images/emoji/person_frowning_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/person_frowning_tone5.png b/app/assets/images/emoji/person_frowning_tone5.png Binary files differdeleted file mode 100644 index e08141f3efe..00000000000 --- a/app/assets/images/emoji/person_frowning_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/person_with_blond_hair.png b/app/assets/images/emoji/person_with_blond_hair.png Binary files differdeleted file mode 100644 index ad6f01a7dda..00000000000 --- a/app/assets/images/emoji/person_with_blond_hair.png +++ /dev/null diff --git a/app/assets/images/emoji/person_with_blond_hair_tone1.png b/app/assets/images/emoji/person_with_blond_hair_tone1.png Binary files differdeleted file mode 100644 index 7d18ef24445..00000000000 --- a/app/assets/images/emoji/person_with_blond_hair_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/person_with_blond_hair_tone2.png b/app/assets/images/emoji/person_with_blond_hair_tone2.png Binary files differdeleted file mode 100644 index dae1307315c..00000000000 --- a/app/assets/images/emoji/person_with_blond_hair_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/person_with_blond_hair_tone3.png b/app/assets/images/emoji/person_with_blond_hair_tone3.png Binary files differdeleted file mode 100644 index 684677e8e5a..00000000000 --- a/app/assets/images/emoji/person_with_blond_hair_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/person_with_blond_hair_tone4.png b/app/assets/images/emoji/person_with_blond_hair_tone4.png Binary files differdeleted file mode 100644 index 012be0b51f8..00000000000 --- a/app/assets/images/emoji/person_with_blond_hair_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/person_with_blond_hair_tone5.png b/app/assets/images/emoji/person_with_blond_hair_tone5.png Binary files differdeleted file mode 100644 index d4ecc4cf44b..00000000000 --- a/app/assets/images/emoji/person_with_blond_hair_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/person_with_pouting_face.png b/app/assets/images/emoji/person_with_pouting_face.png Binary files differdeleted file mode 100644 index 10eb0571078..00000000000 --- a/app/assets/images/emoji/person_with_pouting_face.png +++ /dev/null diff --git a/app/assets/images/emoji/person_with_pouting_face_tone1.png b/app/assets/images/emoji/person_with_pouting_face_tone1.png Binary files differdeleted file mode 100644 index 57e826b75a4..00000000000 --- a/app/assets/images/emoji/person_with_pouting_face_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/person_with_pouting_face_tone2.png b/app/assets/images/emoji/person_with_pouting_face_tone2.png Binary files differdeleted file mode 100644 index 3f317c0c25f..00000000000 --- a/app/assets/images/emoji/person_with_pouting_face_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/person_with_pouting_face_tone3.png b/app/assets/images/emoji/person_with_pouting_face_tone3.png Binary files differdeleted file mode 100644 index d2fbb6c20bf..00000000000 --- a/app/assets/images/emoji/person_with_pouting_face_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/person_with_pouting_face_tone4.png b/app/assets/images/emoji/person_with_pouting_face_tone4.png Binary files differdeleted file mode 100644 index 643ceb4a5c5..00000000000 --- a/app/assets/images/emoji/person_with_pouting_face_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/person_with_pouting_face_tone5.png b/app/assets/images/emoji/person_with_pouting_face_tone5.png Binary files differdeleted file mode 100644 index b2eb6859c32..00000000000 --- a/app/assets/images/emoji/person_with_pouting_face_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/pick.png b/app/assets/images/emoji/pick.png Binary files differdeleted file mode 100644 index 6370fe6d791..00000000000 --- a/app/assets/images/emoji/pick.png +++ /dev/null diff --git a/app/assets/images/emoji/pig.png b/app/assets/images/emoji/pig.png Binary files differdeleted file mode 100644 index afe05ca1676..00000000000 --- a/app/assets/images/emoji/pig.png +++ /dev/null diff --git a/app/assets/images/emoji/pig2.png b/app/assets/images/emoji/pig2.png Binary files differdeleted file mode 100644 index 5f31c1a2d75..00000000000 --- a/app/assets/images/emoji/pig2.png +++ /dev/null diff --git a/app/assets/images/emoji/pig_nose.png b/app/assets/images/emoji/pig_nose.png Binary files differdeleted file mode 100644 index 3610ae4a910..00000000000 --- a/app/assets/images/emoji/pig_nose.png +++ /dev/null diff --git a/app/assets/images/emoji/pill.png b/app/assets/images/emoji/pill.png Binary files differdeleted file mode 100644 index 1d4530e77a3..00000000000 --- a/app/assets/images/emoji/pill.png +++ /dev/null diff --git a/app/assets/images/emoji/pineapple.png b/app/assets/images/emoji/pineapple.png Binary files differdeleted file mode 100644 index c89a1606462..00000000000 --- a/app/assets/images/emoji/pineapple.png +++ /dev/null diff --git a/app/assets/images/emoji/ping_pong.png b/app/assets/images/emoji/ping_pong.png Binary files differdeleted file mode 100644 index ff3c51727d1..00000000000 --- a/app/assets/images/emoji/ping_pong.png +++ /dev/null diff --git a/app/assets/images/emoji/pisces.png b/app/assets/images/emoji/pisces.png Binary files differdeleted file mode 100644 index 7f6f646a95c..00000000000 --- a/app/assets/images/emoji/pisces.png +++ /dev/null diff --git a/app/assets/images/emoji/pizza.png b/app/assets/images/emoji/pizza.png Binary files differdeleted file mode 100644 index e07365cb398..00000000000 --- a/app/assets/images/emoji/pizza.png +++ /dev/null diff --git a/app/assets/images/emoji/place_of_worship.png b/app/assets/images/emoji/place_of_worship.png Binary files differdeleted file mode 100644 index 207d59cce85..00000000000 --- a/app/assets/images/emoji/place_of_worship.png +++ /dev/null diff --git a/app/assets/images/emoji/play_pause.png b/app/assets/images/emoji/play_pause.png Binary files differdeleted file mode 100644 index a9f857139ac..00000000000 --- a/app/assets/images/emoji/play_pause.png +++ /dev/null diff --git a/app/assets/images/emoji/point_down.png b/app/assets/images/emoji/point_down.png Binary files differdeleted file mode 100644 index 00d3d13ab5c..00000000000 --- a/app/assets/images/emoji/point_down.png +++ /dev/null diff --git a/app/assets/images/emoji/point_down_tone1.png b/app/assets/images/emoji/point_down_tone1.png Binary files differdeleted file mode 100644 index 140f157d8c7..00000000000 --- a/app/assets/images/emoji/point_down_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/point_down_tone2.png b/app/assets/images/emoji/point_down_tone2.png Binary files differdeleted file mode 100644 index d518544f7fa..00000000000 --- a/app/assets/images/emoji/point_down_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/point_down_tone3.png b/app/assets/images/emoji/point_down_tone3.png Binary files differdeleted file mode 100644 index 018b688b8b7..00000000000 --- a/app/assets/images/emoji/point_down_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/point_down_tone4.png b/app/assets/images/emoji/point_down_tone4.png Binary files differdeleted file mode 100644 index 98845bf6f72..00000000000 --- a/app/assets/images/emoji/point_down_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/point_down_tone5.png b/app/assets/images/emoji/point_down_tone5.png Binary files differdeleted file mode 100644 index 9a9b039a9fc..00000000000 --- a/app/assets/images/emoji/point_down_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/point_left.png b/app/assets/images/emoji/point_left.png Binary files differdeleted file mode 100644 index 599fa2e3cf1..00000000000 --- a/app/assets/images/emoji/point_left.png +++ /dev/null diff --git a/app/assets/images/emoji/point_left_tone1.png b/app/assets/images/emoji/point_left_tone1.png Binary files differdeleted file mode 100644 index 88e2c306076..00000000000 --- a/app/assets/images/emoji/point_left_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/point_left_tone2.png b/app/assets/images/emoji/point_left_tone2.png Binary files differdeleted file mode 100644 index d3c89d87c5f..00000000000 --- a/app/assets/images/emoji/point_left_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/point_left_tone3.png b/app/assets/images/emoji/point_left_tone3.png Binary files differdeleted file mode 100644 index b23b9167358..00000000000 --- a/app/assets/images/emoji/point_left_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/point_left_tone4.png b/app/assets/images/emoji/point_left_tone4.png Binary files differdeleted file mode 100644 index 3093f325c27..00000000000 --- a/app/assets/images/emoji/point_left_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/point_left_tone5.png b/app/assets/images/emoji/point_left_tone5.png Binary files differdeleted file mode 100644 index 2b4cbfa120c..00000000000 --- a/app/assets/images/emoji/point_left_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/point_right.png b/app/assets/images/emoji/point_right.png Binary files differdeleted file mode 100644 index 93a3cd34aa5..00000000000 --- a/app/assets/images/emoji/point_right.png +++ /dev/null diff --git a/app/assets/images/emoji/point_right_tone1.png b/app/assets/images/emoji/point_right_tone1.png Binary files differdeleted file mode 100644 index 4a28c6bbc89..00000000000 --- a/app/assets/images/emoji/point_right_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/point_right_tone2.png b/app/assets/images/emoji/point_right_tone2.png Binary files differdeleted file mode 100644 index 7cb13231733..00000000000 --- a/app/assets/images/emoji/point_right_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/point_right_tone3.png b/app/assets/images/emoji/point_right_tone3.png Binary files differdeleted file mode 100644 index 5514807d71a..00000000000 --- a/app/assets/images/emoji/point_right_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/point_right_tone4.png b/app/assets/images/emoji/point_right_tone4.png Binary files differdeleted file mode 100644 index b8541d6440d..00000000000 --- a/app/assets/images/emoji/point_right_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/point_right_tone5.png b/app/assets/images/emoji/point_right_tone5.png Binary files differdeleted file mode 100644 index 1b7aab07bb1..00000000000 --- a/app/assets/images/emoji/point_right_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/point_up.png b/app/assets/images/emoji/point_up.png Binary files differdeleted file mode 100644 index f4978ff0f00..00000000000 --- a/app/assets/images/emoji/point_up.png +++ /dev/null diff --git a/app/assets/images/emoji/point_up_2.png b/app/assets/images/emoji/point_up_2.png Binary files differdeleted file mode 100644 index bc496dfeae4..00000000000 --- a/app/assets/images/emoji/point_up_2.png +++ /dev/null diff --git a/app/assets/images/emoji/point_up_2_tone1.png b/app/assets/images/emoji/point_up_2_tone1.png Binary files differdeleted file mode 100644 index a12a7e78430..00000000000 --- a/app/assets/images/emoji/point_up_2_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/point_up_2_tone2.png b/app/assets/images/emoji/point_up_2_tone2.png Binary files differdeleted file mode 100644 index cdff40ceab0..00000000000 --- a/app/assets/images/emoji/point_up_2_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/point_up_2_tone3.png b/app/assets/images/emoji/point_up_2_tone3.png Binary files differdeleted file mode 100644 index a07ce9e5ae8..00000000000 --- a/app/assets/images/emoji/point_up_2_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/point_up_2_tone4.png b/app/assets/images/emoji/point_up_2_tone4.png Binary files differdeleted file mode 100644 index 4f86c88ba42..00000000000 --- a/app/assets/images/emoji/point_up_2_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/point_up_2_tone5.png b/app/assets/images/emoji/point_up_2_tone5.png Binary files differdeleted file mode 100644 index ed1b26c35d3..00000000000 --- a/app/assets/images/emoji/point_up_2_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/point_up_tone1.png b/app/assets/images/emoji/point_up_tone1.png Binary files differdeleted file mode 100644 index 6a9db21d64c..00000000000 --- a/app/assets/images/emoji/point_up_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/point_up_tone2.png b/app/assets/images/emoji/point_up_tone2.png Binary files differdeleted file mode 100644 index 15aa9ea0e05..00000000000 --- a/app/assets/images/emoji/point_up_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/point_up_tone3.png b/app/assets/images/emoji/point_up_tone3.png Binary files differdeleted file mode 100644 index 652b73a9c5d..00000000000 --- a/app/assets/images/emoji/point_up_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/point_up_tone4.png b/app/assets/images/emoji/point_up_tone4.png Binary files differdeleted file mode 100644 index 692bad926e9..00000000000 --- a/app/assets/images/emoji/point_up_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/point_up_tone5.png b/app/assets/images/emoji/point_up_tone5.png Binary files differdeleted file mode 100644 index 1e1b10fb71c..00000000000 --- a/app/assets/images/emoji/point_up_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/police_car.png b/app/assets/images/emoji/police_car.png Binary files differdeleted file mode 100644 index 3da4253de7e..00000000000 --- a/app/assets/images/emoji/police_car.png +++ /dev/null diff --git a/app/assets/images/emoji/poodle.png b/app/assets/images/emoji/poodle.png Binary files differdeleted file mode 100644 index 8ec39e396af..00000000000 --- a/app/assets/images/emoji/poodle.png +++ /dev/null diff --git a/app/assets/images/emoji/poop.png b/app/assets/images/emoji/poop.png Binary files differdeleted file mode 100644 index 10b15e72d56..00000000000 --- a/app/assets/images/emoji/poop.png +++ /dev/null diff --git a/app/assets/images/emoji/popcorn.png b/app/assets/images/emoji/popcorn.png Binary files differdeleted file mode 100644 index 36853e381d4..00000000000 --- a/app/assets/images/emoji/popcorn.png +++ /dev/null diff --git a/app/assets/images/emoji/post_office.png b/app/assets/images/emoji/post_office.png Binary files differdeleted file mode 100644 index a23848f9aa0..00000000000 --- a/app/assets/images/emoji/post_office.png +++ /dev/null diff --git a/app/assets/images/emoji/postal_horn.png b/app/assets/images/emoji/postal_horn.png Binary files differdeleted file mode 100644 index c173b8dbd67..00000000000 --- a/app/assets/images/emoji/postal_horn.png +++ /dev/null diff --git a/app/assets/images/emoji/postbox.png b/app/assets/images/emoji/postbox.png Binary files differdeleted file mode 100644 index 07c9c4ab3d6..00000000000 --- a/app/assets/images/emoji/postbox.png +++ /dev/null diff --git a/app/assets/images/emoji/potable_water.png b/app/assets/images/emoji/potable_water.png Binary files differdeleted file mode 100644 index 2c610049459..00000000000 --- a/app/assets/images/emoji/potable_water.png +++ /dev/null diff --git a/app/assets/images/emoji/potato.png b/app/assets/images/emoji/potato.png Binary files differdeleted file mode 100644 index 70350ca2c0a..00000000000 --- a/app/assets/images/emoji/potato.png +++ /dev/null diff --git a/app/assets/images/emoji/pouch.png b/app/assets/images/emoji/pouch.png Binary files differdeleted file mode 100644 index 8795c6c66ff..00000000000 --- a/app/assets/images/emoji/pouch.png +++ /dev/null diff --git a/app/assets/images/emoji/poultry_leg.png b/app/assets/images/emoji/poultry_leg.png Binary files differdeleted file mode 100644 index eea4a53a2f9..00000000000 --- a/app/assets/images/emoji/poultry_leg.png +++ /dev/null diff --git a/app/assets/images/emoji/pound.png b/app/assets/images/emoji/pound.png Binary files differdeleted file mode 100644 index a0d4c4099e9..00000000000 --- a/app/assets/images/emoji/pound.png +++ /dev/null diff --git a/app/assets/images/emoji/pouting_cat.png b/app/assets/images/emoji/pouting_cat.png Binary files differdeleted file mode 100644 index 41ddfeab42b..00000000000 --- a/app/assets/images/emoji/pouting_cat.png +++ /dev/null diff --git a/app/assets/images/emoji/pray.png b/app/assets/images/emoji/pray.png Binary files differdeleted file mode 100644 index 8347f2435be..00000000000 --- a/app/assets/images/emoji/pray.png +++ /dev/null diff --git a/app/assets/images/emoji/pray_tone1.png b/app/assets/images/emoji/pray_tone1.png Binary files differdeleted file mode 100644 index 060ef257172..00000000000 --- a/app/assets/images/emoji/pray_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/pray_tone2.png b/app/assets/images/emoji/pray_tone2.png Binary files differdeleted file mode 100644 index 56dc607c07a..00000000000 --- a/app/assets/images/emoji/pray_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/pray_tone3.png b/app/assets/images/emoji/pray_tone3.png Binary files differdeleted file mode 100644 index 0f33b862008..00000000000 --- a/app/assets/images/emoji/pray_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/pray_tone4.png b/app/assets/images/emoji/pray_tone4.png Binary files differdeleted file mode 100644 index 2ea8dc11657..00000000000 --- a/app/assets/images/emoji/pray_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/pray_tone5.png b/app/assets/images/emoji/pray_tone5.png Binary files differdeleted file mode 100644 index 2128a6c4703..00000000000 --- a/app/assets/images/emoji/pray_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/prayer_beads.png b/app/assets/images/emoji/prayer_beads.png Binary files differdeleted file mode 100644 index a4b6dfcc62e..00000000000 --- a/app/assets/images/emoji/prayer_beads.png +++ /dev/null diff --git a/app/assets/images/emoji/pregnant_woman.png b/app/assets/images/emoji/pregnant_woman.png Binary files differdeleted file mode 100644 index 084e83a414a..00000000000 --- a/app/assets/images/emoji/pregnant_woman.png +++ /dev/null diff --git a/app/assets/images/emoji/pregnant_woman_tone1.png b/app/assets/images/emoji/pregnant_woman_tone1.png Binary files differdeleted file mode 100644 index a78703b33aa..00000000000 --- a/app/assets/images/emoji/pregnant_woman_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/pregnant_woman_tone2.png b/app/assets/images/emoji/pregnant_woman_tone2.png Binary files differdeleted file mode 100644 index 0068c6c4a77..00000000000 --- a/app/assets/images/emoji/pregnant_woman_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/pregnant_woman_tone3.png b/app/assets/images/emoji/pregnant_woman_tone3.png Binary files differdeleted file mode 100644 index 3206296b684..00000000000 --- a/app/assets/images/emoji/pregnant_woman_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/pregnant_woman_tone4.png b/app/assets/images/emoji/pregnant_woman_tone4.png Binary files differdeleted file mode 100644 index 120fda5cd8c..00000000000 --- a/app/assets/images/emoji/pregnant_woman_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/pregnant_woman_tone5.png b/app/assets/images/emoji/pregnant_woman_tone5.png Binary files differdeleted file mode 100644 index 569bfdf05ce..00000000000 --- a/app/assets/images/emoji/pregnant_woman_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/prince.png b/app/assets/images/emoji/prince.png Binary files differdeleted file mode 100644 index 38d69344c84..00000000000 --- a/app/assets/images/emoji/prince.png +++ /dev/null diff --git a/app/assets/images/emoji/prince_tone1.png b/app/assets/images/emoji/prince_tone1.png Binary files differdeleted file mode 100644 index 849930c8887..00000000000 --- a/app/assets/images/emoji/prince_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/prince_tone2.png b/app/assets/images/emoji/prince_tone2.png Binary files differdeleted file mode 100644 index 23d8b3b1285..00000000000 --- a/app/assets/images/emoji/prince_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/prince_tone3.png b/app/assets/images/emoji/prince_tone3.png Binary files differdeleted file mode 100644 index db6dfff0647..00000000000 --- a/app/assets/images/emoji/prince_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/prince_tone4.png b/app/assets/images/emoji/prince_tone4.png Binary files differdeleted file mode 100644 index 8e10f8be6a8..00000000000 --- a/app/assets/images/emoji/prince_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/prince_tone5.png b/app/assets/images/emoji/prince_tone5.png Binary files differdeleted file mode 100644 index 138d4ea7048..00000000000 --- a/app/assets/images/emoji/prince_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/princess.png b/app/assets/images/emoji/princess.png Binary files differdeleted file mode 100644 index 879e9fa8c5d..00000000000 --- a/app/assets/images/emoji/princess.png +++ /dev/null diff --git a/app/assets/images/emoji/princess_tone1.png b/app/assets/images/emoji/princess_tone1.png Binary files differdeleted file mode 100644 index c28078cdc36..00000000000 --- a/app/assets/images/emoji/princess_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/princess_tone2.png b/app/assets/images/emoji/princess_tone2.png Binary files differdeleted file mode 100644 index dcd20e6ecd4..00000000000 --- a/app/assets/images/emoji/princess_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/princess_tone3.png b/app/assets/images/emoji/princess_tone3.png Binary files differdeleted file mode 100644 index cde6f315c56..00000000000 --- a/app/assets/images/emoji/princess_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/princess_tone4.png b/app/assets/images/emoji/princess_tone4.png Binary files differdeleted file mode 100644 index c71e69caaef..00000000000 --- a/app/assets/images/emoji/princess_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/princess_tone5.png b/app/assets/images/emoji/princess_tone5.png Binary files differdeleted file mode 100644 index 063e2645910..00000000000 --- a/app/assets/images/emoji/princess_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/printer.png b/app/assets/images/emoji/printer.png Binary files differdeleted file mode 100644 index 027c830f0fe..00000000000 --- a/app/assets/images/emoji/printer.png +++ /dev/null diff --git a/app/assets/images/emoji/projector.png b/app/assets/images/emoji/projector.png Binary files differdeleted file mode 100644 index ce9ab0daa28..00000000000 --- a/app/assets/images/emoji/projector.png +++ /dev/null diff --git a/app/assets/images/emoji/punch.png b/app/assets/images/emoji/punch.png Binary files differdeleted file mode 100644 index b14ca5f5211..00000000000 --- a/app/assets/images/emoji/punch.png +++ /dev/null diff --git a/app/assets/images/emoji/punch_tone1.png b/app/assets/images/emoji/punch_tone1.png Binary files differdeleted file mode 100644 index 93c7d17fb47..00000000000 --- a/app/assets/images/emoji/punch_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/punch_tone2.png b/app/assets/images/emoji/punch_tone2.png Binary files differdeleted file mode 100644 index c0a1af6e10a..00000000000 --- a/app/assets/images/emoji/punch_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/punch_tone3.png b/app/assets/images/emoji/punch_tone3.png Binary files differdeleted file mode 100644 index 1458b021201..00000000000 --- a/app/assets/images/emoji/punch_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/punch_tone4.png b/app/assets/images/emoji/punch_tone4.png Binary files differdeleted file mode 100644 index c1466bfcdef..00000000000 --- a/app/assets/images/emoji/punch_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/punch_tone5.png b/app/assets/images/emoji/punch_tone5.png Binary files differdeleted file mode 100644 index 00b4ddb8953..00000000000 --- a/app/assets/images/emoji/punch_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/purple_heart.png b/app/assets/images/emoji/purple_heart.png Binary files differdeleted file mode 100644 index 95c53a9ade6..00000000000 --- a/app/assets/images/emoji/purple_heart.png +++ /dev/null diff --git a/app/assets/images/emoji/purse.png b/app/assets/images/emoji/purse.png Binary files differdeleted file mode 100644 index 981346193c5..00000000000 --- a/app/assets/images/emoji/purse.png +++ /dev/null diff --git a/app/assets/images/emoji/pushpin.png b/app/assets/images/emoji/pushpin.png Binary files differdeleted file mode 100644 index 57e07d7f4cc..00000000000 --- a/app/assets/images/emoji/pushpin.png +++ /dev/null diff --git a/app/assets/images/emoji/put_litter_in_its_place.png b/app/assets/images/emoji/put_litter_in_its_place.png Binary files differdeleted file mode 100644 index 82a84f9a375..00000000000 --- a/app/assets/images/emoji/put_litter_in_its_place.png +++ /dev/null diff --git a/app/assets/images/emoji/question.png b/app/assets/images/emoji/question.png Binary files differdeleted file mode 100644 index 5a58f3458aa..00000000000 --- a/app/assets/images/emoji/question.png +++ /dev/null diff --git a/app/assets/images/emoji/rabbit.png b/app/assets/images/emoji/rabbit.png Binary files differdeleted file mode 100644 index ea75ab0426e..00000000000 --- a/app/assets/images/emoji/rabbit.png +++ /dev/null diff --git a/app/assets/images/emoji/rabbit2.png b/app/assets/images/emoji/rabbit2.png Binary files differdeleted file mode 100644 index 2c8a29c642f..00000000000 --- a/app/assets/images/emoji/rabbit2.png +++ /dev/null diff --git a/app/assets/images/emoji/race_car.png b/app/assets/images/emoji/race_car.png Binary files differdeleted file mode 100644 index fe3f045f446..00000000000 --- a/app/assets/images/emoji/race_car.png +++ /dev/null diff --git a/app/assets/images/emoji/racehorse.png b/app/assets/images/emoji/racehorse.png Binary files differdeleted file mode 100644 index b3e73cc8903..00000000000 --- a/app/assets/images/emoji/racehorse.png +++ /dev/null diff --git a/app/assets/images/emoji/radio.png b/app/assets/images/emoji/radio.png Binary files differdeleted file mode 100644 index dec381fa242..00000000000 --- a/app/assets/images/emoji/radio.png +++ /dev/null diff --git a/app/assets/images/emoji/radio_button.png b/app/assets/images/emoji/radio_button.png Binary files differdeleted file mode 100644 index 3a23449d917..00000000000 --- a/app/assets/images/emoji/radio_button.png +++ /dev/null diff --git a/app/assets/images/emoji/radioactive.png b/app/assets/images/emoji/radioactive.png Binary files differdeleted file mode 100644 index 3b46199fe37..00000000000 --- a/app/assets/images/emoji/radioactive.png +++ /dev/null diff --git a/app/assets/images/emoji/rage.png b/app/assets/images/emoji/rage.png Binary files differdeleted file mode 100644 index 9d739bd40ad..00000000000 --- a/app/assets/images/emoji/rage.png +++ /dev/null diff --git a/app/assets/images/emoji/railway_car.png b/app/assets/images/emoji/railway_car.png Binary files differdeleted file mode 100644 index a9acbf13008..00000000000 --- a/app/assets/images/emoji/railway_car.png +++ /dev/null diff --git a/app/assets/images/emoji/railway_track.png b/app/assets/images/emoji/railway_track.png Binary files differdeleted file mode 100644 index e1a7a0d1430..00000000000 --- a/app/assets/images/emoji/railway_track.png +++ /dev/null diff --git a/app/assets/images/emoji/rainbow.png b/app/assets/images/emoji/rainbow.png Binary files differdeleted file mode 100644 index 154735d7147..00000000000 --- a/app/assets/images/emoji/rainbow.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_back_of_hand.png b/app/assets/images/emoji/raised_back_of_hand.png Binary files differdeleted file mode 100644 index 479234294b4..00000000000 --- a/app/assets/images/emoji/raised_back_of_hand.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_back_of_hand_tone1.png b/app/assets/images/emoji/raised_back_of_hand_tone1.png Binary files differdeleted file mode 100644 index 813d28499b5..00000000000 --- a/app/assets/images/emoji/raised_back_of_hand_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_back_of_hand_tone2.png b/app/assets/images/emoji/raised_back_of_hand_tone2.png Binary files differdeleted file mode 100644 index 192ff795e37..00000000000 --- a/app/assets/images/emoji/raised_back_of_hand_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_back_of_hand_tone3.png b/app/assets/images/emoji/raised_back_of_hand_tone3.png Binary files differdeleted file mode 100644 index 61a727abe6b..00000000000 --- a/app/assets/images/emoji/raised_back_of_hand_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_back_of_hand_tone4.png b/app/assets/images/emoji/raised_back_of_hand_tone4.png Binary files differdeleted file mode 100644 index 2e83da511f5..00000000000 --- a/app/assets/images/emoji/raised_back_of_hand_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_back_of_hand_tone5.png b/app/assets/images/emoji/raised_back_of_hand_tone5.png Binary files differdeleted file mode 100644 index d7a5b95a02c..00000000000 --- a/app/assets/images/emoji/raised_back_of_hand_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_hand.png b/app/assets/images/emoji/raised_hand.png Binary files differdeleted file mode 100644 index 6b2954315d1..00000000000 --- a/app/assets/images/emoji/raised_hand.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_hand_tone1.png b/app/assets/images/emoji/raised_hand_tone1.png Binary files differdeleted file mode 100644 index 3b752902c07..00000000000 --- a/app/assets/images/emoji/raised_hand_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_hand_tone2.png b/app/assets/images/emoji/raised_hand_tone2.png Binary files differdeleted file mode 100644 index 44e2a514c60..00000000000 --- a/app/assets/images/emoji/raised_hand_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_hand_tone3.png b/app/assets/images/emoji/raised_hand_tone3.png Binary files differdeleted file mode 100644 index 5bb62a7528a..00000000000 --- a/app/assets/images/emoji/raised_hand_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_hand_tone4.png b/app/assets/images/emoji/raised_hand_tone4.png Binary files differdeleted file mode 100644 index c7f8c9ec270..00000000000 --- a/app/assets/images/emoji/raised_hand_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_hand_tone5.png b/app/assets/images/emoji/raised_hand_tone5.png Binary files differdeleted file mode 100644 index c601b58a73e..00000000000 --- a/app/assets/images/emoji/raised_hand_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_hands.png b/app/assets/images/emoji/raised_hands.png Binary files differdeleted file mode 100644 index c0155f728e7..00000000000 --- a/app/assets/images/emoji/raised_hands.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_hands_tone1.png b/app/assets/images/emoji/raised_hands_tone1.png Binary files differdeleted file mode 100644 index 1168b8236b6..00000000000 --- a/app/assets/images/emoji/raised_hands_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_hands_tone2.png b/app/assets/images/emoji/raised_hands_tone2.png Binary files differdeleted file mode 100644 index 322de622903..00000000000 --- a/app/assets/images/emoji/raised_hands_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_hands_tone3.png b/app/assets/images/emoji/raised_hands_tone3.png Binary files differdeleted file mode 100644 index 2aa24e05ae1..00000000000 --- a/app/assets/images/emoji/raised_hands_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_hands_tone4.png b/app/assets/images/emoji/raised_hands_tone4.png Binary files differdeleted file mode 100644 index f31bf0db992..00000000000 --- a/app/assets/images/emoji/raised_hands_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/raised_hands_tone5.png b/app/assets/images/emoji/raised_hands_tone5.png Binary files differdeleted file mode 100644 index 5e95067f98b..00000000000 --- a/app/assets/images/emoji/raised_hands_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/raising_hand.png b/app/assets/images/emoji/raising_hand.png Binary files differdeleted file mode 100644 index 2880708c0cc..00000000000 --- a/app/assets/images/emoji/raising_hand.png +++ /dev/null diff --git a/app/assets/images/emoji/raising_hand_tone1.png b/app/assets/images/emoji/raising_hand_tone1.png Binary files differdeleted file mode 100644 index 1c90e3e2689..00000000000 --- a/app/assets/images/emoji/raising_hand_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/raising_hand_tone2.png b/app/assets/images/emoji/raising_hand_tone2.png Binary files differdeleted file mode 100644 index 82c3ef2bfc5..00000000000 --- a/app/assets/images/emoji/raising_hand_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/raising_hand_tone3.png b/app/assets/images/emoji/raising_hand_tone3.png Binary files differdeleted file mode 100644 index 1b1da2aa0ca..00000000000 --- a/app/assets/images/emoji/raising_hand_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/raising_hand_tone4.png b/app/assets/images/emoji/raising_hand_tone4.png Binary files differdeleted file mode 100644 index e453855c01f..00000000000 --- a/app/assets/images/emoji/raising_hand_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/raising_hand_tone5.png b/app/assets/images/emoji/raising_hand_tone5.png Binary files differdeleted file mode 100644 index b86200fd844..00000000000 --- a/app/assets/images/emoji/raising_hand_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/ram.png b/app/assets/images/emoji/ram.png Binary files differdeleted file mode 100644 index 52a44464c9b..00000000000 --- a/app/assets/images/emoji/ram.png +++ /dev/null diff --git a/app/assets/images/emoji/ramen.png b/app/assets/images/emoji/ramen.png Binary files differdeleted file mode 100644 index c1cb7cd7384..00000000000 --- a/app/assets/images/emoji/ramen.png +++ /dev/null diff --git a/app/assets/images/emoji/rat.png b/app/assets/images/emoji/rat.png Binary files differdeleted file mode 100644 index 86219144f10..00000000000 --- a/app/assets/images/emoji/rat.png +++ /dev/null diff --git a/app/assets/images/emoji/record_button.png b/app/assets/images/emoji/record_button.png Binary files differdeleted file mode 100644 index ada52830fce..00000000000 --- a/app/assets/images/emoji/record_button.png +++ /dev/null diff --git a/app/assets/images/emoji/recycle.png b/app/assets/images/emoji/recycle.png Binary files differdeleted file mode 100644 index 9221f095c37..00000000000 --- a/app/assets/images/emoji/recycle.png +++ /dev/null diff --git a/app/assets/images/emoji/red_car.png b/app/assets/images/emoji/red_car.png Binary files differdeleted file mode 100644 index b3e6a774dea..00000000000 --- a/app/assets/images/emoji/red_car.png +++ /dev/null diff --git a/app/assets/images/emoji/red_circle.png b/app/assets/images/emoji/red_circle.png Binary files differdeleted file mode 100644 index 4bef930d92f..00000000000 --- a/app/assets/images/emoji/red_circle.png +++ /dev/null diff --git a/app/assets/images/emoji/registered.png b/app/assets/images/emoji/registered.png Binary files differdeleted file mode 100644 index 53ef9f2d4e6..00000000000 --- a/app/assets/images/emoji/registered.png +++ /dev/null diff --git a/app/assets/images/emoji/relaxed.png b/app/assets/images/emoji/relaxed.png Binary files differdeleted file mode 100644 index e9e53c03d45..00000000000 --- a/app/assets/images/emoji/relaxed.png +++ /dev/null diff --git a/app/assets/images/emoji/relieved.png b/app/assets/images/emoji/relieved.png Binary files differdeleted file mode 100644 index 715ad0bf53f..00000000000 --- a/app/assets/images/emoji/relieved.png +++ /dev/null diff --git a/app/assets/images/emoji/reminder_ribbon.png b/app/assets/images/emoji/reminder_ribbon.png Binary files differdeleted file mode 100644 index 3988bbd094c..00000000000 --- a/app/assets/images/emoji/reminder_ribbon.png +++ /dev/null diff --git a/app/assets/images/emoji/repeat.png b/app/assets/images/emoji/repeat.png Binary files differdeleted file mode 100644 index 540ce4e0fba..00000000000 --- a/app/assets/images/emoji/repeat.png +++ /dev/null diff --git a/app/assets/images/emoji/repeat_one.png b/app/assets/images/emoji/repeat_one.png Binary files differdeleted file mode 100644 index 9567e83337f..00000000000 --- a/app/assets/images/emoji/repeat_one.png +++ /dev/null diff --git a/app/assets/images/emoji/restroom.png b/app/assets/images/emoji/restroom.png Binary files differdeleted file mode 100644 index 9588e0f0ef7..00000000000 --- a/app/assets/images/emoji/restroom.png +++ /dev/null diff --git a/app/assets/images/emoji/revolving_hearts.png b/app/assets/images/emoji/revolving_hearts.png Binary files differdeleted file mode 100644 index 7b9d1948f73..00000000000 --- a/app/assets/images/emoji/revolving_hearts.png +++ /dev/null diff --git a/app/assets/images/emoji/rewind.png b/app/assets/images/emoji/rewind.png Binary files differdeleted file mode 100644 index e22e2bd3da5..00000000000 --- a/app/assets/images/emoji/rewind.png +++ /dev/null diff --git a/app/assets/images/emoji/rhino.png b/app/assets/images/emoji/rhino.png Binary files differdeleted file mode 100644 index 12f4e0d9d9b..00000000000 --- a/app/assets/images/emoji/rhino.png +++ /dev/null diff --git a/app/assets/images/emoji/ribbon.png b/app/assets/images/emoji/ribbon.png Binary files differdeleted file mode 100644 index 0f253c3d8c8..00000000000 --- a/app/assets/images/emoji/ribbon.png +++ /dev/null diff --git a/app/assets/images/emoji/rice.png b/app/assets/images/emoji/rice.png Binary files differdeleted file mode 100644 index 6e3ac7956b1..00000000000 --- a/app/assets/images/emoji/rice.png +++ /dev/null diff --git a/app/assets/images/emoji/rice_ball.png b/app/assets/images/emoji/rice_ball.png Binary files differdeleted file mode 100644 index d3d8ee25cb8..00000000000 --- a/app/assets/images/emoji/rice_ball.png +++ /dev/null diff --git a/app/assets/images/emoji/rice_cracker.png b/app/assets/images/emoji/rice_cracker.png Binary files differdeleted file mode 100644 index 7fbd08e4ff9..00000000000 --- a/app/assets/images/emoji/rice_cracker.png +++ /dev/null diff --git a/app/assets/images/emoji/rice_scene.png b/app/assets/images/emoji/rice_scene.png Binary files differdeleted file mode 100644 index 1a28426592a..00000000000 --- a/app/assets/images/emoji/rice_scene.png +++ /dev/null diff --git a/app/assets/images/emoji/right_facing_fist.png b/app/assets/images/emoji/right_facing_fist.png Binary files differdeleted file mode 100644 index 754ed066d2c..00000000000 --- a/app/assets/images/emoji/right_facing_fist.png +++ /dev/null diff --git a/app/assets/images/emoji/right_facing_fist_tone1.png b/app/assets/images/emoji/right_facing_fist_tone1.png Binary files differdeleted file mode 100644 index 33ded2f61a6..00000000000 --- a/app/assets/images/emoji/right_facing_fist_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/right_facing_fist_tone2.png b/app/assets/images/emoji/right_facing_fist_tone2.png Binary files differdeleted file mode 100644 index 88054e335c7..00000000000 --- a/app/assets/images/emoji/right_facing_fist_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/right_facing_fist_tone3.png b/app/assets/images/emoji/right_facing_fist_tone3.png Binary files differdeleted file mode 100644 index 84b9f5da7f7..00000000000 --- a/app/assets/images/emoji/right_facing_fist_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/right_facing_fist_tone4.png b/app/assets/images/emoji/right_facing_fist_tone4.png Binary files differdeleted file mode 100644 index e741cfea68b..00000000000 --- a/app/assets/images/emoji/right_facing_fist_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/right_facing_fist_tone5.png b/app/assets/images/emoji/right_facing_fist_tone5.png Binary files differdeleted file mode 100644 index cf66d760c1f..00000000000 --- a/app/assets/images/emoji/right_facing_fist_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/ring.png b/app/assets/images/emoji/ring.png Binary files differdeleted file mode 100644 index 87d227adb74..00000000000 --- a/app/assets/images/emoji/ring.png +++ /dev/null diff --git a/app/assets/images/emoji/robot.png b/app/assets/images/emoji/robot.png Binary files differdeleted file mode 100644 index 7cc62612c6a..00000000000 --- a/app/assets/images/emoji/robot.png +++ /dev/null diff --git a/app/assets/images/emoji/rocket.png b/app/assets/images/emoji/rocket.png Binary files differdeleted file mode 100644 index 0d8da089a37..00000000000 --- a/app/assets/images/emoji/rocket.png +++ /dev/null diff --git a/app/assets/images/emoji/rofl.png b/app/assets/images/emoji/rofl.png Binary files differdeleted file mode 100644 index b1736fedfeb..00000000000 --- a/app/assets/images/emoji/rofl.png +++ /dev/null diff --git a/app/assets/images/emoji/roller_coaster.png b/app/assets/images/emoji/roller_coaster.png Binary files differdeleted file mode 100644 index 5b849e071e8..00000000000 --- a/app/assets/images/emoji/roller_coaster.png +++ /dev/null diff --git a/app/assets/images/emoji/rolling_eyes.png b/app/assets/images/emoji/rolling_eyes.png Binary files differdeleted file mode 100644 index 2f77b9fc3b9..00000000000 --- a/app/assets/images/emoji/rolling_eyes.png +++ /dev/null diff --git a/app/assets/images/emoji/rooster.png b/app/assets/images/emoji/rooster.png Binary files differdeleted file mode 100644 index bbf2bbff97a..00000000000 --- a/app/assets/images/emoji/rooster.png +++ /dev/null diff --git a/app/assets/images/emoji/rose.png b/app/assets/images/emoji/rose.png Binary files differdeleted file mode 100644 index 52c286d31ce..00000000000 --- a/app/assets/images/emoji/rose.png +++ /dev/null diff --git a/app/assets/images/emoji/rosette.png b/app/assets/images/emoji/rosette.png Binary files differdeleted file mode 100644 index 8030e494bcf..00000000000 --- a/app/assets/images/emoji/rosette.png +++ /dev/null diff --git a/app/assets/images/emoji/rotating_light.png b/app/assets/images/emoji/rotating_light.png Binary files differdeleted file mode 100644 index cad66b0afef..00000000000 --- a/app/assets/images/emoji/rotating_light.png +++ /dev/null diff --git a/app/assets/images/emoji/round_pushpin.png b/app/assets/images/emoji/round_pushpin.png Binary files differdeleted file mode 100644 index 28b9d72866e..00000000000 --- a/app/assets/images/emoji/round_pushpin.png +++ /dev/null diff --git a/app/assets/images/emoji/rowboat.png b/app/assets/images/emoji/rowboat.png Binary files differdeleted file mode 100644 index dd4dfc095d9..00000000000 --- a/app/assets/images/emoji/rowboat.png +++ /dev/null diff --git a/app/assets/images/emoji/rowboat_tone1.png b/app/assets/images/emoji/rowboat_tone1.png Binary files differdeleted file mode 100644 index 5e5d18548cb..00000000000 --- a/app/assets/images/emoji/rowboat_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/rowboat_tone2.png b/app/assets/images/emoji/rowboat_tone2.png Binary files differdeleted file mode 100644 index 9b123ef8871..00000000000 --- a/app/assets/images/emoji/rowboat_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/rowboat_tone3.png b/app/assets/images/emoji/rowboat_tone3.png Binary files differdeleted file mode 100644 index 8ebd89a55f5..00000000000 --- a/app/assets/images/emoji/rowboat_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/rowboat_tone4.png b/app/assets/images/emoji/rowboat_tone4.png Binary files differdeleted file mode 100644 index 2b0d04f8725..00000000000 --- a/app/assets/images/emoji/rowboat_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/rowboat_tone5.png b/app/assets/images/emoji/rowboat_tone5.png Binary files differdeleted file mode 100644 index b346f2dfc84..00000000000 --- a/app/assets/images/emoji/rowboat_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/rugby_football.png b/app/assets/images/emoji/rugby_football.png Binary files differdeleted file mode 100644 index b1872273436..00000000000 --- a/app/assets/images/emoji/rugby_football.png +++ /dev/null diff --git a/app/assets/images/emoji/runner.png b/app/assets/images/emoji/runner.png Binary files differdeleted file mode 100644 index e914915976a..00000000000 --- a/app/assets/images/emoji/runner.png +++ /dev/null diff --git a/app/assets/images/emoji/runner_tone1.png b/app/assets/images/emoji/runner_tone1.png Binary files differdeleted file mode 100644 index 9355239a52d..00000000000 --- a/app/assets/images/emoji/runner_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/runner_tone2.png b/app/assets/images/emoji/runner_tone2.png Binary files differdeleted file mode 100644 index 6112fd5c376..00000000000 --- a/app/assets/images/emoji/runner_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/runner_tone3.png b/app/assets/images/emoji/runner_tone3.png Binary files differdeleted file mode 100644 index 625ec708f48..00000000000 --- a/app/assets/images/emoji/runner_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/runner_tone4.png b/app/assets/images/emoji/runner_tone4.png Binary files differdeleted file mode 100644 index 242f1b56337..00000000000 --- a/app/assets/images/emoji/runner_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/runner_tone5.png b/app/assets/images/emoji/runner_tone5.png Binary files differdeleted file mode 100644 index 2976c6f019f..00000000000 --- a/app/assets/images/emoji/runner_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/running_shirt_with_sash.png b/app/assets/images/emoji/running_shirt_with_sash.png Binary files differdeleted file mode 100644 index 6d83c06b803..00000000000 --- a/app/assets/images/emoji/running_shirt_with_sash.png +++ /dev/null diff --git a/app/assets/images/emoji/sa.png b/app/assets/images/emoji/sa.png Binary files differdeleted file mode 100644 index 900f9633247..00000000000 --- a/app/assets/images/emoji/sa.png +++ /dev/null diff --git a/app/assets/images/emoji/sagittarius.png b/app/assets/images/emoji/sagittarius.png Binary files differdeleted file mode 100644 index f8d94ff2923..00000000000 --- a/app/assets/images/emoji/sagittarius.png +++ /dev/null diff --git a/app/assets/images/emoji/sailboat.png b/app/assets/images/emoji/sailboat.png Binary files differdeleted file mode 100644 index 772ef11da5d..00000000000 --- a/app/assets/images/emoji/sailboat.png +++ /dev/null diff --git a/app/assets/images/emoji/sake.png b/app/assets/images/emoji/sake.png Binary files differdeleted file mode 100644 index 2933f5672c4..00000000000 --- a/app/assets/images/emoji/sake.png +++ /dev/null diff --git a/app/assets/images/emoji/salad.png b/app/assets/images/emoji/salad.png Binary files differdeleted file mode 100644 index c89f9341158..00000000000 --- a/app/assets/images/emoji/salad.png +++ /dev/null diff --git a/app/assets/images/emoji/sandal.png b/app/assets/images/emoji/sandal.png Binary files differdeleted file mode 100644 index 9d9f5122b7a..00000000000 --- a/app/assets/images/emoji/sandal.png +++ /dev/null diff --git a/app/assets/images/emoji/santa.png b/app/assets/images/emoji/santa.png Binary files differdeleted file mode 100644 index bc83ab80d52..00000000000 --- a/app/assets/images/emoji/santa.png +++ /dev/null diff --git a/app/assets/images/emoji/santa_tone1.png b/app/assets/images/emoji/santa_tone1.png Binary files differdeleted file mode 100644 index 5233ffb7174..00000000000 --- a/app/assets/images/emoji/santa_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/santa_tone2.png b/app/assets/images/emoji/santa_tone2.png Binary files differdeleted file mode 100644 index 4e845438197..00000000000 --- a/app/assets/images/emoji/santa_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/santa_tone3.png b/app/assets/images/emoji/santa_tone3.png Binary files differdeleted file mode 100644 index 7fc4f33b60f..00000000000 --- a/app/assets/images/emoji/santa_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/santa_tone4.png b/app/assets/images/emoji/santa_tone4.png Binary files differdeleted file mode 100644 index d1d5a15132d..00000000000 --- a/app/assets/images/emoji/santa_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/santa_tone5.png b/app/assets/images/emoji/santa_tone5.png Binary files differdeleted file mode 100644 index 4d697a01f24..00000000000 --- a/app/assets/images/emoji/santa_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/satellite.png b/app/assets/images/emoji/satellite.png Binary files differdeleted file mode 100644 index db0372795f4..00000000000 --- a/app/assets/images/emoji/satellite.png +++ /dev/null diff --git a/app/assets/images/emoji/satellite_orbital.png b/app/assets/images/emoji/satellite_orbital.png Binary files differdeleted file mode 100644 index 4ba55d6e297..00000000000 --- a/app/assets/images/emoji/satellite_orbital.png +++ /dev/null diff --git a/app/assets/images/emoji/saxophone.png b/app/assets/images/emoji/saxophone.png Binary files differdeleted file mode 100644 index a392faec291..00000000000 --- a/app/assets/images/emoji/saxophone.png +++ /dev/null diff --git a/app/assets/images/emoji/scales.png b/app/assets/images/emoji/scales.png Binary files differdeleted file mode 100644 index 0757eda1684..00000000000 --- a/app/assets/images/emoji/scales.png +++ /dev/null diff --git a/app/assets/images/emoji/school.png b/app/assets/images/emoji/school.png Binary files differdeleted file mode 100644 index 269759534f0..00000000000 --- a/app/assets/images/emoji/school.png +++ /dev/null diff --git a/app/assets/images/emoji/school_satchel.png b/app/assets/images/emoji/school_satchel.png Binary files differdeleted file mode 100644 index 9997c86e7dc..00000000000 --- a/app/assets/images/emoji/school_satchel.png +++ /dev/null diff --git a/app/assets/images/emoji/scissors.png b/app/assets/images/emoji/scissors.png Binary files differdeleted file mode 100644 index 270571c8cdd..00000000000 --- a/app/assets/images/emoji/scissors.png +++ /dev/null diff --git a/app/assets/images/emoji/scooter.png b/app/assets/images/emoji/scooter.png Binary files differdeleted file mode 100644 index 4ab7ef59cd2..00000000000 --- a/app/assets/images/emoji/scooter.png +++ /dev/null diff --git a/app/assets/images/emoji/scorpion.png b/app/assets/images/emoji/scorpion.png Binary files differdeleted file mode 100644 index 449a6b281c9..00000000000 --- a/app/assets/images/emoji/scorpion.png +++ /dev/null diff --git a/app/assets/images/emoji/scorpius.png b/app/assets/images/emoji/scorpius.png Binary files differdeleted file mode 100644 index c31a9920455..00000000000 --- a/app/assets/images/emoji/scorpius.png +++ /dev/null diff --git a/app/assets/images/emoji/scream.png b/app/assets/images/emoji/scream.png Binary files differdeleted file mode 100644 index c3bea9f2510..00000000000 --- a/app/assets/images/emoji/scream.png +++ /dev/null diff --git a/app/assets/images/emoji/scream_cat.png b/app/assets/images/emoji/scream_cat.png Binary files differdeleted file mode 100644 index 15803ad8e6e..00000000000 --- a/app/assets/images/emoji/scream_cat.png +++ /dev/null diff --git a/app/assets/images/emoji/scroll.png b/app/assets/images/emoji/scroll.png Binary files differdeleted file mode 100644 index 50ee5dcd4b9..00000000000 --- a/app/assets/images/emoji/scroll.png +++ /dev/null diff --git a/app/assets/images/emoji/seat.png b/app/assets/images/emoji/seat.png Binary files differdeleted file mode 100644 index a6d72d95adb..00000000000 --- a/app/assets/images/emoji/seat.png +++ /dev/null diff --git a/app/assets/images/emoji/second_place.png b/app/assets/images/emoji/second_place.png Binary files differdeleted file mode 100644 index 17b011268b6..00000000000 --- a/app/assets/images/emoji/second_place.png +++ /dev/null diff --git a/app/assets/images/emoji/secret.png b/app/assets/images/emoji/secret.png Binary files differdeleted file mode 100644 index 5fd72608e60..00000000000 --- a/app/assets/images/emoji/secret.png +++ /dev/null diff --git a/app/assets/images/emoji/see_no_evil.png b/app/assets/images/emoji/see_no_evil.png Binary files differdeleted file mode 100644 index 5187e474531..00000000000 --- a/app/assets/images/emoji/see_no_evil.png +++ /dev/null diff --git a/app/assets/images/emoji/seedling.png b/app/assets/images/emoji/seedling.png Binary files differdeleted file mode 100644 index ae0948bcfd6..00000000000 --- a/app/assets/images/emoji/seedling.png +++ /dev/null diff --git a/app/assets/images/emoji/selfie.png b/app/assets/images/emoji/selfie.png Binary files differdeleted file mode 100644 index 6a1ba75c7e3..00000000000 --- a/app/assets/images/emoji/selfie.png +++ /dev/null diff --git a/app/assets/images/emoji/selfie_tone1.png b/app/assets/images/emoji/selfie_tone1.png Binary files differdeleted file mode 100644 index 290e075b56f..00000000000 --- a/app/assets/images/emoji/selfie_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/selfie_tone2.png b/app/assets/images/emoji/selfie_tone2.png Binary files differdeleted file mode 100644 index fcd9595b643..00000000000 --- a/app/assets/images/emoji/selfie_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/selfie_tone3.png b/app/assets/images/emoji/selfie_tone3.png Binary files differdeleted file mode 100644 index f3a22fdf435..00000000000 --- a/app/assets/images/emoji/selfie_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/selfie_tone4.png b/app/assets/images/emoji/selfie_tone4.png Binary files differdeleted file mode 100644 index cdecf6d9f4e..00000000000 --- a/app/assets/images/emoji/selfie_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/selfie_tone5.png b/app/assets/images/emoji/selfie_tone5.png Binary files differdeleted file mode 100644 index 86acbb6c202..00000000000 --- a/app/assets/images/emoji/selfie_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/seven.png b/app/assets/images/emoji/seven.png Binary files differdeleted file mode 100644 index 9b3476ae7c7..00000000000 --- a/app/assets/images/emoji/seven.png +++ /dev/null diff --git a/app/assets/images/emoji/shallow_pan_of_food.png b/app/assets/images/emoji/shallow_pan_of_food.png Binary files differdeleted file mode 100644 index 663a1006acd..00000000000 --- a/app/assets/images/emoji/shallow_pan_of_food.png +++ /dev/null diff --git a/app/assets/images/emoji/shamrock.png b/app/assets/images/emoji/shamrock.png Binary files differdeleted file mode 100644 index f202aecfe6f..00000000000 --- a/app/assets/images/emoji/shamrock.png +++ /dev/null diff --git a/app/assets/images/emoji/shark.png b/app/assets/images/emoji/shark.png Binary files differdeleted file mode 100644 index c75076d57d8..00000000000 --- a/app/assets/images/emoji/shark.png +++ /dev/null diff --git a/app/assets/images/emoji/shaved_ice.png b/app/assets/images/emoji/shaved_ice.png Binary files differdeleted file mode 100644 index 36dfb53ca93..00000000000 --- a/app/assets/images/emoji/shaved_ice.png +++ /dev/null diff --git a/app/assets/images/emoji/sheep.png b/app/assets/images/emoji/sheep.png Binary files differdeleted file mode 100644 index 102b8a52b28..00000000000 --- a/app/assets/images/emoji/sheep.png +++ /dev/null diff --git a/app/assets/images/emoji/shell.png b/app/assets/images/emoji/shell.png Binary files differdeleted file mode 100644 index 55721629f62..00000000000 --- a/app/assets/images/emoji/shell.png +++ /dev/null diff --git a/app/assets/images/emoji/shield.png b/app/assets/images/emoji/shield.png Binary files differdeleted file mode 100644 index 610bf033ce0..00000000000 --- a/app/assets/images/emoji/shield.png +++ /dev/null diff --git a/app/assets/images/emoji/shinto_shrine.png b/app/assets/images/emoji/shinto_shrine.png Binary files differdeleted file mode 100644 index 5a344975bf3..00000000000 --- a/app/assets/images/emoji/shinto_shrine.png +++ /dev/null diff --git a/app/assets/images/emoji/ship.png b/app/assets/images/emoji/ship.png Binary files differdeleted file mode 100644 index 62d54f7d6c9..00000000000 --- a/app/assets/images/emoji/ship.png +++ /dev/null diff --git a/app/assets/images/emoji/shirt.png b/app/assets/images/emoji/shirt.png Binary files differdeleted file mode 100644 index af08dec8b59..00000000000 --- a/app/assets/images/emoji/shirt.png +++ /dev/null diff --git a/app/assets/images/emoji/shopping_bags.png b/app/assets/images/emoji/shopping_bags.png Binary files differdeleted file mode 100644 index 99f2a2b13ac..00000000000 --- a/app/assets/images/emoji/shopping_bags.png +++ /dev/null diff --git a/app/assets/images/emoji/shopping_cart.png b/app/assets/images/emoji/shopping_cart.png Binary files differdeleted file mode 100644 index 1086fe6e456..00000000000 --- a/app/assets/images/emoji/shopping_cart.png +++ /dev/null diff --git a/app/assets/images/emoji/shower.png b/app/assets/images/emoji/shower.png Binary files differdeleted file mode 100644 index 156776a2e52..00000000000 --- a/app/assets/images/emoji/shower.png +++ /dev/null diff --git a/app/assets/images/emoji/shrimp.png b/app/assets/images/emoji/shrimp.png Binary files differdeleted file mode 100644 index 49eff28a71e..00000000000 --- a/app/assets/images/emoji/shrimp.png +++ /dev/null diff --git a/app/assets/images/emoji/shrug.png b/app/assets/images/emoji/shrug.png Binary files differdeleted file mode 100644 index 76e63bfac77..00000000000 --- a/app/assets/images/emoji/shrug.png +++ /dev/null diff --git a/app/assets/images/emoji/shrug_tone1.png b/app/assets/images/emoji/shrug_tone1.png Binary files differdeleted file mode 100644 index 1c895e64468..00000000000 --- a/app/assets/images/emoji/shrug_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/shrug_tone2.png b/app/assets/images/emoji/shrug_tone2.png Binary files differdeleted file mode 100644 index 4e3ca8f8bac..00000000000 --- a/app/assets/images/emoji/shrug_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/shrug_tone3.png b/app/assets/images/emoji/shrug_tone3.png Binary files differdeleted file mode 100644 index d1b16a19bb5..00000000000 --- a/app/assets/images/emoji/shrug_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/shrug_tone4.png b/app/assets/images/emoji/shrug_tone4.png Binary files differdeleted file mode 100644 index 5fbef3f2255..00000000000 --- a/app/assets/images/emoji/shrug_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/shrug_tone5.png b/app/assets/images/emoji/shrug_tone5.png Binary files differdeleted file mode 100644 index 4af2e28bc5c..00000000000 --- a/app/assets/images/emoji/shrug_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/signal_strength.png b/app/assets/images/emoji/signal_strength.png Binary files differdeleted file mode 100644 index ee2b5a4b519..00000000000 --- a/app/assets/images/emoji/signal_strength.png +++ /dev/null diff --git a/app/assets/images/emoji/six.png b/app/assets/images/emoji/six.png Binary files differdeleted file mode 100644 index 371b3acef2c..00000000000 --- a/app/assets/images/emoji/six.png +++ /dev/null diff --git a/app/assets/images/emoji/six_pointed_star.png b/app/assets/images/emoji/six_pointed_star.png Binary files differdeleted file mode 100644 index 2eb1707458b..00000000000 --- a/app/assets/images/emoji/six_pointed_star.png +++ /dev/null diff --git a/app/assets/images/emoji/ski.png b/app/assets/images/emoji/ski.png Binary files differdeleted file mode 100644 index 4a2d2c12306..00000000000 --- a/app/assets/images/emoji/ski.png +++ /dev/null diff --git a/app/assets/images/emoji/skier.png b/app/assets/images/emoji/skier.png Binary files differdeleted file mode 100644 index 2eb3bdce2af..00000000000 --- a/app/assets/images/emoji/skier.png +++ /dev/null diff --git a/app/assets/images/emoji/skull.png b/app/assets/images/emoji/skull.png Binary files differdeleted file mode 100644 index 26abb17296a..00000000000 --- a/app/assets/images/emoji/skull.png +++ /dev/null diff --git a/app/assets/images/emoji/skull_crossbones.png b/app/assets/images/emoji/skull_crossbones.png Binary files differdeleted file mode 100644 index b459df9227a..00000000000 --- a/app/assets/images/emoji/skull_crossbones.png +++ /dev/null diff --git a/app/assets/images/emoji/sleeping.png b/app/assets/images/emoji/sleeping.png Binary files differdeleted file mode 100644 index 9ecf600d6d8..00000000000 --- a/app/assets/images/emoji/sleeping.png +++ /dev/null diff --git a/app/assets/images/emoji/sleeping_accommodation.png b/app/assets/images/emoji/sleeping_accommodation.png Binary files differdeleted file mode 100644 index c739e7fb69b..00000000000 --- a/app/assets/images/emoji/sleeping_accommodation.png +++ /dev/null diff --git a/app/assets/images/emoji/sleepy.png b/app/assets/images/emoji/sleepy.png Binary files differdeleted file mode 100644 index 836b4107717..00000000000 --- a/app/assets/images/emoji/sleepy.png +++ /dev/null diff --git a/app/assets/images/emoji/slight_frown.png b/app/assets/images/emoji/slight_frown.png Binary files differdeleted file mode 100644 index b2f1d983d36..00000000000 --- a/app/assets/images/emoji/slight_frown.png +++ /dev/null diff --git a/app/assets/images/emoji/slight_smile.png b/app/assets/images/emoji/slight_smile.png Binary files differdeleted file mode 100644 index ddd7d65dd3d..00000000000 --- a/app/assets/images/emoji/slight_smile.png +++ /dev/null diff --git a/app/assets/images/emoji/slot_machine.png b/app/assets/images/emoji/slot_machine.png Binary files differdeleted file mode 100644 index ee71b6c268c..00000000000 --- a/app/assets/images/emoji/slot_machine.png +++ /dev/null diff --git a/app/assets/images/emoji/small_blue_diamond.png b/app/assets/images/emoji/small_blue_diamond.png Binary files differdeleted file mode 100644 index b86b5bc4db3..00000000000 --- a/app/assets/images/emoji/small_blue_diamond.png +++ /dev/null diff --git a/app/assets/images/emoji/small_orange_diamond.png b/app/assets/images/emoji/small_orange_diamond.png Binary files differdeleted file mode 100644 index e1c6ed9b2f8..00000000000 --- a/app/assets/images/emoji/small_orange_diamond.png +++ /dev/null diff --git a/app/assets/images/emoji/small_red_triangle.png b/app/assets/images/emoji/small_red_triangle.png Binary files differdeleted file mode 100644 index 785887c195a..00000000000 --- a/app/assets/images/emoji/small_red_triangle.png +++ /dev/null diff --git a/app/assets/images/emoji/small_red_triangle_down.png b/app/assets/images/emoji/small_red_triangle_down.png Binary files differdeleted file mode 100644 index a83beff1914..00000000000 --- a/app/assets/images/emoji/small_red_triangle_down.png +++ /dev/null diff --git a/app/assets/images/emoji/smile.png b/app/assets/images/emoji/smile.png Binary files differdeleted file mode 100644 index aa47ffe978c..00000000000 --- a/app/assets/images/emoji/smile.png +++ /dev/null diff --git a/app/assets/images/emoji/smile_cat.png b/app/assets/images/emoji/smile_cat.png Binary files differdeleted file mode 100644 index 6f25f11dd3a..00000000000 --- a/app/assets/images/emoji/smile_cat.png +++ /dev/null diff --git a/app/assets/images/emoji/smiley.png b/app/assets/images/emoji/smiley.png Binary files differdeleted file mode 100644 index 30957a65968..00000000000 --- a/app/assets/images/emoji/smiley.png +++ /dev/null diff --git a/app/assets/images/emoji/smiley_cat.png b/app/assets/images/emoji/smiley_cat.png Binary files differdeleted file mode 100644 index 163b57a3427..00000000000 --- a/app/assets/images/emoji/smiley_cat.png +++ /dev/null diff --git a/app/assets/images/emoji/smiling_imp.png b/app/assets/images/emoji/smiling_imp.png Binary files differdeleted file mode 100644 index cc2c5f1ec72..00000000000 --- a/app/assets/images/emoji/smiling_imp.png +++ /dev/null diff --git a/app/assets/images/emoji/smirk.png b/app/assets/images/emoji/smirk.png Binary files differdeleted file mode 100644 index 87852109988..00000000000 --- a/app/assets/images/emoji/smirk.png +++ /dev/null diff --git a/app/assets/images/emoji/smirk_cat.png b/app/assets/images/emoji/smirk_cat.png Binary files differdeleted file mode 100644 index 9ac5954c199..00000000000 --- a/app/assets/images/emoji/smirk_cat.png +++ /dev/null diff --git a/app/assets/images/emoji/smoking.png b/app/assets/images/emoji/smoking.png Binary files differdeleted file mode 100644 index 910f648c8f9..00000000000 --- a/app/assets/images/emoji/smoking.png +++ /dev/null diff --git a/app/assets/images/emoji/snail.png b/app/assets/images/emoji/snail.png Binary files differdeleted file mode 100644 index f4ea071e2d3..00000000000 --- a/app/assets/images/emoji/snail.png +++ /dev/null diff --git a/app/assets/images/emoji/snake.png b/app/assets/images/emoji/snake.png Binary files differdeleted file mode 100644 index d0278a28d8c..00000000000 --- a/app/assets/images/emoji/snake.png +++ /dev/null diff --git a/app/assets/images/emoji/sneezing_face.png b/app/assets/images/emoji/sneezing_face.png Binary files differdeleted file mode 100644 index ccf07d4b64d..00000000000 --- a/app/assets/images/emoji/sneezing_face.png +++ /dev/null diff --git a/app/assets/images/emoji/snowboarder.png b/app/assets/images/emoji/snowboarder.png Binary files differdeleted file mode 100644 index 6361c0f2c9d..00000000000 --- a/app/assets/images/emoji/snowboarder.png +++ /dev/null diff --git a/app/assets/images/emoji/snowflake.png b/app/assets/images/emoji/snowflake.png Binary files differdeleted file mode 100644 index db319a77ec6..00000000000 --- a/app/assets/images/emoji/snowflake.png +++ /dev/null diff --git a/app/assets/images/emoji/snowman.png b/app/assets/images/emoji/snowman.png Binary files differdeleted file mode 100644 index 20c177c2aff..00000000000 --- a/app/assets/images/emoji/snowman.png +++ /dev/null diff --git a/app/assets/images/emoji/snowman2.png b/app/assets/images/emoji/snowman2.png Binary files differdeleted file mode 100644 index 896f28502af..00000000000 --- a/app/assets/images/emoji/snowman2.png +++ /dev/null diff --git a/app/assets/images/emoji/sob.png b/app/assets/images/emoji/sob.png Binary files differdeleted file mode 100644 index 52e3517a1ee..00000000000 --- a/app/assets/images/emoji/sob.png +++ /dev/null diff --git a/app/assets/images/emoji/soccer.png b/app/assets/images/emoji/soccer.png Binary files differdeleted file mode 100644 index 28cfa218d6d..00000000000 --- a/app/assets/images/emoji/soccer.png +++ /dev/null diff --git a/app/assets/images/emoji/soon.png b/app/assets/images/emoji/soon.png Binary files differdeleted file mode 100644 index 8cdfd86690d..00000000000 --- a/app/assets/images/emoji/soon.png +++ /dev/null diff --git a/app/assets/images/emoji/sos.png b/app/assets/images/emoji/sos.png Binary files differdeleted file mode 100644 index d7d8c9953e4..00000000000 --- a/app/assets/images/emoji/sos.png +++ /dev/null diff --git a/app/assets/images/emoji/sound.png b/app/assets/images/emoji/sound.png Binary files differdeleted file mode 100644 index e75ddca53ba..00000000000 --- a/app/assets/images/emoji/sound.png +++ /dev/null diff --git a/app/assets/images/emoji/space_invader.png b/app/assets/images/emoji/space_invader.png Binary files differdeleted file mode 100644 index 2e73f5f32e5..00000000000 --- a/app/assets/images/emoji/space_invader.png +++ /dev/null diff --git a/app/assets/images/emoji/spades.png b/app/assets/images/emoji/spades.png Binary files differdeleted file mode 100644 index f822f184cb0..00000000000 --- a/app/assets/images/emoji/spades.png +++ /dev/null diff --git a/app/assets/images/emoji/spaghetti.png b/app/assets/images/emoji/spaghetti.png Binary files differdeleted file mode 100644 index 89c24a321f1..00000000000 --- a/app/assets/images/emoji/spaghetti.png +++ /dev/null diff --git a/app/assets/images/emoji/sparkle.png b/app/assets/images/emoji/sparkle.png Binary files differdeleted file mode 100644 index 6aa7b6ec9cf..00000000000 --- a/app/assets/images/emoji/sparkle.png +++ /dev/null diff --git a/app/assets/images/emoji/sparkler.png b/app/assets/images/emoji/sparkler.png Binary files differdeleted file mode 100644 index 30339cd6e09..00000000000 --- a/app/assets/images/emoji/sparkler.png +++ /dev/null diff --git a/app/assets/images/emoji/sparkles.png b/app/assets/images/emoji/sparkles.png Binary files differdeleted file mode 100644 index 169bc10b023..00000000000 --- a/app/assets/images/emoji/sparkles.png +++ /dev/null diff --git a/app/assets/images/emoji/sparkling_heart.png b/app/assets/images/emoji/sparkling_heart.png Binary files differdeleted file mode 100644 index 6709269454e..00000000000 --- a/app/assets/images/emoji/sparkling_heart.png +++ /dev/null diff --git a/app/assets/images/emoji/speak_no_evil.png b/app/assets/images/emoji/speak_no_evil.png Binary files differdeleted file mode 100644 index 9d9e07c974b..00000000000 --- a/app/assets/images/emoji/speak_no_evil.png +++ /dev/null diff --git a/app/assets/images/emoji/speaker.png b/app/assets/images/emoji/speaker.png Binary files differdeleted file mode 100644 index 7bcffb8fc43..00000000000 --- a/app/assets/images/emoji/speaker.png +++ /dev/null diff --git a/app/assets/images/emoji/speaking_head.png b/app/assets/images/emoji/speaking_head.png Binary files differdeleted file mode 100644 index 2df93aaae09..00000000000 --- a/app/assets/images/emoji/speaking_head.png +++ /dev/null diff --git a/app/assets/images/emoji/speech_balloon.png b/app/assets/images/emoji/speech_balloon.png Binary files differdeleted file mode 100644 index a34ef741733..00000000000 --- a/app/assets/images/emoji/speech_balloon.png +++ /dev/null diff --git a/app/assets/images/emoji/speech_left.png b/app/assets/images/emoji/speech_left.png Binary files differdeleted file mode 100644 index 00c05959bcd..00000000000 --- a/app/assets/images/emoji/speech_left.png +++ /dev/null diff --git a/app/assets/images/emoji/speedboat.png b/app/assets/images/emoji/speedboat.png Binary files differdeleted file mode 100644 index 74059d12de1..00000000000 --- a/app/assets/images/emoji/speedboat.png +++ /dev/null diff --git a/app/assets/images/emoji/spider.png b/app/assets/images/emoji/spider.png Binary files differdeleted file mode 100644 index 3849fa90b94..00000000000 --- a/app/assets/images/emoji/spider.png +++ /dev/null diff --git a/app/assets/images/emoji/spider_web.png b/app/assets/images/emoji/spider_web.png Binary files differdeleted file mode 100644 index ba448ee7fba..00000000000 --- a/app/assets/images/emoji/spider_web.png +++ /dev/null diff --git a/app/assets/images/emoji/spoon.png b/app/assets/images/emoji/spoon.png Binary files differdeleted file mode 100644 index 3c4da766aee..00000000000 --- a/app/assets/images/emoji/spoon.png +++ /dev/null diff --git a/app/assets/images/emoji/spy.png b/app/assets/images/emoji/spy.png Binary files differdeleted file mode 100644 index a729e9584d6..00000000000 --- a/app/assets/images/emoji/spy.png +++ /dev/null diff --git a/app/assets/images/emoji/spy_tone1.png b/app/assets/images/emoji/spy_tone1.png Binary files differdeleted file mode 100644 index 2d1c022caee..00000000000 --- a/app/assets/images/emoji/spy_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/spy_tone2.png b/app/assets/images/emoji/spy_tone2.png Binary files differdeleted file mode 100644 index 548b9c26f5d..00000000000 --- a/app/assets/images/emoji/spy_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/spy_tone3.png b/app/assets/images/emoji/spy_tone3.png Binary files differdeleted file mode 100644 index b023f4b18e1..00000000000 --- a/app/assets/images/emoji/spy_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/spy_tone4.png b/app/assets/images/emoji/spy_tone4.png Binary files differdeleted file mode 100644 index d8300af492d..00000000000 --- a/app/assets/images/emoji/spy_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/spy_tone5.png b/app/assets/images/emoji/spy_tone5.png Binary files differdeleted file mode 100644 index ca1462595fa..00000000000 --- a/app/assets/images/emoji/spy_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/squid.png b/app/assets/images/emoji/squid.png Binary files differdeleted file mode 100644 index d2af223f0cb..00000000000 --- a/app/assets/images/emoji/squid.png +++ /dev/null diff --git a/app/assets/images/emoji/stadium.png b/app/assets/images/emoji/stadium.png Binary files differdeleted file mode 100644 index 00cd6db5e29..00000000000 --- a/app/assets/images/emoji/stadium.png +++ /dev/null diff --git a/app/assets/images/emoji/star.png b/app/assets/images/emoji/star.png Binary files differdeleted file mode 100644 index c930947076e..00000000000 --- a/app/assets/images/emoji/star.png +++ /dev/null diff --git a/app/assets/images/emoji/star2.png b/app/assets/images/emoji/star2.png Binary files differdeleted file mode 100644 index 2f5cba592db..00000000000 --- a/app/assets/images/emoji/star2.png +++ /dev/null diff --git a/app/assets/images/emoji/star_and_crescent.png b/app/assets/images/emoji/star_and_crescent.png Binary files differdeleted file mode 100644 index e182636457d..00000000000 --- a/app/assets/images/emoji/star_and_crescent.png +++ /dev/null diff --git a/app/assets/images/emoji/star_of_david.png b/app/assets/images/emoji/star_of_david.png Binary files differdeleted file mode 100644 index fc59d0dde24..00000000000 --- a/app/assets/images/emoji/star_of_david.png +++ /dev/null diff --git a/app/assets/images/emoji/stars.png b/app/assets/images/emoji/stars.png Binary files differdeleted file mode 100644 index aa45384d1c6..00000000000 --- a/app/assets/images/emoji/stars.png +++ /dev/null diff --git a/app/assets/images/emoji/station.png b/app/assets/images/emoji/station.png Binary files differdeleted file mode 100644 index 5c26fee529c..00000000000 --- a/app/assets/images/emoji/station.png +++ /dev/null diff --git a/app/assets/images/emoji/statue_of_liberty.png b/app/assets/images/emoji/statue_of_liberty.png Binary files differdeleted file mode 100644 index 05df8289b59..00000000000 --- a/app/assets/images/emoji/statue_of_liberty.png +++ /dev/null diff --git a/app/assets/images/emoji/steam_locomotive.png b/app/assets/images/emoji/steam_locomotive.png Binary files differdeleted file mode 100644 index 9ac0d999c4c..00000000000 --- a/app/assets/images/emoji/steam_locomotive.png +++ /dev/null diff --git a/app/assets/images/emoji/stew.png b/app/assets/images/emoji/stew.png Binary files differdeleted file mode 100644 index 6b3f010c17a..00000000000 --- a/app/assets/images/emoji/stew.png +++ /dev/null diff --git a/app/assets/images/emoji/stop_button.png b/app/assets/images/emoji/stop_button.png Binary files differdeleted file mode 100644 index cfa99988ac2..00000000000 --- a/app/assets/images/emoji/stop_button.png +++ /dev/null diff --git a/app/assets/images/emoji/stopwatch.png b/app/assets/images/emoji/stopwatch.png Binary files differdeleted file mode 100644 index 8fae1c9a898..00000000000 --- a/app/assets/images/emoji/stopwatch.png +++ /dev/null diff --git a/app/assets/images/emoji/straight_ruler.png b/app/assets/images/emoji/straight_ruler.png Binary files differdeleted file mode 100644 index 1017b7433a1..00000000000 --- a/app/assets/images/emoji/straight_ruler.png +++ /dev/null diff --git a/app/assets/images/emoji/strawberry.png b/app/assets/images/emoji/strawberry.png Binary files differdeleted file mode 100644 index 7bb86f0b29c..00000000000 --- a/app/assets/images/emoji/strawberry.png +++ /dev/null diff --git a/app/assets/images/emoji/stuck_out_tongue.png b/app/assets/images/emoji/stuck_out_tongue.png Binary files differdeleted file mode 100644 index 25757341f96..00000000000 --- a/app/assets/images/emoji/stuck_out_tongue.png +++ /dev/null diff --git a/app/assets/images/emoji/stuck_out_tongue_closed_eyes.png b/app/assets/images/emoji/stuck_out_tongue_closed_eyes.png Binary files differdeleted file mode 100644 index 5c0401e9b1d..00000000000 --- a/app/assets/images/emoji/stuck_out_tongue_closed_eyes.png +++ /dev/null diff --git a/app/assets/images/emoji/stuck_out_tongue_winking_eye.png b/app/assets/images/emoji/stuck_out_tongue_winking_eye.png Binary files differdeleted file mode 100644 index 4817eaa3dc6..00000000000 --- a/app/assets/images/emoji/stuck_out_tongue_winking_eye.png +++ /dev/null diff --git a/app/assets/images/emoji/stuffed_flatbread.png b/app/assets/images/emoji/stuffed_flatbread.png Binary files differdeleted file mode 100644 index a2e10df40a5..00000000000 --- a/app/assets/images/emoji/stuffed_flatbread.png +++ /dev/null diff --git a/app/assets/images/emoji/sun_with_face.png b/app/assets/images/emoji/sun_with_face.png Binary files differdeleted file mode 100644 index 14a4ea971db..00000000000 --- a/app/assets/images/emoji/sun_with_face.png +++ /dev/null diff --git a/app/assets/images/emoji/sunflower.png b/app/assets/images/emoji/sunflower.png Binary files differdeleted file mode 100644 index 08cc07761ea..00000000000 --- a/app/assets/images/emoji/sunflower.png +++ /dev/null diff --git a/app/assets/images/emoji/sunglasses.png b/app/assets/images/emoji/sunglasses.png Binary files differdeleted file mode 100644 index 20011735110..00000000000 --- a/app/assets/images/emoji/sunglasses.png +++ /dev/null diff --git a/app/assets/images/emoji/sunny.png b/app/assets/images/emoji/sunny.png Binary files differdeleted file mode 100644 index fd521ae31a7..00000000000 --- a/app/assets/images/emoji/sunny.png +++ /dev/null diff --git a/app/assets/images/emoji/sunrise.png b/app/assets/images/emoji/sunrise.png Binary files differdeleted file mode 100644 index 4ad36003c20..00000000000 --- a/app/assets/images/emoji/sunrise.png +++ /dev/null diff --git a/app/assets/images/emoji/sunrise_over_mountains.png b/app/assets/images/emoji/sunrise_over_mountains.png Binary files differdeleted file mode 100644 index 2b99307344d..00000000000 --- a/app/assets/images/emoji/sunrise_over_mountains.png +++ /dev/null diff --git a/app/assets/images/emoji/surfer.png b/app/assets/images/emoji/surfer.png Binary files differdeleted file mode 100644 index 3ab017adf4b..00000000000 --- a/app/assets/images/emoji/surfer.png +++ /dev/null diff --git a/app/assets/images/emoji/surfer_tone1.png b/app/assets/images/emoji/surfer_tone1.png Binary files differdeleted file mode 100644 index b5faaa524cc..00000000000 --- a/app/assets/images/emoji/surfer_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/surfer_tone2.png b/app/assets/images/emoji/surfer_tone2.png Binary files differdeleted file mode 100644 index 6d92e412ff1..00000000000 --- a/app/assets/images/emoji/surfer_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/surfer_tone3.png b/app/assets/images/emoji/surfer_tone3.png Binary files differdeleted file mode 100644 index f05ef59496e..00000000000 --- a/app/assets/images/emoji/surfer_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/surfer_tone4.png b/app/assets/images/emoji/surfer_tone4.png Binary files differdeleted file mode 100644 index 35e143d19dc..00000000000 --- a/app/assets/images/emoji/surfer_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/surfer_tone5.png b/app/assets/images/emoji/surfer_tone5.png Binary files differdeleted file mode 100644 index 38917658eac..00000000000 --- a/app/assets/images/emoji/surfer_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/sushi.png b/app/assets/images/emoji/sushi.png Binary files differdeleted file mode 100644 index f171fd2f7a1..00000000000 --- a/app/assets/images/emoji/sushi.png +++ /dev/null diff --git a/app/assets/images/emoji/suspension_railway.png b/app/assets/images/emoji/suspension_railway.png Binary files differdeleted file mode 100644 index a59d5f48c24..00000000000 --- a/app/assets/images/emoji/suspension_railway.png +++ /dev/null diff --git a/app/assets/images/emoji/sweat.png b/app/assets/images/emoji/sweat.png Binary files differdeleted file mode 100644 index f0dae7b7893..00000000000 --- a/app/assets/images/emoji/sweat.png +++ /dev/null diff --git a/app/assets/images/emoji/sweat_drops.png b/app/assets/images/emoji/sweat_drops.png Binary files differdeleted file mode 100644 index 4106117ebc8..00000000000 --- a/app/assets/images/emoji/sweat_drops.png +++ /dev/null diff --git a/app/assets/images/emoji/sweat_smile.png b/app/assets/images/emoji/sweat_smile.png Binary files differdeleted file mode 100644 index cb18d9c899b..00000000000 --- a/app/assets/images/emoji/sweat_smile.png +++ /dev/null diff --git a/app/assets/images/emoji/sweet_potato.png b/app/assets/images/emoji/sweet_potato.png Binary files differdeleted file mode 100644 index 92a425f2e20..00000000000 --- a/app/assets/images/emoji/sweet_potato.png +++ /dev/null diff --git a/app/assets/images/emoji/swimmer.png b/app/assets/images/emoji/swimmer.png Binary files differdeleted file mode 100644 index 55b4d72f9a7..00000000000 --- a/app/assets/images/emoji/swimmer.png +++ /dev/null diff --git a/app/assets/images/emoji/swimmer_tone1.png b/app/assets/images/emoji/swimmer_tone1.png Binary files differdeleted file mode 100644 index 38441c9ca9a..00000000000 --- a/app/assets/images/emoji/swimmer_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/swimmer_tone2.png b/app/assets/images/emoji/swimmer_tone2.png Binary files differdeleted file mode 100644 index b0d43112444..00000000000 --- a/app/assets/images/emoji/swimmer_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/swimmer_tone3.png b/app/assets/images/emoji/swimmer_tone3.png Binary files differdeleted file mode 100644 index 211e77e2aa0..00000000000 --- a/app/assets/images/emoji/swimmer_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/swimmer_tone4.png b/app/assets/images/emoji/swimmer_tone4.png Binary files differdeleted file mode 100644 index f34c34db9d2..00000000000 --- a/app/assets/images/emoji/swimmer_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/swimmer_tone5.png b/app/assets/images/emoji/swimmer_tone5.png Binary files differdeleted file mode 100644 index 3e9231ff868..00000000000 --- a/app/assets/images/emoji/swimmer_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/symbols.png b/app/assets/images/emoji/symbols.png Binary files differdeleted file mode 100644 index ac2fc1f358f..00000000000 --- a/app/assets/images/emoji/symbols.png +++ /dev/null diff --git a/app/assets/images/emoji/synagogue.png b/app/assets/images/emoji/synagogue.png Binary files differdeleted file mode 100644 index ee347904c80..00000000000 --- a/app/assets/images/emoji/synagogue.png +++ /dev/null diff --git a/app/assets/images/emoji/syringe.png b/app/assets/images/emoji/syringe.png Binary files differdeleted file mode 100644 index 71c1a9528d5..00000000000 --- a/app/assets/images/emoji/syringe.png +++ /dev/null diff --git a/app/assets/images/emoji/taco.png b/app/assets/images/emoji/taco.png Binary files differdeleted file mode 100644 index 10e847a4619..00000000000 --- a/app/assets/images/emoji/taco.png +++ /dev/null diff --git a/app/assets/images/emoji/tada.png b/app/assets/images/emoji/tada.png Binary files differdeleted file mode 100644 index 0244d60f269..00000000000 --- a/app/assets/images/emoji/tada.png +++ /dev/null diff --git a/app/assets/images/emoji/tanabata_tree.png b/app/assets/images/emoji/tanabata_tree.png Binary files differdeleted file mode 100644 index 46fcb3a1aac..00000000000 --- a/app/assets/images/emoji/tanabata_tree.png +++ /dev/null diff --git a/app/assets/images/emoji/tangerine.png b/app/assets/images/emoji/tangerine.png Binary files differdeleted file mode 100644 index ab14e5378db..00000000000 --- a/app/assets/images/emoji/tangerine.png +++ /dev/null diff --git a/app/assets/images/emoji/taurus.png b/app/assets/images/emoji/taurus.png Binary files differdeleted file mode 100644 index b2a370df42b..00000000000 --- a/app/assets/images/emoji/taurus.png +++ /dev/null diff --git a/app/assets/images/emoji/taxi.png b/app/assets/images/emoji/taxi.png Binary files differdeleted file mode 100644 index 55f4cc84797..00000000000 --- a/app/assets/images/emoji/taxi.png +++ /dev/null diff --git a/app/assets/images/emoji/tea.png b/app/assets/images/emoji/tea.png Binary files differdeleted file mode 100644 index b53b98f0c45..00000000000 --- a/app/assets/images/emoji/tea.png +++ /dev/null diff --git a/app/assets/images/emoji/telephone.png b/app/assets/images/emoji/telephone.png Binary files differdeleted file mode 100644 index a1e69f566bc..00000000000 --- a/app/assets/images/emoji/telephone.png +++ /dev/null diff --git a/app/assets/images/emoji/telephone_receiver.png b/app/assets/images/emoji/telephone_receiver.png Binary files differdeleted file mode 100644 index 69388316c35..00000000000 --- a/app/assets/images/emoji/telephone_receiver.png +++ /dev/null diff --git a/app/assets/images/emoji/telescope.png b/app/assets/images/emoji/telescope.png Binary files differdeleted file mode 100644 index d63154614b5..00000000000 --- a/app/assets/images/emoji/telescope.png +++ /dev/null diff --git a/app/assets/images/emoji/ten.png b/app/assets/images/emoji/ten.png Binary files differdeleted file mode 100644 index 782d4004962..00000000000 --- a/app/assets/images/emoji/ten.png +++ /dev/null diff --git a/app/assets/images/emoji/tennis.png b/app/assets/images/emoji/tennis.png Binary files differdeleted file mode 100644 index 7e68ba8f301..00000000000 --- a/app/assets/images/emoji/tennis.png +++ /dev/null diff --git a/app/assets/images/emoji/tent.png b/app/assets/images/emoji/tent.png Binary files differdeleted file mode 100644 index 3fddcfc56eb..00000000000 --- a/app/assets/images/emoji/tent.png +++ /dev/null diff --git a/app/assets/images/emoji/thermometer.png b/app/assets/images/emoji/thermometer.png Binary files differdeleted file mode 100644 index b1147392426..00000000000 --- a/app/assets/images/emoji/thermometer.png +++ /dev/null diff --git a/app/assets/images/emoji/thermometer_face.png b/app/assets/images/emoji/thermometer_face.png Binary files differdeleted file mode 100644 index 8fc57387563..00000000000 --- a/app/assets/images/emoji/thermometer_face.png +++ /dev/null diff --git a/app/assets/images/emoji/thinking.png b/app/assets/images/emoji/thinking.png Binary files differdeleted file mode 100644 index c18f6fd14ad..00000000000 --- a/app/assets/images/emoji/thinking.png +++ /dev/null diff --git a/app/assets/images/emoji/third_place.png b/app/assets/images/emoji/third_place.png Binary files differdeleted file mode 100644 index 636e04a5950..00000000000 --- a/app/assets/images/emoji/third_place.png +++ /dev/null diff --git a/app/assets/images/emoji/thought_balloon.png b/app/assets/images/emoji/thought_balloon.png Binary files differdeleted file mode 100644 index 72fe8fa7022..00000000000 --- a/app/assets/images/emoji/thought_balloon.png +++ /dev/null diff --git a/app/assets/images/emoji/three.png b/app/assets/images/emoji/three.png Binary files differdeleted file mode 100644 index dbaa6183e72..00000000000 --- a/app/assets/images/emoji/three.png +++ /dev/null diff --git a/app/assets/images/emoji/thumbsdown.png b/app/assets/images/emoji/thumbsdown.png Binary files differdeleted file mode 100644 index b63da2f20a8..00000000000 --- a/app/assets/images/emoji/thumbsdown.png +++ /dev/null diff --git a/app/assets/images/emoji/thumbsdown_tone1.png b/app/assets/images/emoji/thumbsdown_tone1.png Binary files differdeleted file mode 100644 index a1631af8e92..00000000000 --- a/app/assets/images/emoji/thumbsdown_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/thumbsdown_tone2.png b/app/assets/images/emoji/thumbsdown_tone2.png Binary files differdeleted file mode 100644 index 85fff82d595..00000000000 --- a/app/assets/images/emoji/thumbsdown_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/thumbsdown_tone3.png b/app/assets/images/emoji/thumbsdown_tone3.png Binary files differdeleted file mode 100644 index eeba3be80fd..00000000000 --- a/app/assets/images/emoji/thumbsdown_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/thumbsdown_tone4.png b/app/assets/images/emoji/thumbsdown_tone4.png Binary files differdeleted file mode 100644 index 1addafdaed0..00000000000 --- a/app/assets/images/emoji/thumbsdown_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/thumbsdown_tone5.png b/app/assets/images/emoji/thumbsdown_tone5.png Binary files differdeleted file mode 100644 index 37ec07b5721..00000000000 --- a/app/assets/images/emoji/thumbsdown_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/thumbsup.png b/app/assets/images/emoji/thumbsup.png Binary files differdeleted file mode 100644 index f9e6f13a34f..00000000000 --- a/app/assets/images/emoji/thumbsup.png +++ /dev/null diff --git a/app/assets/images/emoji/thumbsup_tone1.png b/app/assets/images/emoji/thumbsup_tone1.png Binary files differdeleted file mode 100644 index 39684cd5cc7..00000000000 --- a/app/assets/images/emoji/thumbsup_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/thumbsup_tone2.png b/app/assets/images/emoji/thumbsup_tone2.png Binary files differdeleted file mode 100644 index a9b59723573..00000000000 --- a/app/assets/images/emoji/thumbsup_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/thumbsup_tone3.png b/app/assets/images/emoji/thumbsup_tone3.png Binary files differdeleted file mode 100644 index c5e29167015..00000000000 --- a/app/assets/images/emoji/thumbsup_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/thumbsup_tone4.png b/app/assets/images/emoji/thumbsup_tone4.png Binary files differdeleted file mode 100644 index 5bf4857a884..00000000000 --- a/app/assets/images/emoji/thumbsup_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/thumbsup_tone5.png b/app/assets/images/emoji/thumbsup_tone5.png Binary files differdeleted file mode 100644 index d829f787c61..00000000000 --- a/app/assets/images/emoji/thumbsup_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/thunder_cloud_rain.png b/app/assets/images/emoji/thunder_cloud_rain.png Binary files differdeleted file mode 100644 index 31a26a1b6ee..00000000000 --- a/app/assets/images/emoji/thunder_cloud_rain.png +++ /dev/null diff --git a/app/assets/images/emoji/ticket.png b/app/assets/images/emoji/ticket.png Binary files differdeleted file mode 100644 index 605936bb6b3..00000000000 --- a/app/assets/images/emoji/ticket.png +++ /dev/null diff --git a/app/assets/images/emoji/tickets.png b/app/assets/images/emoji/tickets.png Binary files differdeleted file mode 100644 index e510f4a7a50..00000000000 --- a/app/assets/images/emoji/tickets.png +++ /dev/null diff --git a/app/assets/images/emoji/tiger.png b/app/assets/images/emoji/tiger.png Binary files differdeleted file mode 100644 index a4d3ef086d4..00000000000 --- a/app/assets/images/emoji/tiger.png +++ /dev/null diff --git a/app/assets/images/emoji/tiger2.png b/app/assets/images/emoji/tiger2.png Binary files differdeleted file mode 100644 index 871a8b74d56..00000000000 --- a/app/assets/images/emoji/tiger2.png +++ /dev/null diff --git a/app/assets/images/emoji/timer.png b/app/assets/images/emoji/timer.png Binary files differdeleted file mode 100644 index 8a3be574c24..00000000000 --- a/app/assets/images/emoji/timer.png +++ /dev/null diff --git a/app/assets/images/emoji/tired_face.png b/app/assets/images/emoji/tired_face.png Binary files differdeleted file mode 100644 index 4e01eff5b23..00000000000 --- a/app/assets/images/emoji/tired_face.png +++ /dev/null diff --git a/app/assets/images/emoji/tm.png b/app/assets/images/emoji/tm.png Binary files differdeleted file mode 100644 index 7a0c44a2c2b..00000000000 --- a/app/assets/images/emoji/tm.png +++ /dev/null diff --git a/app/assets/images/emoji/toilet.png b/app/assets/images/emoji/toilet.png Binary files differdeleted file mode 100644 index 1392f761835..00000000000 --- a/app/assets/images/emoji/toilet.png +++ /dev/null diff --git a/app/assets/images/emoji/tokyo_tower.png b/app/assets/images/emoji/tokyo_tower.png Binary files differdeleted file mode 100644 index 37df7fc65b1..00000000000 --- a/app/assets/images/emoji/tokyo_tower.png +++ /dev/null diff --git a/app/assets/images/emoji/tomato.png b/app/assets/images/emoji/tomato.png Binary files differdeleted file mode 100644 index 497da8f6b22..00000000000 --- a/app/assets/images/emoji/tomato.png +++ /dev/null diff --git a/app/assets/images/emoji/tone1.png b/app/assets/images/emoji/tone1.png Binary files differdeleted file mode 100644 index c395f3d0d68..00000000000 --- a/app/assets/images/emoji/tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/tone2.png b/app/assets/images/emoji/tone2.png Binary files differdeleted file mode 100644 index 080847431c1..00000000000 --- a/app/assets/images/emoji/tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/tone3.png b/app/assets/images/emoji/tone3.png Binary files differdeleted file mode 100644 index 482dd403475..00000000000 --- a/app/assets/images/emoji/tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/tone4.png b/app/assets/images/emoji/tone4.png Binary files differdeleted file mode 100644 index 5cae8bb20b0..00000000000 --- a/app/assets/images/emoji/tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/tone5.png b/app/assets/images/emoji/tone5.png Binary files differdeleted file mode 100644 index 49d1a8c3a64..00000000000 --- a/app/assets/images/emoji/tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/tongue.png b/app/assets/images/emoji/tongue.png Binary files differdeleted file mode 100644 index 70ce9c1225f..00000000000 --- a/app/assets/images/emoji/tongue.png +++ /dev/null diff --git a/app/assets/images/emoji/tools.png b/app/assets/images/emoji/tools.png Binary files differdeleted file mode 100644 index 3c6049273a9..00000000000 --- a/app/assets/images/emoji/tools.png +++ /dev/null diff --git a/app/assets/images/emoji/top.png b/app/assets/images/emoji/top.png Binary files differdeleted file mode 100644 index 49dea8c08b5..00000000000 --- a/app/assets/images/emoji/top.png +++ /dev/null diff --git a/app/assets/images/emoji/tophat.png b/app/assets/images/emoji/tophat.png Binary files differdeleted file mode 100644 index 131b657b109..00000000000 --- a/app/assets/images/emoji/tophat.png +++ /dev/null diff --git a/app/assets/images/emoji/track_next.png b/app/assets/images/emoji/track_next.png Binary files differdeleted file mode 100644 index f8880d33bab..00000000000 --- a/app/assets/images/emoji/track_next.png +++ /dev/null diff --git a/app/assets/images/emoji/track_previous.png b/app/assets/images/emoji/track_previous.png Binary files differdeleted file mode 100644 index 1ffd0566cfc..00000000000 --- a/app/assets/images/emoji/track_previous.png +++ /dev/null diff --git a/app/assets/images/emoji/trackball.png b/app/assets/images/emoji/trackball.png Binary files differdeleted file mode 100644 index 3bea84ad7ce..00000000000 --- a/app/assets/images/emoji/trackball.png +++ /dev/null diff --git a/app/assets/images/emoji/tractor.png b/app/assets/images/emoji/tractor.png Binary files differdeleted file mode 100644 index c1bf8cae44f..00000000000 --- a/app/assets/images/emoji/tractor.png +++ /dev/null diff --git a/app/assets/images/emoji/traffic_light.png b/app/assets/images/emoji/traffic_light.png Binary files differdeleted file mode 100644 index 6b312285b00..00000000000 --- a/app/assets/images/emoji/traffic_light.png +++ /dev/null diff --git a/app/assets/images/emoji/train.png b/app/assets/images/emoji/train.png Binary files differdeleted file mode 100644 index 3c80321f7e8..00000000000 --- a/app/assets/images/emoji/train.png +++ /dev/null diff --git a/app/assets/images/emoji/train2.png b/app/assets/images/emoji/train2.png Binary files differdeleted file mode 100644 index 367c7bc5d39..00000000000 --- a/app/assets/images/emoji/train2.png +++ /dev/null diff --git a/app/assets/images/emoji/tram.png b/app/assets/images/emoji/tram.png Binary files differdeleted file mode 100644 index b6f0e69038f..00000000000 --- a/app/assets/images/emoji/tram.png +++ /dev/null diff --git a/app/assets/images/emoji/triangular_flag_on_post.png b/app/assets/images/emoji/triangular_flag_on_post.png Binary files differdeleted file mode 100644 index c12d8b06886..00000000000 --- a/app/assets/images/emoji/triangular_flag_on_post.png +++ /dev/null diff --git a/app/assets/images/emoji/triangular_ruler.png b/app/assets/images/emoji/triangular_ruler.png Binary files differdeleted file mode 100644 index 77dee9ee843..00000000000 --- a/app/assets/images/emoji/triangular_ruler.png +++ /dev/null diff --git a/app/assets/images/emoji/trident.png b/app/assets/images/emoji/trident.png Binary files differdeleted file mode 100644 index 777a1dad121..00000000000 --- a/app/assets/images/emoji/trident.png +++ /dev/null diff --git a/app/assets/images/emoji/triumph.png b/app/assets/images/emoji/triumph.png Binary files differdeleted file mode 100644 index 0be7a501969..00000000000 --- a/app/assets/images/emoji/triumph.png +++ /dev/null diff --git a/app/assets/images/emoji/trolleybus.png b/app/assets/images/emoji/trolleybus.png Binary files differdeleted file mode 100644 index 139a9931b52..00000000000 --- a/app/assets/images/emoji/trolleybus.png +++ /dev/null diff --git a/app/assets/images/emoji/trophy.png b/app/assets/images/emoji/trophy.png Binary files differdeleted file mode 100644 index ac2895c1896..00000000000 --- a/app/assets/images/emoji/trophy.png +++ /dev/null diff --git a/app/assets/images/emoji/tropical_drink.png b/app/assets/images/emoji/tropical_drink.png Binary files differdeleted file mode 100644 index cd714f81b36..00000000000 --- a/app/assets/images/emoji/tropical_drink.png +++ /dev/null diff --git a/app/assets/images/emoji/tropical_fish.png b/app/assets/images/emoji/tropical_fish.png Binary files differdeleted file mode 100644 index 252105235a6..00000000000 --- a/app/assets/images/emoji/tropical_fish.png +++ /dev/null diff --git a/app/assets/images/emoji/truck.png b/app/assets/images/emoji/truck.png Binary files differdeleted file mode 100644 index 130de047f8b..00000000000 --- a/app/assets/images/emoji/truck.png +++ /dev/null diff --git a/app/assets/images/emoji/trumpet.png b/app/assets/images/emoji/trumpet.png Binary files differdeleted file mode 100644 index 864ccbcd04a..00000000000 --- a/app/assets/images/emoji/trumpet.png +++ /dev/null diff --git a/app/assets/images/emoji/tulip.png b/app/assets/images/emoji/tulip.png Binary files differdeleted file mode 100644 index f799d75c182..00000000000 --- a/app/assets/images/emoji/tulip.png +++ /dev/null diff --git a/app/assets/images/emoji/tumbler_glass.png b/app/assets/images/emoji/tumbler_glass.png Binary files differdeleted file mode 100644 index 7bf09229879..00000000000 --- a/app/assets/images/emoji/tumbler_glass.png +++ /dev/null diff --git a/app/assets/images/emoji/turkey.png b/app/assets/images/emoji/turkey.png Binary files differdeleted file mode 100644 index 344af94c9ec..00000000000 --- a/app/assets/images/emoji/turkey.png +++ /dev/null diff --git a/app/assets/images/emoji/turtle.png b/app/assets/images/emoji/turtle.png Binary files differdeleted file mode 100644 index c22f7519fe8..00000000000 --- a/app/assets/images/emoji/turtle.png +++ /dev/null diff --git a/app/assets/images/emoji/tv.png b/app/assets/images/emoji/tv.png Binary files differdeleted file mode 100644 index 999f1fb5c6d..00000000000 --- a/app/assets/images/emoji/tv.png +++ /dev/null diff --git a/app/assets/images/emoji/twisted_rightwards_arrows.png b/app/assets/images/emoji/twisted_rightwards_arrows.png Binary files differdeleted file mode 100644 index 5904badde65..00000000000 --- a/app/assets/images/emoji/twisted_rightwards_arrows.png +++ /dev/null diff --git a/app/assets/images/emoji/two.png b/app/assets/images/emoji/two.png Binary files differdeleted file mode 100644 index 927339c9bff..00000000000 --- a/app/assets/images/emoji/two.png +++ /dev/null diff --git a/app/assets/images/emoji/two_hearts.png b/app/assets/images/emoji/two_hearts.png Binary files differdeleted file mode 100644 index 4d8c3386042..00000000000 --- a/app/assets/images/emoji/two_hearts.png +++ /dev/null diff --git a/app/assets/images/emoji/two_men_holding_hands.png b/app/assets/images/emoji/two_men_holding_hands.png Binary files differdeleted file mode 100644 index a511fda822a..00000000000 --- a/app/assets/images/emoji/two_men_holding_hands.png +++ /dev/null diff --git a/app/assets/images/emoji/two_women_holding_hands.png b/app/assets/images/emoji/two_women_holding_hands.png Binary files differdeleted file mode 100644 index b077cd3e40f..00000000000 --- a/app/assets/images/emoji/two_women_holding_hands.png +++ /dev/null diff --git a/app/assets/images/emoji/u5272.png b/app/assets/images/emoji/u5272.png Binary files differdeleted file mode 100644 index c4f837fe684..00000000000 --- a/app/assets/images/emoji/u5272.png +++ /dev/null diff --git a/app/assets/images/emoji/u5408.png b/app/assets/images/emoji/u5408.png Binary files differdeleted file mode 100644 index 8375ad9d9af..00000000000 --- a/app/assets/images/emoji/u5408.png +++ /dev/null diff --git a/app/assets/images/emoji/u55b6.png b/app/assets/images/emoji/u55b6.png Binary files differdeleted file mode 100644 index d21cb30eaf3..00000000000 --- a/app/assets/images/emoji/u55b6.png +++ /dev/null diff --git a/app/assets/images/emoji/u6307.png b/app/assets/images/emoji/u6307.png Binary files differdeleted file mode 100644 index 078e23e4ff3..00000000000 --- a/app/assets/images/emoji/u6307.png +++ /dev/null diff --git a/app/assets/images/emoji/u6708.png b/app/assets/images/emoji/u6708.png Binary files differdeleted file mode 100644 index c41bd36a26a..00000000000 --- a/app/assets/images/emoji/u6708.png +++ /dev/null diff --git a/app/assets/images/emoji/u6709.png b/app/assets/images/emoji/u6709.png Binary files differdeleted file mode 100644 index a4510de41c0..00000000000 --- a/app/assets/images/emoji/u6709.png +++ /dev/null diff --git a/app/assets/images/emoji/u6e80.png b/app/assets/images/emoji/u6e80.png Binary files differdeleted file mode 100644 index f9dea8b8833..00000000000 --- a/app/assets/images/emoji/u6e80.png +++ /dev/null diff --git a/app/assets/images/emoji/u7121.png b/app/assets/images/emoji/u7121.png Binary files differdeleted file mode 100644 index d3a19b420de..00000000000 --- a/app/assets/images/emoji/u7121.png +++ /dev/null diff --git a/app/assets/images/emoji/u7533.png b/app/assets/images/emoji/u7533.png Binary files differdeleted file mode 100644 index 6b7af0ee222..00000000000 --- a/app/assets/images/emoji/u7533.png +++ /dev/null diff --git a/app/assets/images/emoji/u7981.png b/app/assets/images/emoji/u7981.png Binary files differdeleted file mode 100644 index 4c704e03433..00000000000 --- a/app/assets/images/emoji/u7981.png +++ /dev/null diff --git a/app/assets/images/emoji/u7a7a.png b/app/assets/images/emoji/u7a7a.png Binary files differdeleted file mode 100644 index 47966c1ea93..00000000000 --- a/app/assets/images/emoji/u7a7a.png +++ /dev/null diff --git a/app/assets/images/emoji/umbrella.png b/app/assets/images/emoji/umbrella.png Binary files differdeleted file mode 100644 index 5b35b7ff6a4..00000000000 --- a/app/assets/images/emoji/umbrella.png +++ /dev/null diff --git a/app/assets/images/emoji/umbrella2.png b/app/assets/images/emoji/umbrella2.png Binary files differdeleted file mode 100644 index 97fe859e74f..00000000000 --- a/app/assets/images/emoji/umbrella2.png +++ /dev/null diff --git a/app/assets/images/emoji/unamused.png b/app/assets/images/emoji/unamused.png Binary files differdeleted file mode 100644 index 25e3677f2eb..00000000000 --- a/app/assets/images/emoji/unamused.png +++ /dev/null diff --git a/app/assets/images/emoji/underage.png b/app/assets/images/emoji/underage.png Binary files differdeleted file mode 100644 index 6dfe6da51e2..00000000000 --- a/app/assets/images/emoji/underage.png +++ /dev/null diff --git a/app/assets/images/emoji/unicorn.png b/app/assets/images/emoji/unicorn.png Binary files differdeleted file mode 100644 index 05a97969f7e..00000000000 --- a/app/assets/images/emoji/unicorn.png +++ /dev/null diff --git a/app/assets/images/emoji/unlock.png b/app/assets/images/emoji/unlock.png Binary files differdeleted file mode 100644 index 4a74a693911..00000000000 --- a/app/assets/images/emoji/unlock.png +++ /dev/null diff --git a/app/assets/images/emoji/up.png b/app/assets/images/emoji/up.png Binary files differdeleted file mode 100644 index 0d42142ba04..00000000000 --- a/app/assets/images/emoji/up.png +++ /dev/null diff --git a/app/assets/images/emoji/upside_down.png b/app/assets/images/emoji/upside_down.png Binary files differdeleted file mode 100644 index 128f31c9828..00000000000 --- a/app/assets/images/emoji/upside_down.png +++ /dev/null diff --git a/app/assets/images/emoji/urn.png b/app/assets/images/emoji/urn.png Binary files differdeleted file mode 100644 index 6b5b3503438..00000000000 --- a/app/assets/images/emoji/urn.png +++ /dev/null diff --git a/app/assets/images/emoji/v.png b/app/assets/images/emoji/v.png Binary files differdeleted file mode 100644 index 70c5516ffee..00000000000 --- a/app/assets/images/emoji/v.png +++ /dev/null diff --git a/app/assets/images/emoji/v_tone1.png b/app/assets/images/emoji/v_tone1.png Binary files differdeleted file mode 100644 index 6ac54a745f4..00000000000 --- a/app/assets/images/emoji/v_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/v_tone2.png b/app/assets/images/emoji/v_tone2.png Binary files differdeleted file mode 100644 index 6dd9669866d..00000000000 --- a/app/assets/images/emoji/v_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/v_tone3.png b/app/assets/images/emoji/v_tone3.png Binary files differdeleted file mode 100644 index a615e53f02f..00000000000 --- a/app/assets/images/emoji/v_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/v_tone4.png b/app/assets/images/emoji/v_tone4.png Binary files differdeleted file mode 100644 index 33a34bd5a78..00000000000 --- a/app/assets/images/emoji/v_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/v_tone5.png b/app/assets/images/emoji/v_tone5.png Binary files differdeleted file mode 100644 index 45ad14b6c9c..00000000000 --- a/app/assets/images/emoji/v_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/vertical_traffic_light.png b/app/assets/images/emoji/vertical_traffic_light.png Binary files differdeleted file mode 100644 index 8085973eecf..00000000000 --- a/app/assets/images/emoji/vertical_traffic_light.png +++ /dev/null diff --git a/app/assets/images/emoji/vhs.png b/app/assets/images/emoji/vhs.png Binary files differdeleted file mode 100644 index b9eb78ecd92..00000000000 --- a/app/assets/images/emoji/vhs.png +++ /dev/null diff --git a/app/assets/images/emoji/vibration_mode.png b/app/assets/images/emoji/vibration_mode.png Binary files differdeleted file mode 100644 index cc46510e48e..00000000000 --- a/app/assets/images/emoji/vibration_mode.png +++ /dev/null diff --git a/app/assets/images/emoji/video_camera.png b/app/assets/images/emoji/video_camera.png Binary files differdeleted file mode 100644 index 85b300d425c..00000000000 --- a/app/assets/images/emoji/video_camera.png +++ /dev/null diff --git a/app/assets/images/emoji/video_game.png b/app/assets/images/emoji/video_game.png Binary files differdeleted file mode 100644 index 316a9106a55..00000000000 --- a/app/assets/images/emoji/video_game.png +++ /dev/null diff --git a/app/assets/images/emoji/violin.png b/app/assets/images/emoji/violin.png Binary files differdeleted file mode 100644 index e1e76cce242..00000000000 --- a/app/assets/images/emoji/violin.png +++ /dev/null diff --git a/app/assets/images/emoji/virgo.png b/app/assets/images/emoji/virgo.png Binary files differdeleted file mode 100644 index a6b56c2cb5e..00000000000 --- a/app/assets/images/emoji/virgo.png +++ /dev/null diff --git a/app/assets/images/emoji/volcano.png b/app/assets/images/emoji/volcano.png Binary files differdeleted file mode 100644 index 931d569294c..00000000000 --- a/app/assets/images/emoji/volcano.png +++ /dev/null diff --git a/app/assets/images/emoji/volleyball.png b/app/assets/images/emoji/volleyball.png Binary files differdeleted file mode 100644 index 7a0e49d4b07..00000000000 --- a/app/assets/images/emoji/volleyball.png +++ /dev/null diff --git a/app/assets/images/emoji/vs.png b/app/assets/images/emoji/vs.png Binary files differdeleted file mode 100644 index e1180f4a464..00000000000 --- a/app/assets/images/emoji/vs.png +++ /dev/null diff --git a/app/assets/images/emoji/vulcan.png b/app/assets/images/emoji/vulcan.png Binary files differdeleted file mode 100644 index 54728bcaf5c..00000000000 --- a/app/assets/images/emoji/vulcan.png +++ /dev/null diff --git a/app/assets/images/emoji/vulcan_tone1.png b/app/assets/images/emoji/vulcan_tone1.png Binary files differdeleted file mode 100644 index 8aff5d8fa16..00000000000 --- a/app/assets/images/emoji/vulcan_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/vulcan_tone2.png b/app/assets/images/emoji/vulcan_tone2.png Binary files differdeleted file mode 100644 index 82b7ad519b4..00000000000 --- a/app/assets/images/emoji/vulcan_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/vulcan_tone3.png b/app/assets/images/emoji/vulcan_tone3.png Binary files differdeleted file mode 100644 index d1400e1dd28..00000000000 --- a/app/assets/images/emoji/vulcan_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/vulcan_tone4.png b/app/assets/images/emoji/vulcan_tone4.png Binary files differdeleted file mode 100644 index 47e2b280148..00000000000 --- a/app/assets/images/emoji/vulcan_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/vulcan_tone5.png b/app/assets/images/emoji/vulcan_tone5.png Binary files differdeleted file mode 100644 index 60b5c6077be..00000000000 --- a/app/assets/images/emoji/vulcan_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/walking.png b/app/assets/images/emoji/walking.png Binary files differdeleted file mode 100644 index 06dc169a3fd..00000000000 --- a/app/assets/images/emoji/walking.png +++ /dev/null diff --git a/app/assets/images/emoji/walking_tone1.png b/app/assets/images/emoji/walking_tone1.png Binary files differdeleted file mode 100644 index 4e391b45a0b..00000000000 --- a/app/assets/images/emoji/walking_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/walking_tone2.png b/app/assets/images/emoji/walking_tone2.png Binary files differdeleted file mode 100644 index 31f94a1bce1..00000000000 --- a/app/assets/images/emoji/walking_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/walking_tone3.png b/app/assets/images/emoji/walking_tone3.png Binary files differdeleted file mode 100644 index f7ed8e39c2e..00000000000 --- a/app/assets/images/emoji/walking_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/walking_tone4.png b/app/assets/images/emoji/walking_tone4.png Binary files differdeleted file mode 100644 index e58dc04c7b2..00000000000 --- a/app/assets/images/emoji/walking_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/walking_tone5.png b/app/assets/images/emoji/walking_tone5.png Binary files differdeleted file mode 100644 index ba4e1b58fcb..00000000000 --- a/app/assets/images/emoji/walking_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/waning_crescent_moon.png b/app/assets/images/emoji/waning_crescent_moon.png Binary files differdeleted file mode 100644 index cf68706b871..00000000000 --- a/app/assets/images/emoji/waning_crescent_moon.png +++ /dev/null diff --git a/app/assets/images/emoji/waning_gibbous_moon.png b/app/assets/images/emoji/waning_gibbous_moon.png Binary files differdeleted file mode 100644 index 24e16266119..00000000000 --- a/app/assets/images/emoji/waning_gibbous_moon.png +++ /dev/null diff --git a/app/assets/images/emoji/warning.png b/app/assets/images/emoji/warning.png Binary files differdeleted file mode 100644 index 35691c2ed97..00000000000 --- a/app/assets/images/emoji/warning.png +++ /dev/null diff --git a/app/assets/images/emoji/wastebasket.png b/app/assets/images/emoji/wastebasket.png Binary files differdeleted file mode 100644 index 2b3c484b498..00000000000 --- a/app/assets/images/emoji/wastebasket.png +++ /dev/null diff --git a/app/assets/images/emoji/watch.png b/app/assets/images/emoji/watch.png Binary files differdeleted file mode 100644 index 64819bc6e21..00000000000 --- a/app/assets/images/emoji/watch.png +++ /dev/null diff --git a/app/assets/images/emoji/water_buffalo.png b/app/assets/images/emoji/water_buffalo.png Binary files differdeleted file mode 100644 index 80446615caf..00000000000 --- a/app/assets/images/emoji/water_buffalo.png +++ /dev/null diff --git a/app/assets/images/emoji/water_polo.png b/app/assets/images/emoji/water_polo.png Binary files differdeleted file mode 100644 index cb44576780d..00000000000 --- a/app/assets/images/emoji/water_polo.png +++ /dev/null diff --git a/app/assets/images/emoji/water_polo_tone1.png b/app/assets/images/emoji/water_polo_tone1.png Binary files differdeleted file mode 100644 index bed1a908d6a..00000000000 --- a/app/assets/images/emoji/water_polo_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/water_polo_tone2.png b/app/assets/images/emoji/water_polo_tone2.png Binary files differdeleted file mode 100644 index ec5a43b4d4a..00000000000 --- a/app/assets/images/emoji/water_polo_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/water_polo_tone3.png b/app/assets/images/emoji/water_polo_tone3.png Binary files differdeleted file mode 100644 index b081a4a5a96..00000000000 --- a/app/assets/images/emoji/water_polo_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/water_polo_tone4.png b/app/assets/images/emoji/water_polo_tone4.png Binary files differdeleted file mode 100644 index 82cfbc3b0c7..00000000000 --- a/app/assets/images/emoji/water_polo_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/water_polo_tone5.png b/app/assets/images/emoji/water_polo_tone5.png Binary files differdeleted file mode 100644 index bd3366eb06c..00000000000 --- a/app/assets/images/emoji/water_polo_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/watermelon.png b/app/assets/images/emoji/watermelon.png Binary files differdeleted file mode 100644 index 0761488b4c9..00000000000 --- a/app/assets/images/emoji/watermelon.png +++ /dev/null diff --git a/app/assets/images/emoji/wave.png b/app/assets/images/emoji/wave.png Binary files differdeleted file mode 100644 index e0cd79b45f5..00000000000 --- a/app/assets/images/emoji/wave.png +++ /dev/null diff --git a/app/assets/images/emoji/wave_tone1.png b/app/assets/images/emoji/wave_tone1.png Binary files differdeleted file mode 100644 index 6b2b34b106e..00000000000 --- a/app/assets/images/emoji/wave_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/wave_tone2.png b/app/assets/images/emoji/wave_tone2.png Binary files differdeleted file mode 100644 index b857119732e..00000000000 --- a/app/assets/images/emoji/wave_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/wave_tone3.png b/app/assets/images/emoji/wave_tone3.png Binary files differdeleted file mode 100644 index 6283b670f43..00000000000 --- a/app/assets/images/emoji/wave_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/wave_tone4.png b/app/assets/images/emoji/wave_tone4.png Binary files differdeleted file mode 100644 index fe6b2baa747..00000000000 --- a/app/assets/images/emoji/wave_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/wave_tone5.png b/app/assets/images/emoji/wave_tone5.png Binary files differdeleted file mode 100644 index 4bd168ebb78..00000000000 --- a/app/assets/images/emoji/wave_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/wavy_dash.png b/app/assets/images/emoji/wavy_dash.png Binary files differdeleted file mode 100644 index 001c8d6e47d..00000000000 --- a/app/assets/images/emoji/wavy_dash.png +++ /dev/null diff --git a/app/assets/images/emoji/waxing_crescent_moon.png b/app/assets/images/emoji/waxing_crescent_moon.png Binary files differdeleted file mode 100644 index 687125173d9..00000000000 --- a/app/assets/images/emoji/waxing_crescent_moon.png +++ /dev/null diff --git a/app/assets/images/emoji/waxing_gibbous_moon.png b/app/assets/images/emoji/waxing_gibbous_moon.png Binary files differdeleted file mode 100644 index 3a808156318..00000000000 --- a/app/assets/images/emoji/waxing_gibbous_moon.png +++ /dev/null diff --git a/app/assets/images/emoji/wc.png b/app/assets/images/emoji/wc.png Binary files differdeleted file mode 100644 index aa433e84ba6..00000000000 --- a/app/assets/images/emoji/wc.png +++ /dev/null diff --git a/app/assets/images/emoji/weary.png b/app/assets/images/emoji/weary.png Binary files differdeleted file mode 100644 index 98bfbd24a16..00000000000 --- a/app/assets/images/emoji/weary.png +++ /dev/null diff --git a/app/assets/images/emoji/wedding.png b/app/assets/images/emoji/wedding.png Binary files differdeleted file mode 100644 index d0d8aa0bfae..00000000000 --- a/app/assets/images/emoji/wedding.png +++ /dev/null diff --git a/app/assets/images/emoji/whale.png b/app/assets/images/emoji/whale.png Binary files differdeleted file mode 100644 index 9f19b44257c..00000000000 --- a/app/assets/images/emoji/whale.png +++ /dev/null diff --git a/app/assets/images/emoji/whale2.png b/app/assets/images/emoji/whale2.png Binary files differdeleted file mode 100644 index 0df9d3c73a4..00000000000 --- a/app/assets/images/emoji/whale2.png +++ /dev/null diff --git a/app/assets/images/emoji/wheel_of_dharma.png b/app/assets/images/emoji/wheel_of_dharma.png Binary files differdeleted file mode 100644 index 3666db0016b..00000000000 --- a/app/assets/images/emoji/wheel_of_dharma.png +++ /dev/null diff --git a/app/assets/images/emoji/wheelchair.png b/app/assets/images/emoji/wheelchair.png Binary files differdeleted file mode 100644 index 4e5b2698eac..00000000000 --- a/app/assets/images/emoji/wheelchair.png +++ /dev/null diff --git a/app/assets/images/emoji/white_check_mark.png b/app/assets/images/emoji/white_check_mark.png Binary files differdeleted file mode 100644 index e55f087e544..00000000000 --- a/app/assets/images/emoji/white_check_mark.png +++ /dev/null diff --git a/app/assets/images/emoji/white_circle.png b/app/assets/images/emoji/white_circle.png Binary files differdeleted file mode 100644 index c19e15684dd..00000000000 --- a/app/assets/images/emoji/white_circle.png +++ /dev/null diff --git a/app/assets/images/emoji/white_flower.png b/app/assets/images/emoji/white_flower.png Binary files differdeleted file mode 100644 index d6af8b60077..00000000000 --- a/app/assets/images/emoji/white_flower.png +++ /dev/null diff --git a/app/assets/images/emoji/white_large_square.png b/app/assets/images/emoji/white_large_square.png Binary files differdeleted file mode 100644 index 6f06c1c79de..00000000000 --- a/app/assets/images/emoji/white_large_square.png +++ /dev/null diff --git a/app/assets/images/emoji/white_medium_small_square.png b/app/assets/images/emoji/white_medium_small_square.png Binary files differdeleted file mode 100644 index ae874126750..00000000000 --- a/app/assets/images/emoji/white_medium_small_square.png +++ /dev/null diff --git a/app/assets/images/emoji/white_medium_square.png b/app/assets/images/emoji/white_medium_square.png Binary files differdeleted file mode 100644 index 8daacf57059..00000000000 --- a/app/assets/images/emoji/white_medium_square.png +++ /dev/null diff --git a/app/assets/images/emoji/white_small_square.png b/app/assets/images/emoji/white_small_square.png Binary files differdeleted file mode 100644 index d7ebdb0c0ed..00000000000 --- a/app/assets/images/emoji/white_small_square.png +++ /dev/null diff --git a/app/assets/images/emoji/white_square_button.png b/app/assets/images/emoji/white_square_button.png Binary files differdeleted file mode 100644 index 934b1cedfd2..00000000000 --- a/app/assets/images/emoji/white_square_button.png +++ /dev/null diff --git a/app/assets/images/emoji/white_sun_cloud.png b/app/assets/images/emoji/white_sun_cloud.png Binary files differdeleted file mode 100644 index 0a4cc100269..00000000000 --- a/app/assets/images/emoji/white_sun_cloud.png +++ /dev/null diff --git a/app/assets/images/emoji/white_sun_rain_cloud.png b/app/assets/images/emoji/white_sun_rain_cloud.png Binary files differdeleted file mode 100644 index 491f9ca4839..00000000000 --- a/app/assets/images/emoji/white_sun_rain_cloud.png +++ /dev/null diff --git a/app/assets/images/emoji/white_sun_small_cloud.png b/app/assets/images/emoji/white_sun_small_cloud.png Binary files differdeleted file mode 100644 index cead0bfa521..00000000000 --- a/app/assets/images/emoji/white_sun_small_cloud.png +++ /dev/null diff --git a/app/assets/images/emoji/wilted_rose.png b/app/assets/images/emoji/wilted_rose.png Binary files differdeleted file mode 100644 index 62412b143ae..00000000000 --- a/app/assets/images/emoji/wilted_rose.png +++ /dev/null diff --git a/app/assets/images/emoji/wind_blowing_face.png b/app/assets/images/emoji/wind_blowing_face.png Binary files differdeleted file mode 100644 index df81b652eb6..00000000000 --- a/app/assets/images/emoji/wind_blowing_face.png +++ /dev/null diff --git a/app/assets/images/emoji/wind_chime.png b/app/assets/images/emoji/wind_chime.png Binary files differdeleted file mode 100644 index 3c9ef3a95f6..00000000000 --- a/app/assets/images/emoji/wind_chime.png +++ /dev/null diff --git a/app/assets/images/emoji/wine_glass.png b/app/assets/images/emoji/wine_glass.png Binary files differdeleted file mode 100644 index 3cc98689192..00000000000 --- a/app/assets/images/emoji/wine_glass.png +++ /dev/null diff --git a/app/assets/images/emoji/wink.png b/app/assets/images/emoji/wink.png Binary files differdeleted file mode 100644 index 7ea7810a37d..00000000000 --- a/app/assets/images/emoji/wink.png +++ /dev/null diff --git a/app/assets/images/emoji/wolf.png b/app/assets/images/emoji/wolf.png Binary files differdeleted file mode 100644 index ba7220f2de9..00000000000 --- a/app/assets/images/emoji/wolf.png +++ /dev/null diff --git a/app/assets/images/emoji/woman.png b/app/assets/images/emoji/woman.png Binary files differdeleted file mode 100644 index ece440e7a61..00000000000 --- a/app/assets/images/emoji/woman.png +++ /dev/null diff --git a/app/assets/images/emoji/woman_tone1.png b/app/assets/images/emoji/woman_tone1.png Binary files differdeleted file mode 100644 index ff089b8889b..00000000000 --- a/app/assets/images/emoji/woman_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/woman_tone2.png b/app/assets/images/emoji/woman_tone2.png Binary files differdeleted file mode 100644 index 0719c378016..00000000000 --- a/app/assets/images/emoji/woman_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/woman_tone3.png b/app/assets/images/emoji/woman_tone3.png Binary files differdeleted file mode 100644 index 5672e2fd52d..00000000000 --- a/app/assets/images/emoji/woman_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/woman_tone4.png b/app/assets/images/emoji/woman_tone4.png Binary files differdeleted file mode 100644 index 5754aab558b..00000000000 --- a/app/assets/images/emoji/woman_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/woman_tone5.png b/app/assets/images/emoji/woman_tone5.png Binary files differdeleted file mode 100644 index fc252af3a39..00000000000 --- a/app/assets/images/emoji/woman_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/womans_clothes.png b/app/assets/images/emoji/womans_clothes.png Binary files differdeleted file mode 100644 index 01410dc8107..00000000000 --- a/app/assets/images/emoji/womans_clothes.png +++ /dev/null diff --git a/app/assets/images/emoji/womans_hat.png b/app/assets/images/emoji/womans_hat.png Binary files differdeleted file mode 100644 index b837b6a2e47..00000000000 --- a/app/assets/images/emoji/womans_hat.png +++ /dev/null diff --git a/app/assets/images/emoji/womens.png b/app/assets/images/emoji/womens.png Binary files differdeleted file mode 100644 index d4ecc22e7b3..00000000000 --- a/app/assets/images/emoji/womens.png +++ /dev/null diff --git a/app/assets/images/emoji/worried.png b/app/assets/images/emoji/worried.png Binary files differdeleted file mode 100644 index 7074afcf5b7..00000000000 --- a/app/assets/images/emoji/worried.png +++ /dev/null diff --git a/app/assets/images/emoji/wrench.png b/app/assets/images/emoji/wrench.png Binary files differdeleted file mode 100644 index c16b7439697..00000000000 --- a/app/assets/images/emoji/wrench.png +++ /dev/null diff --git a/app/assets/images/emoji/wrestlers.png b/app/assets/images/emoji/wrestlers.png Binary files differdeleted file mode 100644 index 71e67cfad85..00000000000 --- a/app/assets/images/emoji/wrestlers.png +++ /dev/null diff --git a/app/assets/images/emoji/wrestlers_tone1.png b/app/assets/images/emoji/wrestlers_tone1.png Binary files differdeleted file mode 100644 index 379070fd03b..00000000000 --- a/app/assets/images/emoji/wrestlers_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/wrestlers_tone2.png b/app/assets/images/emoji/wrestlers_tone2.png Binary files differdeleted file mode 100644 index 6863ea9209d..00000000000 --- a/app/assets/images/emoji/wrestlers_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/wrestlers_tone3.png b/app/assets/images/emoji/wrestlers_tone3.png Binary files differdeleted file mode 100644 index b7e62910127..00000000000 --- a/app/assets/images/emoji/wrestlers_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/wrestlers_tone4.png b/app/assets/images/emoji/wrestlers_tone4.png Binary files differdeleted file mode 100644 index 750f9589233..00000000000 --- a/app/assets/images/emoji/wrestlers_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/wrestlers_tone5.png b/app/assets/images/emoji/wrestlers_tone5.png Binary files differdeleted file mode 100644 index 36ab9bb3f42..00000000000 --- a/app/assets/images/emoji/wrestlers_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/writing_hand.png b/app/assets/images/emoji/writing_hand.png Binary files differdeleted file mode 100644 index 85639f8ac40..00000000000 --- a/app/assets/images/emoji/writing_hand.png +++ /dev/null diff --git a/app/assets/images/emoji/writing_hand_tone1.png b/app/assets/images/emoji/writing_hand_tone1.png Binary files differdeleted file mode 100644 index 7923d8ebb17..00000000000 --- a/app/assets/images/emoji/writing_hand_tone1.png +++ /dev/null diff --git a/app/assets/images/emoji/writing_hand_tone2.png b/app/assets/images/emoji/writing_hand_tone2.png Binary files differdeleted file mode 100644 index bcb304e15d2..00000000000 --- a/app/assets/images/emoji/writing_hand_tone2.png +++ /dev/null diff --git a/app/assets/images/emoji/writing_hand_tone3.png b/app/assets/images/emoji/writing_hand_tone3.png Binary files differdeleted file mode 100644 index fd885fd2d90..00000000000 --- a/app/assets/images/emoji/writing_hand_tone3.png +++ /dev/null diff --git a/app/assets/images/emoji/writing_hand_tone4.png b/app/assets/images/emoji/writing_hand_tone4.png Binary files differdeleted file mode 100644 index d065b8c64ab..00000000000 --- a/app/assets/images/emoji/writing_hand_tone4.png +++ /dev/null diff --git a/app/assets/images/emoji/writing_hand_tone5.png b/app/assets/images/emoji/writing_hand_tone5.png Binary files differdeleted file mode 100644 index a44b3dd757c..00000000000 --- a/app/assets/images/emoji/writing_hand_tone5.png +++ /dev/null diff --git a/app/assets/images/emoji/x.png b/app/assets/images/emoji/x.png Binary files differdeleted file mode 100644 index 9f9ed0f7ad2..00000000000 --- a/app/assets/images/emoji/x.png +++ /dev/null diff --git a/app/assets/images/emoji/yellow_heart.png b/app/assets/images/emoji/yellow_heart.png Binary files differdeleted file mode 100644 index 7901a9d0103..00000000000 --- a/app/assets/images/emoji/yellow_heart.png +++ /dev/null diff --git a/app/assets/images/emoji/yen.png b/app/assets/images/emoji/yen.png Binary files differdeleted file mode 100644 index 63ee4799d66..00000000000 --- a/app/assets/images/emoji/yen.png +++ /dev/null diff --git a/app/assets/images/emoji/yin_yang.png b/app/assets/images/emoji/yin_yang.png Binary files differdeleted file mode 100644 index f2900f6338f..00000000000 --- a/app/assets/images/emoji/yin_yang.png +++ /dev/null diff --git a/app/assets/images/emoji/yum.png b/app/assets/images/emoji/yum.png Binary files differdeleted file mode 100644 index 2df15753ca1..00000000000 --- a/app/assets/images/emoji/yum.png +++ /dev/null diff --git a/app/assets/images/emoji/zap.png b/app/assets/images/emoji/zap.png Binary files differdeleted file mode 100644 index 47e68e48e49..00000000000 --- a/app/assets/images/emoji/zap.png +++ /dev/null diff --git a/app/assets/images/emoji/zero.png b/app/assets/images/emoji/zero.png Binary files differdeleted file mode 100644 index 13aca83e018..00000000000 --- a/app/assets/images/emoji/zero.png +++ /dev/null diff --git a/app/assets/images/emoji/zipper_mouth.png b/app/assets/images/emoji/zipper_mouth.png Binary files differdeleted file mode 100644 index f8ced2502a7..00000000000 --- a/app/assets/images/emoji/zipper_mouth.png +++ /dev/null diff --git a/app/assets/images/emoji/zzz.png b/app/assets/images/emoji/zzz.png Binary files differdeleted file mode 100644 index 9bc72b4469f..00000000000 --- a/app/assets/images/emoji/zzz.png +++ /dev/null diff --git a/app/assets/images/emoji@2x.png b/app/assets/images/emoji@2x.png Binary files differindex 987279c13cc..8fe724329bf 100644 --- a/app/assets/images/emoji@2x.png +++ b/app/assets/images/emoji@2x.png diff --git a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql index 60110437ecd..09278e1776a 100644 --- a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql +++ b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql @@ -1,6 +1,6 @@ #import "~/graphql_shared/fragments/pageInfo.fragment.graphql" -query getProjects( +query accessTokensGetProjects( $search: String = "" $after: String = "" $first: Int = null diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js index 2cd3a8f12ee..7f5f0403de6 100644 --- a/app/assets/javascripts/access_tokens/index.js +++ b/app/assets/javascripts/access_tokens/index.js @@ -49,7 +49,7 @@ export const initProjectsField = () => { { default: createDefaultClient }, ]) => { const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), + defaultClient: createDefaultClient(), }); Vue.use(VueApollo); diff --git a/app/assets/javascripts/admin/deploy_keys/components/table.vue b/app/assets/javascripts/admin/deploy_keys/components/table.vue new file mode 100644 index 00000000000..97a5a2f2f32 --- /dev/null +++ b/app/assets/javascripts/admin/deploy_keys/components/table.vue @@ -0,0 +1,54 @@ +<script> +import { GlTable, GlButton } from '@gitlab/ui'; + +import { __ } from '~/locale'; + +export default { + name: 'DeployKeysTable', + i18n: { + pageTitle: __('Public deploy keys'), + newDeployKeyButtonText: __('New deploy key'), + }, + fields: [ + { + key: 'title', + label: __('Title'), + }, + { + key: 'fingerprint', + label: __('Fingerprint'), + }, + { + key: 'projects', + label: __('Projects with write access'), + }, + { + key: 'created', + label: __('Created'), + }, + { + key: 'actions', + label: __('Actions'), + }, + ], + components: { + GlTable, + GlButton, + }, + inject: ['editPath', 'deletePath', 'createPath', 'emptyStateSvgPath'], +}; +</script> + +<template> + <div> + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-5"> + <h4 class="gl-m-0"> + {{ $options.i18n.pageTitle }} + </h4> + <gl-button variant="confirm" :href="createPath">{{ + $options.i18n.newDeployKeyButtonText + }}</gl-button> + </div> + <gl-table :fields="$options.fields" data-testid="deploy-keys-list" /> + </div> +</template> diff --git a/app/assets/javascripts/admin/deploy_keys/index.js b/app/assets/javascripts/admin/deploy_keys/index.js new file mode 100644 index 00000000000..d86de4229de --- /dev/null +++ b/app/assets/javascripts/admin/deploy_keys/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import DeployKeysTable from './components/table.vue'; + +export const initAdminDeployKeysTable = () => { + const el = document.getElementById('js-admin-deploy-keys-table'); + + if (!el) return false; + + const { editPath, deletePath, createPath, emptyStateSvgPath } = el.dataset; + + return new Vue({ + el, + provide: { + editPath, + deletePath, + createPath, + emptyStateSvgPath, + }, + render(createElement) { + return createElement(DeployKeysTable); + }, + }); +}; diff --git a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue index ed90343777d..e949498c55b 100644 --- a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue +++ b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue @@ -138,7 +138,7 @@ export default { /> </form> <template #modal-footer> - <gl-button @click="onCancel">{{ s__('Cancel') }}</gl-button> + <gl-button @click="onCancel">{{ __('Cancel') }}</gl-button> <gl-button :disabled="!canSubmit" category="secondary" diff --git a/app/assets/javascripts/admin/users/index.js b/app/assets/javascripts/admin/users/index.js index 852b253d25a..0c485d2a239 100644 --- a/app/assets/javascripts/admin/users/index.js +++ b/app/assets/javascripts/admin/users/index.js @@ -15,7 +15,7 @@ import { Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), + defaultClient: createDefaultClient(), }); const initApp = (el, component, userPropKey, props = {}) => { diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index e59d7fc058a..79a6bac3ba7 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -17,7 +17,6 @@ import { convertToSnakeCase } from '~/lib/utils/text_utility'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; import { s__, __ } from '~/locale'; import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue'; -import AlertsDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue'; import { tdClass, thClass, @@ -26,7 +25,6 @@ import { } from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants'; import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { ALERTS_STATUS_TABS, SEVERITY_LEVELS, trackAlertListViewsOptions } from '../constants'; import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; @@ -98,7 +96,6 @@ export default { severityLabels: SEVERITY_LEVELS, statusTabs: ALERTS_STATUS_TABS, components: { - AlertsDeprecationWarning, GlAlert, GlLoadingIcon, GlTable, @@ -115,7 +112,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagMixin()], inject: ['projectPath', 'textQuery', 'assigneeUsernameQuery', 'populatingAlertsHelpUrl'], apollo: { alerts: { @@ -277,8 +273,6 @@ export default { </gl-sprintf> </gl-alert> - <alerts-deprecation-warning v-if="!glFeatures.managedAlertsDeprecation" /> - <paginated-table-with-search-and-tabs :show-error-msg="showErrorMsg" :i18n="$options.i18n" diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js index 57d1f135606..b23f8a8eba4 100644 --- a/app/assets/javascripts/alert_management/list.js +++ b/app/assets/javascripts/alert_management/list.js @@ -23,7 +23,6 @@ export default () => { assigneeUsernameQuery, alertManagementEnabled, userCanEnableAlertManagement, - hasManagedPrometheus, } = domEl.dataset; const apolloProvider = new VueApollo({ @@ -39,7 +38,6 @@ export default () => { return defaultDataIdFromObject(object); }, }, - assumeImmutableResults: true, }, ), }); @@ -66,7 +64,6 @@ export default () => { alertManagementEnabled: parseBoolean(alertManagementEnabled), trackAlertStatusUpdateOptions: PAGE_CONFIG.OPERATIONS.TRACK_ALERT_STATUS_UPDATE_OPTIONS, userCanEnableAlertManagement: parseBoolean(userCanEnableAlertManagement), - hasManagedPrometheus: parseBoolean(hasManagedPrometheus), }, apolloProvider, render(createElement) { diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index a5f7b84446f..6b5aac57f1c 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -16,6 +16,7 @@ import { import * as Sentry from '@sentry/browser'; import { isEqual, isEmpty, omit } from 'lodash'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; import { integrationTypes, integrationSteps, @@ -129,6 +130,7 @@ export default { name: true, apiUrl: true, }, + pricingLink: `${PROMO_URL}/pricing`, }; }, computed: { @@ -436,7 +438,7 @@ export default { disabled="true" class="gl-display-inline-block gl-my-4" :message="$options.i18n.integrationFormSteps.selectType.enterprise" - link="https://about.gitlab.com/pricing" + :link="pricingLink" data-testid="multi-integrations-not-supported" /> </gl-form-group> diff --git a/app/assets/javascripts/alerts_settings/graphql.js b/app/assets/javascripts/alerts_settings/graphql.js index 15862f4034a..b64e2e3eefa 100644 --- a/app/assets/javascripts/alerts_settings/graphql.js +++ b/app/assets/javascripts/alerts_settings/graphql.js @@ -59,6 +59,5 @@ export default new VueApollo({ cacheConfig: { fragmentMatcher, }, - assumeImmutableResults: true, }), }); diff --git a/app/assets/javascripts/analytics/devops_report/constants.js b/app/assets/javascripts/analytics/devops_report/constants.js deleted file mode 100644 index b395d7eb464..00000000000 --- a/app/assets/javascripts/analytics/devops_report/constants.js +++ /dev/null @@ -1,11 +0,0 @@ -import { __ } from '~/locale'; - -export const INTRO_COOKIE_KEY = 'dev_ops_report_intro_callout_dismissed'; - -export const INTRO_BANNER_TITLE = __('Introducing Your DevOps Report'); - -export const INTRO_BANNER_BODY = __( - 'Your DevOps Report gives an overview of how you are using GitLab from a feature perspective. Use it to view how you compare with other organizations.', -); - -export const INTRO_BANNER_ACTION_TEXT = __('Read more'); diff --git a/app/assets/javascripts/analytics/devops_report/components/devops_score.vue b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue index 238081cc3c0..238081cc3c0 100644 --- a/app/assets/javascripts/analytics/devops_report/components/devops_score.vue +++ b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue diff --git a/app/assets/javascripts/analytics/devops_report/components/devops_score_callout.vue b/app/assets/javascripts/analytics/devops_reports/components/devops_score_callout.vue index e594b4e360a..e594b4e360a 100644 --- a/app/assets/javascripts/analytics/devops_report/components/devops_score_callout.vue +++ b/app/assets/javascripts/analytics/devops_reports/components/devops_score_callout.vue diff --git a/app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue b/app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue index 400326e41e1..400326e41e1 100644 --- a/app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue +++ b/app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue diff --git a/app/assets/javascripts/analytics/devops_reports/constants.js b/app/assets/javascripts/analytics/devops_reports/constants.js new file mode 100644 index 00000000000..6091fcb5724 --- /dev/null +++ b/app/assets/javascripts/analytics/devops_reports/constants.js @@ -0,0 +1,11 @@ +import { __ } from '~/locale'; + +export const INTRO_COOKIE_KEY = 'dev_ops_report_intro_callout_dismissed'; + +export const INTRO_BANNER_TITLE = __('Introducing Your DevOps Reports'); + +export const INTRO_BANNER_BODY = __( + 'Your DevOps Reports give an overview of how you are using GitLab from a feature perspective. Use them to view how you compare with other organizations, and how your teams compare against each other.', +); + +export const INTRO_BANNER_ACTION_TEXT = __('Read more'); diff --git a/app/assets/javascripts/analytics/devops_report/devops_score.js b/app/assets/javascripts/analytics/devops_reports/devops_score.js index 0bf98b65ed5..0bf98b65ed5 100644 --- a/app/assets/javascripts/analytics/devops_report/devops_score.js +++ b/app/assets/javascripts/analytics/devops_reports/devops_score.js diff --git a/app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js b/app/assets/javascripts/analytics/devops_reports/devops_score_disabled_service_ping.js index eb2992422a4..eb2992422a4 100644 --- a/app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js +++ b/app/assets/javascripts/analytics/devops_reports/devops_score_disabled_service_ping.js diff --git a/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql index 63e95d6804c..b870ed4dcbf 100644 --- a/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql +++ b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql @@ -1,4 +1,4 @@ -query getGroupProjects( +query analyticsGetGroupProjects( $groupFullPath: ID! $search: String! $first: Int! diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue index 1eb4832a2a3..63ec40d4ec6 100644 --- a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue @@ -3,7 +3,7 @@ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import { GlSingleStat } from '@gitlab/ui/dist/charts'; import createFlash from '~/flash'; import { number } from '~/lib/utils/unit_format'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import usageTrendsCountQuery from '../graphql/queries/usage_trends_count.query.graphql'; const defaultPrecision = 0; @@ -52,7 +52,7 @@ export default { mergeRequests: s__('UsageTrends|Merge requests'), pipelines: s__('UsageTrends|Pipelines'), }, - loadCountsError: s__('Could not load usage counts. Please refresh the page to try again.'), + loadCountsError: __('Could not load usage counts. Please refresh the page to try again.'), }, }; </script> diff --git a/app/assets/javascripts/analytics/usage_trends/index.js b/app/assets/javascripts/analytics/usage_trends/index.js index 3e85832edcf..d1880b09f15 100644 --- a/app/assets/javascripts/analytics/usage_trends/index.js +++ b/app/assets/javascripts/analytics/usage_trends/index.js @@ -6,7 +6,7 @@ import UsageTrendsApp from './components/app.vue'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), + defaultClient: createDefaultClient(), }); export default () => { diff --git a/app/assets/javascripts/api/namespaces_api.js b/app/assets/javascripts/api/namespaces_api.js new file mode 100644 index 00000000000..166a95b749a --- /dev/null +++ b/app/assets/javascripts/api/namespaces_api.js @@ -0,0 +1,13 @@ +import { buildApiUrl } from '~/api/api_utils'; +import axios from '~/lib/utils/axios_utils'; + +const NAMESPACE_EXISTS_PATH = '/api/:version/namespaces/:id/exists'; + +export function getGroupPathAvailability(groupPath, parentId, axiosOptions = {}) { + const url = buildApiUrl(NAMESPACE_EXISTS_PATH).replace(':id', encodeURIComponent(groupPath)); + + return axios.get(url, { + params: { parent_id: parentId, ...axiosOptions.params }, + ...axiosOptions, + }); +} diff --git a/app/assets/javascripts/artifacts_settings/index.js b/app/assets/javascripts/artifacts_settings/index.js index 5c9f1c3129c..531b42bc185 100644 --- a/app/assets/javascripts/artifacts_settings/index.js +++ b/app/assets/javascripts/artifacts_settings/index.js @@ -6,7 +6,7 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), + defaultClient: createDefaultClient(), }); export default (containerId = 'js-artifacts-settings-app') => { diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue index 0b748f18cb2..484c6524d0e 100644 --- a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue +++ b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue @@ -1,26 +1,46 @@ <script> -import { GlFormInput, GlFormGroup, GlButton, GlForm } from '@gitlab/ui'; +import { GlFormInput, GlFormGroup, GlButton, GlForm, GlModal } from '@gitlab/ui'; import csrf from '~/lib/utils/csrf'; import { __ } from '~/locale'; export const i18n = { currentPassword: __('Current password'), + confirmTitle: __('Are you sure?'), confirmWebAuthn: __( - 'Are you sure? This will invalidate your registered applications and U2F / WebAuthn devices.', + 'This will invalidate your registered applications and U2F / WebAuthn devices.', ), - confirm: __('Are you sure? This will invalidate your registered applications and U2F devices.'), + confirm: __('This will invalidate your registered applications and U2F devices.'), disableTwoFactor: __('Disable two-factor authentication'), + disable: __('Disable'), + cancel: __('Cancel'), regenerateRecoveryCodes: __('Regenerate recovery codes'), + currentPasswordInvalidFeedback: __('Please enter your current password.'), }; export default { name: 'ManageTwoFactorForm', i18n, + modalId: 'manage-two-factor-auth-confirm-modal', + modalActions: { + primary: { + text: i18n.disable, + attributes: { + variant: 'danger', + }, + }, + secondary: { + text: i18n.cancel, + attributes: { + variant: 'default', + }, + }, + }, components: { GlForm, GlFormInput, GlFormGroup, GlButton, + GlModal, }, inject: [ 'webauthnEnabled', @@ -32,8 +52,11 @@ export default { ], data() { return { - method: '', - action: '#', + method: null, + action: null, + currentPassword: '', + currentPasswordState: null, + showConfirmModal: false, }; }, computed: { @@ -46,9 +69,34 @@ export default { }, }, methods: { - handleFormSubmit(event) { - this.method = event.submitter.dataset.formMethod; - this.action = event.submitter.dataset.formAction; + submitForm() { + this.$refs.form.$el.submit(); + }, + async handleSubmitButtonClick({ method, action, confirm = false }) { + this.method = method; + this.action = action; + + if (this.isCurrentPasswordRequired && this.currentPassword === '') { + this.currentPasswordState = false; + + return; + } + + this.currentPasswordState = null; + + if (confirm) { + this.showConfirmModal = true; + + return; + } + + // Wait for form action and method to be updated + await this.$nextTick(); + + this.submitForm(); + }, + handleModalPrimary() { + this.submitForm(); }, }, csrf, @@ -57,10 +105,11 @@ export default { <template> <gl-form - class="gl-display-inline-block" + ref="form" + class="gl-sm-display-inline-block" method="post" :action="action" - @submit="handleFormSubmit($event)" + @submit.prevent > <input type="hidden" name="_method" data-testid="test-2fa-method-field" :value="method" /> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> @@ -69,35 +118,59 @@ export default { v-if="isCurrentPasswordRequired" :label="$options.i18n.currentPassword" label-for="current-password" + :state="currentPasswordState" + :invalid-feedback="$options.i18n.currentPasswordInvalidFeedback" > <gl-form-input id="current-password" + v-model="currentPassword" type="password" name="current_password" - required + :state="currentPasswordState" data-qa-selector="current_password_field" /> </gl-form-group> - <gl-button - type="submit" - class="btn-danger gl-mr-3 gl-display-inline-block" - data-testid="test-2fa-disable-button" - variant="danger" - :data-confirm="confirmText" - :data-form-action="profileTwoFactorAuthPath" - :data-form-method="profileTwoFactorAuthMethod" - > - {{ $options.i18n.disableTwoFactor }} - </gl-button> - <gl-button - type="submit" - class="gl-display-inline-block" - data-testid="test-2fa-regenerate-codes-button" - :data-form-action="codesProfileTwoFactorAuthPath" - :data-form-method="codesProfileTwoFactorAuthMethod" + <div class="gl-display-flex gl-flex-wrap"> + <gl-button + type="submit" + class="gl-sm-mr-3 gl-w-full gl-sm-w-auto" + data-testid="test-2fa-disable-button" + variant="danger" + @click.prevent=" + handleSubmitButtonClick({ + method: profileTwoFactorAuthMethod, + action: profileTwoFactorAuthPath, + confirm: true, + }) + " + > + {{ $options.i18n.disableTwoFactor }} + </gl-button> + <gl-button + type="submit" + class="gl-mt-3 gl-sm-mt-0 gl-w-full gl-sm-w-auto" + data-testid="test-2fa-regenerate-codes-button" + @click.prevent=" + handleSubmitButtonClick({ + method: codesProfileTwoFactorAuthMethod, + action: codesProfileTwoFactorAuthPath, + }) + " + > + {{ $options.i18n.regenerateRecoveryCodes }} + </gl-button> + </div> + <gl-modal + v-model="showConfirmModal" + :modal-id="$options.modalId" + size="sm" + :title="$options.i18n.confirmTitle" + :action-primary="$options.modalActions.primary" + :action-secondary="$options.modalActions.secondary" + @primary="handleModalPrimary" > - {{ $options.i18n.regenerateRecoveryCodes }} - </gl-button> + {{ confirmText }} + </gl-modal> </gl-form> </template> diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue index 825807e833e..0303930de5d 100644 --- a/app/assets/javascripts/badges/components/badge_settings.vue +++ b/app/assets/javascripts/badges/components/badge_settings.vue @@ -2,7 +2,7 @@ import { GlSprintf, GlModal } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import createFlash from '~/flash'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import Badge from './badge.vue'; import BadgeForm from './badge_form.vue'; import BadgeList from './badge_list.vue'; @@ -25,13 +25,13 @@ export default { ...mapState(['badgeInModal', 'isEditing']), primaryProps() { return { - text: s__('Delete badge'), + text: __('Delete badge'), attributes: [{ category: 'primary' }, { variant: 'danger' }], }; }, cancelProps() { return { - text: s__('Cancel'), + text: __('Cancel'), }; }, }, diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index f5e3bab6ff0..918519f386b 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -1,5 +1,5 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import NoteableNote from '~/notes/components/noteable_note.vue'; import PublishButton from './publish_button.vue'; @@ -10,6 +10,9 @@ export default { PublishButton, GlButton, }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, props: { draft: { type: Object, @@ -72,6 +75,9 @@ export default { } }, }, + safeHtmlConfig: { + ADD_TAGS: ['use', 'gl-emoji'], + }, }; </script> <template> @@ -104,8 +110,8 @@ export default { <template v-if="!isEditingDraft"> <div v-if="draftCommands" + v-safe-html:[$options.safeHtmlConfig]="draftCommands" class="referenced-commands draft-note-commands" - v-html="draftCommands /* eslint-disable-line vue/no-v-html */" ></div> <p class="draft-note-actions d-flex"> diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue index 91b3b6a685c..e90c29e939f 100644 --- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue @@ -15,14 +15,14 @@ export default { ...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']), }, methods: { - ...mapActions('diffs', ['toggleActiveFileByHash']), + ...mapActions('diffs', ['setCurrentFileHash']), ...mapActions('batchComments', ['scrollToDraft']), isLast(index) { return index === this.sortedDrafts.length - 1; }, async onClickDraft(draft) { if (this.viewDiffsFileByFile && draft.file_hash) { - await this.toggleActiveFileByHash(draft.file_hash); + await this.setCurrentFileHash(draft.file_hash); } await this.scrollToDraft(draft); diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue index 213e026c41f..e3e43ea3a0e 100644 --- a/app/assets/javascripts/blob/components/blob_content.vue +++ b/app/assets/javascripts/blob/components/blob_content.vue @@ -65,7 +65,7 @@ export default { }; </script> <template> - <div class="blob-viewer" :data-type="activeViewer.type"> + <div class="blob-viewer" :data-type="activeViewer.type" :data-loaded="!loading"> <gl-loading-icon v-if="loading" size="md" color="dark" class="my-4 mx-auto" /> <template v-else> diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue index 4742b4ae4b4..933ad448c77 100644 --- a/app/assets/javascripts/blob/components/blob_header.vue +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -3,12 +3,14 @@ import DefaultActions from './blob_header_default_actions.vue'; import BlobFilepath from './blob_header_filepath.vue'; import ViewerSwitcher from './blob_header_viewer_switcher.vue'; import { SIMPLE_BLOB_VIEWER } from './constants'; +import TableOfContents from './table_contents.vue'; export default { components: { ViewerSwitcher, DefaultActions, BlobFilepath, + TableOfContents, }, props: { blob: { @@ -70,11 +72,14 @@ export default { </script> <template> <div class="js-file-title file-title-flex-parent"> - <blob-filepath :blob="blob"> - <template #filepath-prepend> - <slot name="prepend"></slot> - </template> - </blob-filepath> + <div class="gl-display-flex"> + <table-of-contents class="gl-pr-2" /> + <blob-filepath :blob="blob"> + <template #filepath-prepend> + <slot name="prepend"></slot> + </template> + </blob-filepath> + </div> <div class="gl-display-none gl-sm-display-flex"> <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" /> diff --git a/app/assets/javascripts/blob/components/table_contents.vue b/app/assets/javascripts/blob/components/table_contents.vue index 78ecb82f2cd..07da262ec9a 100644 --- a/app/assets/javascripts/blob/components/table_contents.vue +++ b/app/assets/javascripts/blob/components/table_contents.vue @@ -18,11 +18,12 @@ export default { }, mounted() { this.blobViewer = document.querySelector('.blob-viewer[data-type="rich"]'); + const blobViewerAttr = (attr) => this.blobViewer.getAttribute(attr); this.observer = new MutationObserver(() => { - if (this.blobViewer.classList.contains('hidden')) { + if (this.blobViewer.classList.contains('hidden') || blobViewerAttr('data-type') !== 'rich') { this.isHidden = true; - } else if (this.blobViewer.getAttribute('data-loaded') === 'true') { + } else if (blobViewerAttr('data-loaded') === 'true') { this.isHidden = false; this.generateHeaders(); } diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue index a3278f8bde2..e75aa523ed0 100644 --- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue +++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue @@ -1,7 +1,7 @@ <script> import { GlModal, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; import Cookies from 'js-cookie'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; const trackingMixin = Tracking.mixin(); @@ -69,7 +69,7 @@ export default { }, }, i18n: { - modalTitle: s__("That's it, well done!"), + modalTitle: __("That's it, well done!"), pipelinesButton: s__('MR widget|See your pipeline in action'), mergeRequestButton: s__('MR widget|Back to the Merge request'), bodyMessage: s__( diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index c10241d00d7..e6c91c7ac1f 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,4 +1,5 @@ import { sortBy, cloneDeep } from 'lodash'; +import { isGid } from '~/graphql_shared/utils'; import { ListType, MilestoneIDs } from './constants'; export function getMilestone() { @@ -95,6 +96,9 @@ export function fullMilestoneId(id) { } export function fullLabelId(label) { + if (isGid(label.id)) { + return label.id; + } if (label.project_id && label.project_id !== null) { return `gid://gitlab/ProjectLabel/${label.id}`; } diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 1e780f9ef84..563bed6a6b8 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -83,7 +83,7 @@ export default { :data-item-path="item.referencePath" data-testid="board_card" class="board-card gl-p-5 gl-rounded-base" - @mouseup="toggleIssue($event)" + @click="toggleIssue($event)" > <board-card-inner :list="list" :item="item" :update-filters="true" /> </li> diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 9bbb8a1a1b2..54668c9e88e 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -15,6 +15,7 @@ import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import SidebarLabelsWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; +import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { @@ -53,6 +54,9 @@ export default { allowLabelEdit: { default: false, }, + labelsFilterBasePath: { + default: '', + }, }, inheritAttrs: false, computed: { @@ -63,7 +67,7 @@ export default { 'groupPathForActiveIssue', 'projectPathForActiveIssue', ]), - ...mapState(['sidebarType', 'issuableType', 'isSettingLabels']), + ...mapState(['sidebarType', 'issuableType']), isIssuableSidebar() { return this.sidebarType === ISSUABLE; }, @@ -84,7 +88,15 @@ export default { }); }, attrWorkspacePath() { - return this.isGroupBoard ? this.groupPathForActiveIssue : undefined; + return this.isGroupBoard ? this.groupPathForActiveIssue : this.projectPathForActiveIssue; + }, + labelType() { + return this.isGroupBoard ? LabelType.group : LabelType.project; + }, + labelsFilterPath() { + return this.isGroupBoard + ? this.labelsFilterBasePath.replace(':project_path', this.projectPathForActiveIssue) + : this.labelsFilterBasePath; }, }, methods: { @@ -98,21 +110,19 @@ export default { handleClose() { this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType }); }, - handleUpdateSelectedLabels(input) { + handleUpdateSelectedLabels({ labels, id }) { this.setActiveBoardItemLabels({ - iid: this.activeBoardItem.iid, + id, projectPath: this.projectPathForActiveIssue, - addLabelIds: input.map((label) => getIdFromGraphQLId(label.id)), - removeLabelIds: this.activeBoardItem.labels - .filter((label) => !input.find((selected) => selected.id === label.id)) - .map((label) => label.id), + labelIds: labels.map((label) => getIdFromGraphQLId(label.id)), + labels, }); }, - handleLabelRemove(input) { + handleLabelRemove(removeLabelId) { this.setActiveBoardItemLabels({ iid: this.activeBoardItem.iid, projectPath: this.projectPathForActiveIssue, - removeLabelIds: [input], + removeLabelIds: [removeLabelId], }); }, }, @@ -207,14 +217,14 @@ export default { :full-path="projectPathForActiveIssue" :allow-label-remove="allowLabelEdit" :allow-multiselect="true" - :selected-labels="activeBoardItem.labels" - :labels-select-in-progress="isSettingLabels" :footer-create-label-title="createLabelTitle" :footer-manage-label-title="manageLabelTitle" :labels-create-title="createLabelTitle" - :labels-filter-base-path="projectPathForActiveIssue" + :labels-filter-base-path="labelsFilterPath" :attr-workspace-path="attrWorkspacePath" + workspace-type="project" :issuable-type="issuableType" + :label-create-type="labelType" @onLabelRemove="handleLabelRemove" @updateSelectedLabels="handleUpdateSelectedLabels" > diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index 7f242dea644..6e6ada2d109 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -1,6 +1,7 @@ <script> -import { pickBy } from 'lodash'; +import { pickBy, isEmpty } from 'lodash'; import { mapActions } from 'vuex'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -19,6 +20,11 @@ export default { type: Array, required: true, }, + eeFilters: { + required: false, + type: Object, + default: () => ({}), + }, }, data() { return { @@ -26,57 +32,6 @@ export default { }; }, computed: { - urlParams() { - const { - authorUsername, - labelName, - assigneeUsername, - search, - milestoneTitle, - types, - weight, - } = this.filterParams; - let notParams = {}; - - if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) { - notParams = pickBy( - { - 'not[label_name][]': this.filterParams.not.labelName, - 'not[author_username]': this.filterParams.not.authorUsername, - 'not[assignee_username]': this.filterParams.not.assigneeUsername, - 'not[types]': this.filterParams.not.types, - 'not[milestone_title]': this.filterParams.not.milestoneTitle, - 'not[weight]': this.filterParams.not.weight, - }, - undefined, - ); - } - - return { - ...notParams, - author_username: authorUsername, - 'label_name[]': labelName, - assignee_username: assigneeUsername, - milestone_title: milestoneTitle, - search, - types, - weight, - }; - }, - }, - methods: { - ...mapActions(['performSearch']), - handleFilter(filters) { - this.filterParams = this.getFilterParams(filters); - - updateHistory({ - url: setUrlParams(this.urlParams, window.location.href, true, false, true), - title: document.title, - replace: true, - }); - - this.performSearch(); - }, getFilteredSearchValue() { const { authorUsername, @@ -86,6 +41,8 @@ export default { milestoneTitle, types, weight, + epicId, + myReactionEmoji, } = this.filterParams; const filteredSearchValue = []; @@ -133,6 +90,20 @@ export default { }); } + if (myReactionEmoji) { + filteredSearchValue.push({ + type: 'my_reaction_emoji', + value: { data: myReactionEmoji, operator: '=' }, + }); + } + + if (epicId) { + filteredSearchValue.push({ + type: 'epic_id', + value: { data: epicId, operator: '=' }, + }); + } + if (this.filterParams['not[authorUsername]']) { filteredSearchValue.push({ type: 'author_username', @@ -177,12 +148,89 @@ export default { }); } + if (this.filterParams['not[epicId]']) { + filteredSearchValue.push({ + type: 'epic_id', + value: { data: this.filterParams['not[epicId]'], operator: '!=' }, + }); + } + + if (this.filterParams['not[myReactionEmoji]']) { + filteredSearchValue.push({ + type: 'my_reaction_emoji', + value: { data: this.filterParams['not[myReactionEmoji]'], operator: '!=' }, + }); + } + if (search) { filteredSearchValue.push(search); } return filteredSearchValue; }, + urlParams() { + const { + authorUsername, + labelName, + assigneeUsername, + search, + milestoneTitle, + types, + weight, + epicId, + myReactionEmoji, + } = this.filterParams; + + let notParams = {}; + + if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) { + notParams = pickBy( + { + 'not[label_name][]': this.filterParams.not.labelName, + 'not[author_username]': this.filterParams.not.authorUsername, + 'not[assignee_username]': this.filterParams.not.assigneeUsername, + 'not[types]': this.filterParams.not.types, + 'not[milestone_title]': this.filterParams.not.milestoneTitle, + 'not[weight]': this.filterParams.not.weight, + 'not[epic_id]': this.filterParams.not.epicId, + 'not[my_reaction_emoji]': this.filterParams.not.myReactionEmoji, + }, + undefined, + ); + } + + return { + ...notParams, + author_username: authorUsername, + 'label_name[]': labelName, + assignee_username: assigneeUsername, + milestone_title: milestoneTitle, + search, + types, + weight, + epic_id: getIdFromGraphQLId(epicId), + my_reaction_emoji: myReactionEmoji, + }; + }, + }, + created() { + if (!isEmpty(this.eeFilters)) { + this.filterParams = this.eeFilters; + } + }, + methods: { + ...mapActions(['performSearch']), + handleFilter(filters) { + this.filterParams = this.getFilterParams(filters); + + updateHistory({ + url: setUrlParams(this.urlParams, window.location.href, true, false, true), + title: document.title, + replace: true, + }); + + this.performSearch(); + }, getFilterParams(filters = []) { const notFilters = filters.filter((item) => item.value.operator === '!='); const equalsFilters = filters.filter( @@ -216,6 +264,12 @@ export default { case 'weight': filterParams.weight = filter.value.data; break; + case 'epic_id': + filterParams.epicId = filter.value.data; + break; + case 'my_reaction_emoji': + filterParams.myReactionEmoji = filter.value.data; + break; case 'filtered-search-term': if (filter.value.data) plainText.push(filter.value.data); break; @@ -243,7 +297,7 @@ export default { namespace="" :tokens="tokens" :search-input-placeholder="$options.i18n.search" - :initial-filter-value="getFilteredSearchValue()" + :initial-filter-value="getFilteredSearchValue" @onFilter="handleFilter" /> </template> diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index e939f0c0ebe..6ad57fd8985 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -2,10 +2,10 @@ import { GlModal, GlAlert } from '@gitlab/ui'; import { mapGetters, mapActions, mapState } from 'vuex'; import { TYPE_USER, TYPE_ITERATION, TYPE_MILESTONE } from '~/graphql_shared/constants'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getParameterByName, visitUrl } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; -import { fullLabelId, fullBoardId } from '../boards_util'; +import { fullLabelId } from '../boards_util'; import { formType } from '../constants'; import createBoardMutation from '../graphql/board_create.mutation.graphql'; @@ -18,11 +18,11 @@ const boardDefaults = { name: '', labels: [], milestone: {}, - iteration_id: undefined, + iteration: {}, assignee: {}, weight: null, - hide_backlog_list: false, - hide_closed_list: false, + hideBacklogList: false, + hideClosedList: false, }; export default { @@ -57,39 +57,16 @@ export default { type: Boolean, required: true, }, - labelsPath: { - type: String, - required: true, - }, - labelsWebUrl: { - type: String, - required: true, - }, scopedIssueBoardFeatureEnabled: { type: Boolean, required: false, default: false, }, - projectId: { - type: Number, - required: false, - default: 0, - }, - groupId: { - type: Number, - required: false, - default: 0, - }, weights: { type: Array, required: false, default: () => [], }, - enableScopedLabels: { - type: Boolean, - required: false, - default: false, - }, currentBoard: { type: Object, required: true, @@ -167,17 +144,16 @@ export default { return destroyBoardMutation; }, baseMutationVariables() { - const { board } = this; - const variables = { - name: board.name, - hideBacklogList: board.hide_backlog_list, - hideClosedList: board.hide_closed_list, - }; + const { + board: { name, hideBacklogList, hideClosedList, id }, + } = this; - return board.id + const variables = { name, hideBacklogList, hideClosedList }; + + return id ? { ...variables, - id: fullBoardId(board.id), + id, } : { ...variables, @@ -191,11 +167,13 @@ export default { assigneeId: this.board.assignee?.id ? convertToGraphQLId(TYPE_USER, this.board.assignee.id) : null, + // Temporarily converting to milestone ID due to https://gitlab.com/gitlab-org/gitlab/-/issues/344779 milestoneId: this.board.milestone?.id - ? convertToGraphQLId(TYPE_MILESTONE, this.board.milestone.id) + ? convertToGraphQLId(TYPE_MILESTONE, getIdFromGraphQLId(this.board.milestone.id)) : null, - iterationId: this.board.iteration_id - ? convertToGraphQLId(TYPE_ITERATION, this.board.iteration_id) + // Temporarily converting to iteration ID due to https://gitlab.com/gitlab-org/gitlab/-/issues/344779 + iterationId: this.board.iteration?.id + ? convertToGraphQLId(TYPE_ITERATION, getIdFromGraphQLId(this.board.iteration.id)) : null, }; }, @@ -249,7 +227,7 @@ export default { await this.$apollo.mutate({ mutation: this.deleteMutation, variables: { - id: fullBoardId(this.board.id), + id: this.board.id, }, }); }, @@ -285,19 +263,12 @@ export default { } }, setIteration(iterationId) { - this.board.iteration_id = iterationId; + this.$set(this.board, 'iteration', { + id: iterationId, + }); }, setBoardLabels(labels) { - labels.forEach((label) => { - if (label.set && !this.board.labels.find((l) => l.id === label.id)) { - this.board.labels.push({ - ...label, - textColor: label.text_color, - }); - } else if (!label.set) { - this.board.labels = this.board.labels.filter((selected) => selected.id !== label.id); - } - }); + this.board.labels = labels; }, setAssignee(assigneeId) { this.$set(this.board, 'assignee', { @@ -361,8 +332,8 @@ export default { </div> <board-configuration-options - :hide-backlog-list.sync="board.hide_backlog_list" - :hide-closed-list.sync="board.hide_closed_list" + :hide-backlog-list.sync="board.hideBacklogList" + :hide-closed-list.sync="board.hideClosedList" :readonly="readonly" /> @@ -371,11 +342,6 @@ export default { :collapse-scope="isNewForm" :board="board" :can-admin-board="canAdminBoard" - :labels-path="labelsPath" - :labels-web-url="labelsWebUrl" - :enable-scoped-labels="enableScopedLabels" - :project-id="projectId" - :group-id="groupId" :weights="weights" @set-iteration="setIteration" @set-board-labels="setBoardLabels" diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index a8d71ab7a35..e985a368e64 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -15,6 +15,8 @@ import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { n__, s__, __ } from '~/locale'; import sidebarEventHub from '~/sidebar/event_hub'; import Tracking from '~/tracking'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import AccessorUtilities from '../../lib/utils/accessor'; import { inactiveId, LIST, ListType, toggleFormEventPrefix } from '../constants'; import eventHub from '../eventhub'; @@ -40,7 +42,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [Tracking.mixin()], + mixins: [Tracking.mixin(), glFeatureFlagMixin()], inject: { boardId: { default: '', @@ -86,6 +88,13 @@ export default { listTitle() { return this.list?.label?.description || this.list?.assignee?.name || this.list.title || ''; }, + listIterationPeriod() { + const iteration = this.list?.iteration; + return iteration ? this.getIterationPeriod(iteration) : ''; + }, + isIterationList() { + return this.listType === ListType.iteration; + }, showListHeaderButton() { return !this.disabled && this.listType !== ListType.closed; }, @@ -96,7 +105,10 @@ export default { return this.listType === ListType.assignee && this.showListDetails; }, showIterationListDetails() { - return this.listType === ListType.iteration && this.showListDetails; + return this.isIterationList && this.showListDetails; + }, + iterationCadencesAvailable() { + return this.isIterationList && this.glFeatures.iterationCadences; }, showListDetails() { return !this.list.collapsed || !this.isSwimlanesHeader; @@ -208,6 +220,16 @@ export default { updateListFunction() { this.updateList({ listId: this.list.id, collapsed: this.list.collapsed }); }, + /** + * TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/344619 + * This method also exists as a utility function in ee/../iterations/utils.js + * Remove the duplication when the EE code is separated from this compoment. + */ + getIterationPeriod({ startDate, dueDate }) { + const start = formatDate(startDate, 'mmm d, yyyy', true); + const due = formatDate(dueDate, 'mmm d, yyyy', true); + return `${start} - ${due}`; + }, }, }; </script> @@ -307,6 +329,13 @@ export default { class="board-title-main-text gl-text-truncate" > {{ listTitle }} + <span + v-if="iterationCadencesAvailable" + class="gl-display-inline-block gl-text-gray-400" + data-testid="board-list-iteration-period" + > + {{ listIterationPeriod }}</span + > </span> <span v-if="listType === 'assignee'" diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 98027917221..71facba1378 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -9,17 +9,20 @@ import { GlModalDirective, } from '@gitlab/ui'; import { throttle } from 'lodash'; -import { mapGetters, mapState } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import BoardForm from 'ee_else_ce/boards/components/board_form.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; +import { s__ } from '~/locale'; import eventHub from '../eventhub'; -import groupQuery from '../graphql/group_boards.query.graphql'; -import projectQuery from '../graphql/project_boards.query.graphql'; +import groupBoardsQuery from '../graphql/group_boards.query.graphql'; +import projectBoardsQuery from '../graphql/project_boards.query.graphql'; +import groupBoardQuery from '../graphql/group_board.query.graphql'; +import projectBoardQuery from '../graphql/project_board.query.graphql'; const MIN_BOARDS_TO_VIEW_RECENT = 10; @@ -39,10 +42,6 @@ export default { }, inject: ['fullPath', 'recentBoardsEndpoint'], props: { - currentBoard: { - type: Object, - required: true, - }, throttleDuration: { type: Number, default: 200, @@ -64,22 +63,6 @@ export default { type: Boolean, required: true, }, - labelsPath: { - type: String, - required: true, - }, - labelsWebUrl: { - type: String, - required: true, - }, - projectId: { - type: Number, - required: true, - }, - groupId: { - type: Number, - required: true, - }, scopedIssueBoardFeatureEnabled: { type: Boolean, required: true, @@ -88,11 +71,6 @@ export default { type: Array, required: true, }, - enabledScopedLabels: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { @@ -107,14 +85,47 @@ export default { maxPosition: 0, filterTerm: '', currentPage: '', + board: {}, }; }, + apollo: { + board: { + query() { + return this.currentBoardQuery; + }, + variables() { + return { + fullPath: this.fullPath, + boardId: this.fullBoardId, + }; + }, + update(data) { + const board = data.workspace?.board; + return { + ...board, + labels: board?.labels?.nodes, + }; + }, + error() { + this.setError({ message: this.$options.i18n.errorFetchingBoard }); + }, + }, + }, computed: { - ...mapState(['boardType']), - ...mapGetters(['isGroupBoard']), + ...mapState(['boardType', 'fullBoardId']), + ...mapGetters(['isGroupBoard', 'isProjectBoard']), parentType() { return this.boardType; }, + currentBoardQueryCE() { + return this.isGroupBoard ? groupBoardQuery : projectBoardQuery; + }, + currentBoardQuery() { + return this.currentBoardQueryCE; + }, + isBoardLoading() { + return this.$apollo.queries.board.loading; + }, loading() { return this.loadingRecentBoards || Boolean(this.loadingBoards); }, @@ -123,9 +134,6 @@ export default { board.name.toLowerCase().includes(this.filterTerm.toLowerCase()), ); }, - board() { - return this.currentBoard; - }, showCreate() { return this.multipleIssueBoardsAvailable; }, @@ -158,6 +166,7 @@ export default { eventHub.$off('showBoardModal', this.showPage); }, methods: { + ...mapActions(['setError']), showPage(page) { this.currentPage = page; }, @@ -174,7 +183,7 @@ export default { })); }, boardQuery() { - return this.isGroupBoard ? groupQuery : projectQuery; + return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery; }, loadBoards(toggleDropdown = true) { if (toggleDropdown && this.boards.length > 0) { @@ -250,6 +259,9 @@ export default { this.hasScrollFade = this.isScrolledUp(); }, }, + i18n: { + errorFetchingBoard: s__('Board|An error occurred while fetching the board, please try again.'), + }, }; </script> @@ -260,6 +272,7 @@ export default { data-qa-selector="boards_dropdown" toggle-class="dropdown-menu-toggle js-dropdown-toggle" menu-class="flex-column dropdown-extended-height" + :loading="isBoardLoading" :text="board.name" @show="loadBoards" > @@ -354,15 +367,10 @@ export default { <board-form v-if="currentPage" - :labels-path="labelsPath" - :labels-web-url="labelsWebUrl" - :project-id="projectId" - :group-id="groupId" :can-admin-board="canAdminBoard" :scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled" :weights="weights" - :enable-scoped-labels="enabledScopedLabels" - :current-board="currentBoard" + :current-board="board" :current-page="currentPage" @cancel="cancel" /> diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue index b6c5ef955c6..bdb9c2be836 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -1,13 +1,20 @@ <script> import { GlFilteredSearchToken } from '@gitlab/ui'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { mapActions } from 'vuex'; -import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue'; +import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_search.vue'; +import { BoardType } from '~/boards/constants'; +import axios from '~/lib/utils/axios_utils'; import issueBoardFilters from '~/boards/issue_board_filters'; import { TYPE_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; -import { DEFAULT_MILESTONES_GRAPHQL } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + DEFAULT_MILESTONES_GRAPHQL, + TOKEN_TITLE_MY_REACTION, +} from '~/vue_shared/components/filtered_search_bar/constants'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'; @@ -19,6 +26,7 @@ export default { }, i18n: { search: __('Search'), + epic: __('Epic'), label: __('Label'), author: __('Author'), assignee: __('Assignee'), @@ -31,6 +39,7 @@ export default { isNot: __('is not'), }, components: { BoardFilteredSearch }, + inject: ['isSignedIn'], props: { fullPath: { type: String, @@ -42,7 +51,15 @@ export default { }, }, computed: { - tokens() { + isGroupBoard() { + return this.boardType === BoardType.group; + }, + epicsGroupPath() { + return this.isGroupBoard + ? this.fullPath + : this.fullPath.slice(0, this.fullPath.lastIndexOf('/')); + }, + tokensCE() { const { label, is, @@ -103,6 +120,32 @@ export default { symbol: '~', fetchLabels, }, + ...(this.isSignedIn + ? [ + { + type: 'my_reaction_emoji', + title: TOKEN_TITLE_MY_REACTION, + icon: 'thumb-up', + token: EmojiToken, + unique: true, + fetchEmojis: (search = '') => { + // TODO: Switch to GraphQL query when backend is ready: https://gitlab.com/gitlab-org/gitlab/-/issues/339694 + return axios + .get(`${gon.relative_url_root || ''}/-/autocomplete/award_emojis`) + .then(({ data }) => { + if (search) { + return { + data: fuzzaldrinPlus.filter(data, search, { + key: ['name'], + }), + }; + } + return { data }; + }); + }, + }, + ] + : []), { type: 'milestone_title', title: milestone, @@ -117,7 +160,6 @@ export default { icon: 'issues', title: type, type: 'types', - operators: [{ value: '=', description: is }], token: GlFilteredSearchToken, unique: true, options: [ @@ -134,6 +176,9 @@ export default { }, ]; }, + tokens() { + return this.tokensCE; + }, }, methods: { ...mapActions(['fetchMilestones']), diff --git a/app/assets/javascripts/boards/components/new_board_button.vue b/app/assets/javascripts/boards/components/new_board_button.vue new file mode 100644 index 00000000000..f7914c636cc --- /dev/null +++ b/app/assets/javascripts/boards/components/new_board_button.vue @@ -0,0 +1,47 @@ +<script> +import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { formType } from '~/boards/constants'; +import eventHub from '~/boards/eventhub'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; + +export default { + components: { + GlButton, + GitlabExperiment, + }, + directives: { + GlModalDirective, + }, + mixins: [Tracking.mixin()], + inject: ['multipleIssueBoardsAvailable', 'canAdminBoard'], + computed: { + canShowCreateButton() { + return this.canAdminBoard && this.multipleIssueBoardsAvailable; + }, + createButtonText() { + return s__('Boards|New board'); + }, + }, + methods: { + showDialog() { + this.track('click_button', { label: 'create_board' }); + eventHub.$emit('showBoardModal', formType.new); + }, + }, +}; +</script> + +<template> + <gitlab-experiment name="prominent_create_board_btn"> + <template #control> </template> + <template #candidate> + <div v-if="canShowCreateButton" class="gl-ml-1 gl-mr-3 gl-display-flex gl-align-items-center"> + <gl-button data-qa-selector="new_board_button" @click.prevent="showDialog"> + {{ createButtonText }} + </gl-button> + </div> + </template> + </gitlab-experiment> +</template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue index e74463825c5..ec53947fd5f 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -91,9 +91,7 @@ export default { try { const addLabelIds = payload.filter((label) => label.set).map((label) => label.id); - const removeLabelIds = this.selectedLabels - .filter((label) => !payload.find((selected) => selected.id === label.id)) - .map((label) => label.id); + const removeLabelIds = payload.filter((label) => !label.set).map((label) => label.id); const input = { addLabelIds, @@ -164,7 +162,7 @@ export default { :labels-list-title="__('Select label')" :dropdown-button-text="__('Choose labels')" :is-editing="edit" - variant="embedded" + variant="sidebar" class="gl-display-block labels gl-w-full" @updateSelectedLabels="setLabels" > diff --git a/app/assets/javascripts/boards/graphql.js b/app/assets/javascripts/boards/graphql.js index d8d16184936..64938cb42ed 100644 --- a/app/assets/javascripts/boards/graphql.js +++ b/app/assets/javascripts/boards/graphql.js @@ -17,6 +17,5 @@ export const gqlClient = createDefaultClient( fragmentMatcher, }, - assumeImmutableResults: true, }, ); diff --git a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql index 3eb23f62940..0e1d11727cf 100644 --- a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql @@ -1,6 +1,6 @@ #import "./board_list.fragment.graphql" -mutation CreateBoardList($boardId: BoardID!, $backlog: Boolean, $labelId: LabelID) { +mutation createBoardList($boardId: BoardID!, $backlog: Boolean, $labelId: LabelID) { boardListCreate(input: { boardId: $boardId, backlog: $backlog, labelId: $labelId }) { list { ...BoardListFragment diff --git a/app/assets/javascripts/boards/graphql/board_lists.query.graphql b/app/assets/javascripts/boards/graphql/board_lists.query.graphql index 734867c77e9..47e87907d76 100644 --- a/app/assets/javascripts/boards/graphql/board_lists.query.graphql +++ b/app/assets/javascripts/boards/graphql/board_lists.query.graphql @@ -1,6 +1,6 @@ #import "ee_else_ce/boards/graphql/board_list.fragment.graphql" -query ListIssues( +query BoardLists( $fullPath: ID! $boardId: ID! $filters: BoardIssueInput diff --git a/app/assets/javascripts/boards/graphql/board_scope.fragment.graphql b/app/assets/javascripts/boards/graphql/board_scope.fragment.graphql new file mode 100644 index 00000000000..57f51822d91 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/board_scope.fragment.graphql @@ -0,0 +1,6 @@ +fragment BoardScopeFragment on Board { + id + name + hideBacklogList + hideClosedList +} diff --git a/app/assets/javascripts/boards/graphql/group_board.query.graphql b/app/assets/javascripts/boards/graphql/group_board.query.graphql new file mode 100644 index 00000000000..77c8e0378f0 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/group_board.query.graphql @@ -0,0 +1,9 @@ +#import "ee_else_ce/boards/graphql/board_scope.fragment.graphql" + +query GroupBoard($fullPath: ID!, $boardId: ID!) { + workspace: group(fullPath: $fullPath) { + board(id: $boardId) { + ...BoardScopeFragment + } + } +} diff --git a/app/assets/javascripts/boards/graphql/group_board_iterations.query.graphql b/app/assets/javascripts/boards/graphql/group_board_iterations.query.graphql deleted file mode 100644 index 1c382c4747b..00000000000 --- a/app/assets/javascripts/boards/graphql/group_board_iterations.query.graphql +++ /dev/null @@ -1,10 +0,0 @@ -query GroupBoardIterations($fullPath: ID!, $title: String) { - group(fullPath: $fullPath) { - iterations(includeAncestors: true, title: $title) { - nodes { - id - title - } - } - } -} diff --git a/app/assets/javascripts/boards/graphql/group_projects.query.graphql b/app/assets/javascripts/boards/graphql/group_projects.query.graphql index 3218c06357c..c5732bbaff3 100644 --- a/app/assets/javascripts/boards/graphql/group_projects.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_projects.query.graphql @@ -1,6 +1,6 @@ #import "~/graphql_shared/fragments/pageInfo.fragment.graphql" -query getGroupProjects($fullPath: ID!, $search: String, $after: String) { +query boardsGetGroupProjects($fullPath: ID!, $search: String, $after: String) { group(fullPath: $fullPath) { projects(search: $search, after: $after, first: 100, includeSubgroups: true) { nodes { diff --git a/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql index 3c574fd8c87..570731ecac6 100644 --- a/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql @@ -1,6 +1,6 @@ #import "ee_else_ce/boards/graphql/issue.fragment.graphql" -mutation IssueMoveList( +mutation issueMoveList( $projectPath: ID! $iid: String! $boardId: ID! diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql index 787dd77b901..9f93bc6d5bf 100644 --- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql +++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql @@ -1,6 +1,6 @@ #import "ee_else_ce/boards/graphql/issue.fragment.graphql" -query ListIssues( +query BoardListEE( $fullPath: ID! $boardId: ID! $id: ID diff --git a/app/assets/javascripts/boards/graphql/project_board.query.graphql b/app/assets/javascripts/boards/graphql/project_board.query.graphql new file mode 100644 index 00000000000..6e4cd6bed57 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/project_board.query.graphql @@ -0,0 +1,9 @@ +#import "ee_else_ce/boards/graphql/board_scope.fragment.graphql" + +query ProjectBoard($fullPath: ID!, $boardId: ID!) { + workspace: project(fullPath: $fullPath) { + board(id: $boardId) { + ...BoardScopeFragment + } + } +} diff --git a/app/assets/javascripts/boards/graphql/project_board_iterations.query.graphql b/app/assets/javascripts/boards/graphql/project_board_iterations.query.graphql deleted file mode 100644 index 078151a275a..00000000000 --- a/app/assets/javascripts/boards/graphql/project_board_iterations.query.graphql +++ /dev/null @@ -1,10 +0,0 @@ -query ProjectBoardIterations($fullPath: ID!, $title: String) { - project(fullPath: $fullPath) { - iterations(includeAncestors: true, title: $title) { - nodes { - id - title - } - } - } -} diff --git a/app/assets/javascripts/boards/graphql/project_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_milestones.query.graphql index 724b7f5a34c..61c9ddded9b 100644 --- a/app/assets/javascripts/boards/graphql/project_milestones.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_milestones.query.graphql @@ -1,4 +1,4 @@ -query projectMilestones( +query boardProjectMilestones( $fullPath: ID! $state: MilestoneStateEnum $includeAncestors: Boolean diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index b6b1094fb3a..6fa8dd63245 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -13,9 +13,10 @@ import FilteredSearchBoards from '~/boards/filtered_search_boards'; import initBoardsFilteredSearch from '~/boards/mount_filtered_search_issue_boards'; import store from '~/boards/stores'; import toggleFocusMode from '~/boards/toggle_focus'; -import { NavigationType, parseBoolean } from '~/lib/utils/common_utils'; +import { NavigationType, isLoggedIn, parseBoolean } from '~/lib/utils/common_utils'; import { fullBoardId } from './boards_util'; import boardConfigToggle from './config_toggle'; +import initNewBoard from './new_board'; import { gqlClient } from './graphql'; import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher'; @@ -109,7 +110,7 @@ export default () => { }); if (gon?.features?.issueBoardsFilteredSearch) { - initBoardsFilteredSearch(apolloProvider); + initBoardsFilteredSearch(apolloProvider, isLoggedIn()); } mountBoardApp($boardApp); @@ -130,6 +131,7 @@ export default () => { } boardConfigToggle(); + initNewBoard(); toggleFocusMode(); toggleLabels(); @@ -142,5 +144,7 @@ export default () => { fullPath: $boardApp.dataset.fullPath, rootPath: $boardApp.dataset.boardsEndpoint, recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint, + allowScopedLabels: $boardApp.dataset.scopedLabels, + labelsManagePath: $boardApp.dataset.labelsManagePath, }); }; diff --git a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js index 7732091ef34..1ea74d5685c 100644 --- a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js +++ b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js @@ -1,10 +1,10 @@ import Vue from 'vue'; -import IssueBoardFilteredSearch from '~/boards/components/issue_board_filtered_search.vue'; +import IssueBoardFilteredSearch from 'ee_else_ce/boards/components/issue_board_filtered_search.vue'; import store from '~/boards/stores'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { queryToObject } from '~/lib/utils/url_utility'; -export default (apolloProvider) => { +export default (apolloProvider, isSignedIn) => { const el = document.getElementById('js-issue-board-filtered-search'); const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); @@ -20,6 +20,7 @@ export default (apolloProvider) => { el, provide: { initialFilterParams, + isSignedIn, }, store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094 apolloProvider, diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js index a3a8ad06c43..ed32579a9c3 100644 --- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js +++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js @@ -1,23 +1,32 @@ +import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue'; import store from '~/boards/stores'; import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; +import introspectionQueryResultData from '~/sidebar/fragmentTypes.json'; Vue.use(VueApollo); +const fragmentMatcher = new IntrospectionFragmentMatcher({ + introspectionQueryResultData, +}); + const apolloProvider = new VueApollo({ defaultClient: createDefaultClient( {}, { - assumeImmutableResults: true, + cacheConfig: { + fragmentMatcher, + }, }, ), }); export default (params = {}) => { const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher'); + const { dataset } = boardsSwitcherElement; return new Vue({ el: boardsSwitcherElement, components: { @@ -29,18 +38,16 @@ export default (params = {}) => { fullPath: params.fullPath, rootPath: params.rootPath, recentBoardsEndpoint: params.recentBoardsEndpoint, + allowScopedLabels: params.allowScopedLabels, + labelsManagePath: params.labelsManagePath, + allowLabelCreate: parseBoolean(dataset.canAdminBoard), }, data() { - const { dataset } = boardsSwitcherElement; - const boardsSelectorProps = { ...dataset, - currentBoard: JSON.parse(dataset.currentBoard), hasMissingBoards: parseBoolean(dataset.hasMissingBoards), canAdminBoard: parseBoolean(dataset.canAdminBoard), multipleIssueBoardsAvailable: parseBoolean(dataset.multipleIssueBoardsAvailable), - projectId: dataset.projectId ? Number(dataset.projectId) : 0, - groupId: Number(dataset.groupId), scopedIssueBoardFeatureEnabled: parseBoolean(dataset.scopedIssueBoardFeatureEnabled), weights: JSON.parse(dataset.weights), }; diff --git a/app/assets/javascripts/boards/new_board.js b/app/assets/javascripts/boards/new_board.js new file mode 100644 index 00000000000..34f2fea79a9 --- /dev/null +++ b/app/assets/javascripts/boards/new_board.js @@ -0,0 +1,29 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { getExperimentVariant } from '~/experimentation/utils'; +import { CANDIDATE_VARIANT } from '~/experimentation/constants'; +import NewBoardButton from './components/new_board_button.vue'; + +export default () => { + if (getExperimentVariant('prominent_create_board_btn') !== CANDIDATE_VARIANT) { + return; + } + + const el = document.querySelector('.js-new-board'); + + if (!el) { + return; + } + + // eslint-disable-next-line no-new + new Vue({ + el, + provide: { + multipleIssueBoardsAvailable: parseBoolean(el.dataset.multipleIssueBoardsAvailable), + canAdminBoard: parseBoolean(el.dataset.canAdminBoard), + }, + render(h) { + return h(NewBoardButton); + }, + }); +}; diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index ca993e75cf9..3a96e535cf7 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -36,13 +36,11 @@ import { } from '../boards_util'; import { gqlClient } from '../graphql'; import boardLabelsQuery from '../graphql/board_labels.query.graphql'; -import groupBoardIterationsQuery from '../graphql/group_board_iterations.query.graphql'; import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql'; import groupProjectsQuery from '../graphql/group_projects.query.graphql'; import issueCreateMutation from '../graphql/issue_create.mutation.graphql'; import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql'; import listsIssuesQuery from '../graphql/lists_issues.query.graphql'; -import projectBoardIterationsQuery from '../graphql/project_board_iterations.query.graphql'; import projectBoardMilestonesQuery from '../graphql/project_board_milestones.query.graphql'; import * as types from './mutation_types'; @@ -203,52 +201,6 @@ export default { }); }, - fetchIterations({ state, commit }, title) { - commit(types.RECEIVE_ITERATIONS_REQUEST); - - const { fullPath, boardType } = state; - - const variables = { - fullPath, - title, - }; - - let query; - if (boardType === BoardType.project) { - query = projectBoardIterationsQuery; - } - if (boardType === BoardType.group) { - query = groupBoardIterationsQuery; - } - - if (!query) { - // eslint-disable-next-line @gitlab/require-i18n-strings - throw new Error('Unknown board type'); - } - - return gqlClient - .query({ - query, - variables, - }) - .then(({ data }) => { - const errors = data[boardType]?.errors; - const iterations = data[boardType]?.iterations.nodes; - - if (errors?.[0]) { - throw new Error(errors[0]); - } - - commit(types.RECEIVE_ITERATIONS_SUCCESS, iterations); - - return iterations; - }) - .catch((e) => { - commit(types.RECEIVE_ITERATIONS_FAILURE); - throw e; - }); - }, - fetchMilestones({ state, commit }, searchTerm) { commit(types.RECEIVE_MILESTONES_REQUEST); @@ -656,30 +608,45 @@ export default { }, setActiveIssueLabels: async ({ commit, getters }, input) => { - commit(types.SET_LABELS_LOADING, true); const { activeBoardItem } = getters; - const { data } = await gqlClient.mutate({ - mutation: issueSetLabelsMutation, - variables: { - input: { - iid: input.iid || String(activeBoardItem.iid), - addLabelIds: input.addLabelIds ?? [], - removeLabelIds: input.removeLabelIds ?? [], - projectPath: input.projectPath, + + if (!gon.features?.labelsWidget) { + const { data } = await gqlClient.mutate({ + mutation: issueSetLabelsMutation, + variables: { + input: { + iid: input.iid || String(activeBoardItem.iid), + labelIds: input.labelsId ?? undefined, + addLabelIds: input.addLabelIds ?? [], + removeLabelIds: input.removeLabelIds ?? [], + projectPath: input.projectPath, + }, }, - }, - }); + }); - commit(types.SET_LABELS_LOADING, false); + if (data.updateIssue?.errors?.length > 0) { + throw new Error(data.updateIssue.errors); + } + + commit(types.UPDATE_BOARD_ITEM_BY_ID, { + itemId: data.updateIssue?.issue?.id || activeBoardItem.id, + prop: 'labels', + value: data.updateIssue?.issue?.labels.nodes, + }); - if (data.updateIssue?.errors?.length > 0) { - throw new Error(data.updateIssue.errors); + return; } + let labels = input?.labels || []; + if (input.removeLabelIds) { + labels = activeBoardItem.labels.filter( + (label) => input.removeLabelIds[0] !== getIdFromGraphQLId(label.id), + ); + } commit(types.UPDATE_BOARD_ITEM_BY_ID, { - itemId: data.updateIssue?.issue?.id || activeBoardItem.id, + itemId: input.id || activeBoardItem.id, prop: 'labels', - value: data.updateIssue.issue.labels.nodes, + value: labels, }); }, diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index 26b785932bb..31b78014525 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -28,7 +28,6 @@ export const ADD_BOARD_ITEM_TO_LIST = 'ADD_BOARD_ITEM_TO_LIST'; export const REMOVE_BOARD_ITEM_FROM_LIST = 'REMOVE_BOARD_ITEM_FROM_LIST'; export const SET_ACTIVE_ID = 'SET_ACTIVE_ID'; export const UPDATE_BOARD_ITEM_BY_ID = 'UPDATE_BOARD_ITEM_BY_ID'; -export const SET_LABELS_LOADING = 'SET_LABELS_LOADING'; export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING'; export const RESET_ISSUES = 'RESET_ISSUES'; export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS'; @@ -42,7 +41,3 @@ export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS'; export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS'; export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION'; export const SET_ERROR = 'SET_ERROR'; - -export const RECEIVE_ITERATIONS_REQUEST = 'RECEIVE_ITERATIONS_REQUEST'; -export const RECEIVE_ITERATIONS_SUCCESS = 'RECEIVE_ITERATIONS_SUCCESS'; -export const RECEIVE_ITERATIONS_FAILURE = 'RECEIVE_ITERATIONS_FAILURE'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index d381c076c19..2a2ce7652e6 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -64,20 +64,6 @@ export default { ); }, - [mutationTypes.RECEIVE_ITERATIONS_REQUEST](state) { - state.iterationsLoading = true; - }, - - [mutationTypes.RECEIVE_ITERATIONS_SUCCESS](state, iterations) { - state.iterations = iterations; - state.iterationsLoading = false; - }, - - [mutationTypes.RECEIVE_ITERATIONS_FAILURE](state) { - state.iterationsLoading = false; - state.error = __('Failed to load iterations.'); - }, - [mutationTypes.SET_ACTIVE_ID](state, { id, sidebarType }) { state.activeId = id; state.sidebarType = sidebarType; @@ -195,10 +181,6 @@ export default { Vue.set(state.boardItems[itemId], prop, value); }, - [mutationTypes.SET_LABELS_LOADING](state, isLoading) { - state.isSettingLabels = isLoading; - }, - [mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) { state.isSettingAssignees = isLoading; }, diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 2a6605e687b..80c51c966d2 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -12,7 +12,6 @@ export default () => ({ listsFlags: {}, boardItemsByListId: {}, backupItemsList: [], - isSettingLabels: false, isSettingAssignees: false, pageInfoByListId: {}, boardItems: {}, diff --git a/app/assets/javascripts/breadcrumb.js b/app/assets/javascripts/breadcrumb.js index b8b8a0b2867..b9d3742974c 100644 --- a/app/assets/javascripts/breadcrumb.js +++ b/app/assets/javascripts/breadcrumb.js @@ -1,5 +1,4 @@ import $ from 'jquery'; -import { hide } from '~/tooltips'; export const addTooltipToEl = (el) => { const textEl = el.querySelector('.js-breadcrumb-item-text'); @@ -19,16 +18,23 @@ export default () => { .filter((el) => !el.classList.contains('dropdown')) .map((el) => el.querySelector('a')) .filter((el) => el); - const $expander = $('.js-breadcrumbs-collapsed-expander'); + const $expanderBtn = $('.js-breadcrumbs-collapsed-expander'); topLevelLinks.forEach((el) => addTooltipToEl(el)); - $expander.closest('.dropdown').on('show.bs.dropdown hide.bs.dropdown', (e) => { - const $el = $('.js-breadcrumbs-collapsed-expander', e.currentTarget); + $expanderBtn.on('click', () => { + const detailItems = $('.breadcrumbs-detail-item'); + const hiddenClass = 'gl-display-none!'; - $el.toggleClass('open'); + $.each(detailItems, (_key, item) => { + $(item).toggleClass(hiddenClass); + }); - hide($el); + // remove the ellipsis + $('li.expander').remove(); + + // set focus on first breadcrumb item + $('.breadcrumb-item-text').first().focus(); }); } }; diff --git a/app/assets/javascripts/chronic_duration.js b/app/assets/javascripts/chronic_duration.js new file mode 100644 index 00000000000..1073d736b06 --- /dev/null +++ b/app/assets/javascripts/chronic_duration.js @@ -0,0 +1,417 @@ +/* + * NOTE: + * Changes to this file should be kept in sync with + * https://gitlab.com/gitlab-org/gitlab-chronic-duration/-/blob/master/lib/gitlab_chronic_duration.rb. + */ + +/* + * This code is based on code from + * https://gitlab.com/gitlab-org/gitlab-chronic-duration and is + * distributed under the following license: + * + * MIT License + * + * Copyright (c) Henry Poydar + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +export class DurationParseError extends Error {} + +// On average, there's a little over 4 weeks in month. +const FULL_WEEKS_PER_MONTH = 4; + +const HOURS_PER_DAY = 24; +const DAYS_PER_MONTH = 30; + +const FLOAT_MATCHER = /[0-9]*\.?[0-9]+/g; +const DURATION_UNITS_LIST = ['seconds', 'minutes', 'hours', 'days', 'weeks', 'months', 'years']; + +const MAPPINGS = { + seconds: 'seconds', + second: 'seconds', + secs: 'seconds', + sec: 'seconds', + s: 'seconds', + minutes: 'minutes', + minute: 'minutes', + mins: 'minutes', + min: 'minutes', + m: 'minutes', + hours: 'hours', + hour: 'hours', + hrs: 'hours', + hr: 'hours', + h: 'hours', + days: 'days', + day: 'days', + dy: 'days', + d: 'days', + weeks: 'weeks', + week: 'weeks', + wks: 'weeks', + wk: 'weeks', + w: 'weeks', + months: 'months', + mo: 'months', + mos: 'months', + month: 'months', + years: 'years', + year: 'years', + yrs: 'years', + yr: 'years', + y: 'years', +}; + +const JOIN_WORDS = ['and', 'with', 'plus']; + +function convertToNumber(string) { + const f = parseFloat(string); + return f % 1 > 0 ? f : parseInt(string, 10); +} + +function durationUnitsSecondsMultiplier(unit, opts) { + if (!DURATION_UNITS_LIST.includes(unit)) { + return 0; + } + + const hoursPerDay = opts.hoursPerDay || HOURS_PER_DAY; + const daysPerMonth = opts.daysPerMonth || DAYS_PER_MONTH; + const daysPerWeek = Math.trunc(daysPerMonth / FULL_WEEKS_PER_MONTH); + + switch (unit) { + case 'years': + return 31557600; + case 'months': + return 3600 * hoursPerDay * daysPerMonth; + case 'weeks': + return 3600 * hoursPerDay * daysPerWeek; + case 'days': + return 3600 * hoursPerDay; + case 'hours': + return 3600; + case 'minutes': + return 60; + case 'seconds': + return 1; + default: + return 0; + } +} + +function calculateFromWords(string, opts) { + let val = 0; + const words = string.split(' '); + words.forEach((v, k) => { + if (v === '') { + return; + } + if (v.search(FLOAT_MATCHER) >= 0) { + val += + convertToNumber(v) * + durationUnitsSecondsMultiplier( + words[parseInt(k, 10) + 1] || opts.defaultUnit || 'seconds', + opts, + ); + } + }); + return val; +} + +// Parse 3:41:59 and return 3 hours 41 minutes 59 seconds +function filterByType(string) { + const chronoUnitsList = DURATION_UNITS_LIST.filter((v) => v !== 'weeks'); + if ( + string + .replace(/ +/g, '') + .search(RegExp(`${FLOAT_MATCHER.source}(:${FLOAT_MATCHER.source})+`, 'g')) >= 0 + ) { + const res = []; + string + .replace(/ +/g, '') + .split(':') + .reverse() + .forEach((v, k) => { + if (!chronoUnitsList[k]) { + return; + } + res.push(`${v} ${chronoUnitsList[k]}`); + }); + return res.reverse().join(' '); + } + return string; +} + +// Get rid of unknown words and map found +// words to defined time units +function filterThroughWhiteList(string, opts) { + const res = []; + string.split(' ').forEach((word) => { + if (word === '') { + return; + } + if (word.search(FLOAT_MATCHER) >= 0) { + res.push(word.trim()); + return; + } + const strippedWord = word.trim().replace(/^,/g, '').replace(/,$/g, ''); + if (MAPPINGS[strippedWord] !== undefined) { + res.push(MAPPINGS[strippedWord]); + } else if (!JOIN_WORDS.includes(strippedWord) && opts.raiseExceptions) { + throw new DurationParseError( + `An invalid word ${JSON.stringify(word)} was used in the string to be parsed.`, + ); + } + }); + // add '1' at front if string starts with something recognizable but not with a number, like 'day' or 'minute 30sec' + if (res.length > 0 && MAPPINGS[res[0]]) { + res.splice(0, 0, 1); + } + return res.join(' '); +} + +function cleanup(string, opts) { + let res = string.toLowerCase(); + /* + * TODO The Ruby implementation of this algorithm uses the Numerizer module, + * which converts strings like "forty two" to "42", but there is no + * JavaScript equivalent of Numerizer. Skip it for now until Numerizer is + * ported to JavaScript. + */ + res = filterByType(res); + res = res + .replace(FLOAT_MATCHER, (n) => ` ${n} `) + .replace(/ +/g, ' ') + .trim(); + return filterThroughWhiteList(res, opts); +} + +function humanizeTimeUnit(number, unit, pluralize, keepZero) { + if (number === '0' && !keepZero) { + return null; + } + let res = number + unit; + // A poor man's pluralizer + if (number !== '1' && pluralize) { + res += 's'; + } + return res; +} + +// Given a string representation of elapsed time, +// return an integer (or float, if fractions of a +// second are input) +export function parseChronicDuration(string, opts = {}) { + const result = calculateFromWords(cleanup(string, opts), opts); + return !opts.keepZero && result === 0 ? null : result; +} + +// Given an integer and an optional format, +// returns a formatted string representing elapsed time +export function outputChronicDuration(seconds, opts = {}) { + const units = { + years: 0, + months: 0, + weeks: 0, + days: 0, + hours: 0, + minutes: 0, + seconds, + }; + + const hoursPerDay = opts.hoursPerDay || HOURS_PER_DAY; + const daysPerMonth = opts.daysPerMonth || DAYS_PER_MONTH; + const daysPerWeek = Math.trunc(daysPerMonth / FULL_WEEKS_PER_MONTH); + + const decimalPlaces = + seconds % 1 !== 0 ? seconds.toString().split('.').reverse()[0].length : null; + + const minute = 60; + const hour = 60 * minute; + const day = hoursPerDay * hour; + const month = daysPerMonth * day; + const year = 31557600; + + if (units.seconds >= 31557600 && units.seconds % year < units.seconds % month) { + units.years = Math.trunc(units.seconds / year); + units.months = Math.trunc((units.seconds % year) / month); + units.days = Math.trunc(((units.seconds % year) % month) / day); + units.hours = Math.trunc((((units.seconds % year) % month) % day) / hour); + units.minutes = Math.trunc(((((units.seconds % year) % month) % day) % hour) / minute); + units.seconds = Math.trunc(((((units.seconds % year) % month) % day) % hour) % minute); + } else if (seconds >= 60) { + units.minutes = Math.trunc(seconds / 60); + units.seconds %= 60; + if (units.minutes >= 60) { + units.hours = Math.trunc(units.minutes / 60); + units.minutes = Math.trunc(units.minutes % 60); + if (!opts.limitToHours) { + if (units.hours >= hoursPerDay) { + units.days = Math.trunc(units.hours / hoursPerDay); + units.hours = Math.trunc(units.hours % hoursPerDay); + if (opts.weeks) { + if (units.days >= daysPerWeek) { + units.weeks = Math.trunc(units.days / daysPerWeek); + units.days = Math.trunc(units.days % daysPerWeek); + if (units.weeks >= FULL_WEEKS_PER_MONTH) { + units.months = Math.trunc(units.weeks / FULL_WEEKS_PER_MONTH); + units.weeks = Math.trunc(units.weeks % FULL_WEEKS_PER_MONTH); + } + } + } else if (units.days >= daysPerMonth) { + units.months = Math.trunc(units.days / daysPerMonth); + units.days = Math.trunc(units.days % daysPerMonth); + } + } + } + } + } + + let joiner = opts.joiner || ' '; + let process = null; + + let dividers; + switch (opts.format) { + case 'micro': + dividers = { + years: 'y', + months: 'mo', + weeks: 'w', + days: 'd', + hours: 'h', + minutes: 'm', + seconds: 's', + }; + joiner = ''; + break; + case 'short': + dividers = { + years: 'y', + months: 'mo', + weeks: 'w', + days: 'd', + hours: 'h', + minutes: 'm', + seconds: 's', + }; + break; + case 'long': + dividers = { + /* eslint-disable @gitlab/require-i18n-strings */ + years: ' year', + months: ' month', + weeks: ' week', + days: ' day', + hours: ' hour', + minutes: ' minute', + seconds: ' second', + /* eslint-enable @gitlab/require-i18n-strings */ + pluralize: true, + }; + break; + case 'chrono': + dividers = { + years: ':', + months: ':', + weeks: ':', + days: ':', + hours: ':', + minutes: ':', + seconds: ':', + keepZero: true, + }; + process = (str) => { + // Pad zeros + // Get rid of lead off times if they are zero + // Get rid of lead off zero + // Get rid of trailing: + const divider = ':'; + const processed = []; + str.split(divider).forEach((n) => { + if (n === '') { + return; + } + // add zeros only if n is an integer + if (n.search('\\.') >= 0) { + processed.push( + parseFloat(n) + .toFixed(decimalPlaces) + .padStart(3 + decimalPlaces, '0'), + ); + } else { + processed.push(n.padStart(2, '0')); + } + }); + return processed + .join(divider) + .replace(/^(00:)+/g, '') + .replace(/^0/g, '') + .replace(/:$/g, ''); + }; + joiner = ''; + break; + default: + dividers = { + /* eslint-disable @gitlab/require-i18n-strings */ + years: ' yr', + months: ' mo', + weeks: ' wk', + days: ' day', + hours: ' hr', + minutes: ' min', + seconds: ' sec', + /* eslint-enable @gitlab/require-i18n-strings */ + pluralize: true, + }; + break; + } + + let result = []; + ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds'].forEach((t) => { + if (t === 'weeks' && !opts.weeks) { + return; + } + let num = units[t]; + if (t === 'seconds' && num % 0 !== 0) { + num = num.toFixed(decimalPlaces); + } else { + num = num.toString(); + } + const keepZero = !dividers.keepZero && t === 'seconds' ? opts.keepZero : dividers.keepZero; + const humanized = humanizeTimeUnit(num, dividers[t], dividers.pluralize, keepZero); + if (humanized !== null) { + result.push(humanized); + } + }); + + if (opts.units) { + result = result.slice(0, opts.units); + } + + result = result.join(joiner); + + if (process) { + result = process(result); + } + + return result.length === 0 ? null : result; +} diff --git a/app/assets/javascripts/ci_lint/index.js b/app/assets/javascripts/ci_lint/index.js index f97590ec5db..274aab45deb 100644 --- a/app/assets/javascripts/ci_lint/index.js +++ b/app/assets/javascripts/ci_lint/index.js @@ -8,9 +8,7 @@ import CiLint from './components/ci_lint.vue'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(resolvers, { - assumeImmutableResults: true, - }), + defaultClient: createDefaultClient(resolvers), }); export default (containerId = '#js-ci-lint') => { diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue index 5c672d288c5..afbba9d1f7c 100644 --- a/app/assets/javascripts/clusters/agents/components/show.vue +++ b/app/assets/javascripts/clusters/agents/components/show.vue @@ -128,6 +128,7 @@ export default { </p> <gl-tabs> + <slot name="ee-security-tab"></slot> <gl-tab> <template #title> <span data-testid="cluster-agent-token-count"> diff --git a/app/assets/javascripts/clusters/agents/index.js b/app/assets/javascripts/clusters/agents/index.js index bcb5b271203..426d8d83847 100644 --- a/app/assets/javascripts/clusters/agents/index.js +++ b/app/assets/javascripts/clusters/agents/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import AgentShowPage from './components/show.vue'; +import AgentShowPage from 'ee_else_ce/clusters/agents/components/show.vue'; Vue.use(VueApollo); diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue index 0da7be4040f..98db620e3ab 100644 --- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue +++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue @@ -1,8 +1,7 @@ <script> -import { GlModal, GlButton, GlFormInput } from '@gitlab/ui'; -import { escape } from 'lodash'; +import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui'; import csrf from '~/lib/utils/csrf'; -import { s__, sprintf } from '~/locale'; +import { s__ } from '~/locale'; import SplitButton from '~/vue_shared/components/split_button.vue'; const splitButtonActionItems = [ @@ -29,6 +28,7 @@ export default { GlModal, GlButton, GlFormInput, + GlSprintf, }, props: { clusterPath: { @@ -67,17 +67,11 @@ export default { : s__('ClusterIntegration|You are about to remove your cluster integration.'); }, confirmationTextLabel() { - return sprintf( - this.confirmCleanup - ? s__( - 'ClusterIntegration|To remove your integration and resources, type %{clusterName} to confirm:', - ) - : s__('ClusterIntegration|To remove your integration, type %{clusterName} to confirm:'), - { - clusterName: `<code>${escape(this.clusterName)}</code>`, - }, - false, - ); + return this.confirmCleanup + ? s__( + 'ClusterIntegration|To remove your integration and resources, type %{clusterName} to confirm:', + ) + : s__('ClusterIntegration|To remove your integration, type %{clusterName} to confirm:'); }, canSubmit() { return this.enteredClusterName === this.clusterName; @@ -140,7 +134,13 @@ export default { <!-- eslint-enable @gitlab/vue-require-i18n-strings --> </ul> </div> - <strong v-html="confirmationTextLabel /* eslint-disable-line vue/no-v-html */"></strong> + <strong> + <gl-sprintf :message="confirmationTextLabel"> + <template #clusterName> + <code>{{ clusterName }}</code> + </template> + </gl-sprintf> + </strong> <form ref="form" :action="clusterPath" method="post" class="gl-mb-5"> <input ref="method" type="hidden" name="_method" value="delete" /> <input :value="csrfToken" type="hidden" name="authenticity_token" /> @@ -159,7 +159,7 @@ export default { ) }}</span> <template #modal-footer> - <gl-button variant="secondary" @click="handleCancel">{{ s__('Cancel') }}</gl-button> + <gl-button variant="secondary" @click="handleCancel">{{ __('Cancel') }}</gl-button> <template v-if="confirmCleanup"> <gl-button :disabled="!canSubmit" diff --git a/app/assets/javascripts/clusters_list/clusters_util.js b/app/assets/javascripts/clusters_list/clusters_util.js index 9b870134512..c78c93fe1ba 100644 --- a/app/assets/javascripts/clusters_list/clusters_util.js +++ b/app/assets/javascripts/clusters_list/clusters_util.js @@ -6,3 +6,7 @@ export function generateAgentRegistrationCommand(agentToken, kasAddress) { --agent-version stable \\ --namespace gitlab-kubernetes-agent | kubectl apply -f -`; } + +export function getAgentConfigPath(clusterAgentName) { + return `.gitlab/agents/${clusterAgentName}`; +} diff --git a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue index 405339b3d36..af44a23b4b3 100644 --- a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue +++ b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue @@ -1,9 +1,16 @@ <script> import { GlButton, GlEmptyState, GlLink, GlSprintf, GlAlert, GlModalDirective } from '@gitlab/ui'; -import { INSTALL_AGENT_MODAL_ID } from '../constants'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { INSTALL_AGENT_MODAL_ID, I18N_AGENTS_EMPTY_STATE } from '../constants'; export default { + i18n: I18N_AGENTS_EMPTY_STATE, modalId: INSTALL_AGENT_MODAL_ID, + multipleClustersDocsUrl: helpPagePath('user/project/clusters/multiple_kubernetes_clusters'), + installDocsUrl: helpPagePath('administration/clusters/kas'), + getStartedDocsUrl: helpPagePath('user/clusters/agent/index', { + anchor: 'define-a-configuration-repository', + }), components: { GlButton, GlEmptyState, @@ -14,19 +21,17 @@ export default { directives: { GlModalDirective, }, - inject: [ - 'emptyStateImage', - 'projectPath', - 'agentDocsUrl', - 'installDocsUrl', - 'getStartedDocsUrl', - 'integrationDocsUrl', - ], + inject: ['emptyStateImage', 'projectPath'], props: { hasConfigurations: { type: Boolean, required: true, }, + isChildComponent: { + default: false, + required: false, + type: Boolean, + }, }, computed: { repositoryPath() { @@ -37,22 +42,19 @@ export default { </script> <template> - <gl-empty-state - :svg-path="emptyStateImage" - :title="s__('ClusterAgents|Integrate Kubernetes with a GitLab Agent')" - class="empty-state--agent" - > + <gl-empty-state :svg-path="emptyStateImage" title="" class="agents-empty-state"> <template #description> - <p class="mw-460 gl-mx-auto"> - <gl-sprintf - :message=" - s__( - 'ClusterAgents|The GitLab Kubernetes Agent allows an Infrastructure as Code, GitOps approach to integrating Kubernetes clusters with GitLab. %{linkStart}Learn more.%{linkEnd}', - ) - " - > + <p class="mw-460 gl-mx-auto gl-text-left"> + {{ $options.i18n.introText }} + </p> + <p class="mw-460 gl-mx-auto gl-text-left"> + <gl-sprintf :message="$options.i18n.multipleClustersText"> <template #link="{ content }"> - <gl-link :href="agentDocsUrl" target="_blank" data-testid="agent-docs-link"> + <gl-link + :href="$options.multipleClustersDocsUrl" + target="_blank" + data-testid="multiple-clusters-docs-link" + > {{ content }} </gl-link> </template> @@ -60,19 +62,9 @@ export default { </p> <p class="mw-460 gl-mx-auto"> - <gl-sprintf - :message=" - s__( - 'ClusterAgents|The GitLab Agent also requires %{linkStart}enabling the Agent Server%{linkEnd}', - ) - " - > - <template #link="{ content }"> - <gl-link :href="installDocsUrl" target="_blank" data-testid="install-docs-link"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> + <gl-link :href="$options.installDocsUrl" target="_blank" data-testid="install-docs-link"> + {{ $options.i18n.learnMoreText }} + </gl-link> </p> <gl-alert @@ -81,24 +73,20 @@ export default { class="gl-mb-5 text-left" :dismissible="false" > - {{ - s__( - 'ClusterAgents|To install an Agent you should create an agent directory in the Repository first. We recommend that you add the Agent configuration to the directory before you start the installation process.', - ) - }} + {{ $options.i18n.warningText }} <template #actions> <gl-button category="primary" variant="info" - :href="getStartedDocsUrl" + :href="$options.getStartedDocsUrl" target="_blank" class="gl-ml-0!" > - {{ s__('ClusterAgents|Read more about getting started') }} + {{ $options.i18n.readMoreText }} </gl-button> <gl-button category="secondary" variant="info" :href="repositoryPath"> - {{ s__('ClusterAgents|Go to the repository') }} + {{ $options.i18n.repositoryButtonText }} </gl-button> </template> </gl-alert> @@ -106,13 +94,14 @@ export default { <template #actions> <gl-button + v-if="!isChildComponent" v-gl-modal-directive="$options.modalId" :disabled="!hasConfigurations" data-testid="integration-primary-button" category="primary" - variant="success" + variant="confirm" > - {{ s__('ClusterAgents|Integrate with the GitLab Agent') }} + {{ $options.i18n.primaryButtonText }} </gl-button> </template> </gl-empty-state> diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue index 487e512c06d..000730ac1ba 100644 --- a/app/assets/javascripts/clusters_list/components/agent_table.vue +++ b/app/assets/javascripts/clusters_list/components/agent_table.vue @@ -1,6 +1,5 @@ <script> import { - GlButton, GlLink, GlModalDirective, GlTable, @@ -12,11 +11,12 @@ import { import { s__ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -import { INSTALL_AGENT_MODAL_ID, AGENT_STATUSES, TROUBLESHOOTING_LINK } from '../constants'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { INSTALL_AGENT_MODAL_ID, AGENT_STATUSES } from '../constants'; +import { getAgentConfigPath } from '../clusters_util'; export default { components: { - GlButton, GlLink, GlTable, GlIcon, @@ -29,10 +29,12 @@ export default { GlModalDirective, }, mixins: [timeagoMixin], - inject: ['integrationDocsUrl'], INSTALL_AGENT_MODAL_ID, AGENT_STATUSES, - TROUBLESHOOTING_LINK, + + troubleshooting_link: helpPagePath('user/clusters/agent/index', { + anchor: 'troubleshooting', + }), props: { agents: { required: true, @@ -41,112 +43,102 @@ export default { }, computed: { fields() { + const tdClass = 'gl-py-5!'; return [ { key: 'name', label: s__('ClusterAgents|Name'), + tdClass, }, { key: 'status', label: s__('ClusterAgents|Connection status'), + tdClass, }, { key: 'lastContact', label: s__('ClusterAgents|Last contact'), + tdClass, }, { key: 'configuration', label: s__('ClusterAgents|Configuration'), + tdClass, }, ]; }, }, + methods: { + getCellId(item) { + return `connection-status-${item.name}`; + }, + getAgentConfigPath, + }, }; </script> <template> - <div> - <div class="gl-display-block gl-text-right gl-my-3"> - <gl-button - v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID" - variant="confirm" - category="primary" - >{{ s__('ClusterAgents|Install a new GitLab Agent') }} - </gl-button> - </div> + <gl-table + :items="agents" + :fields="fields" + stacked="md" + head-variant="white" + thead-class="gl-border-b-solid gl-border-b-2 gl-border-b-gray-100" + class="gl-mb-4!" + data-testid="cluster-agent-list-table" + > + <template #cell(name)="{ item }"> + <gl-link :href="item.webPath" data-testid="cluster-agent-name-link"> + {{ item.name }} + </gl-link> + </template> - <gl-table - :items="agents" - :fields="fields" - stacked="md" - head-variant="white" - thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100" - data-testid="cluster-agent-list-table" - > - <template #cell(name)="{ item }"> - <gl-link :href="item.webPath" data-testid="cluster-agent-name-link"> - {{ item.name }} - </gl-link> - </template> - - <template #cell(status)="{ item }"> - <span - :id="`connection-status-${item.name}`" - class="gl-pr-5" - data-testid="cluster-agent-connection-status" - > - <span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3"> - <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="12" /></span - >{{ $options.AGENT_STATUSES[item.status].name }} - </span> - <gl-tooltip - v-if="item.status === 'active'" - :target="`connection-status-${item.name}`" - placement="right" - > - <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.title" - ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template> - </gl-sprintf> - </gl-tooltip> - <gl-popover - v-else - :target="`connection-status-${item.name}`" - :title="$options.AGENT_STATUSES[item.status].tooltip.title" - placement="right" - container="viewport" - > - <p> - <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.body" - ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template></gl-sprintf - > - </p> - <p class="gl-mb-0"> - {{ s__('ClusterAgents|For more troubleshooting information go to') }} - <gl-link :href="$options.TROUBLESHOOTING_LINK" target="_blank" class="gl-font-sm"> - {{ $options.TROUBLESHOOTING_LINK }}</gl-link - > - </p> - </gl-popover> - </template> + <template #cell(status)="{ item }"> + <span :id="getCellId(item)" class="gl-md-pr-5" data-testid="cluster-agent-connection-status"> + <span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3"> + <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="12" /></span + >{{ $options.AGENT_STATUSES[item.status].name }} + </span> + <gl-tooltip v-if="item.status === 'active'" :target="getCellId(item)" placement="right"> + <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.title" + ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template> + </gl-sprintf> + </gl-tooltip> + <gl-popover + v-else + :target="getCellId(item)" + :title="$options.AGENT_STATUSES[item.status].tooltip.title" + placement="right" + container="viewport" + > + <p> + <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.body" + ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template></gl-sprintf + > + </p> + <p class="gl-mb-0"> + <gl-link :href="$options.troubleshooting_link" target="_blank" class="gl-font-sm"> + {{ s__('ClusterAgents|Learn how to troubleshoot') }}</gl-link + > + </p> + </gl-popover> + </template> - <template #cell(lastContact)="{ item }"> - <span data-testid="cluster-agent-last-contact"> - <time-ago-tooltip v-if="item.lastContact" :time="item.lastContact" /> - <span v-else>{{ s__('ClusterAgents|Never') }}</span> - </span> - </template> + <template #cell(lastContact)="{ item }"> + <span data-testid="cluster-agent-last-contact"> + <time-ago-tooltip v-if="item.lastContact" :time="item.lastContact" /> + <span v-else>{{ s__('ClusterAgents|Never') }}</span> + </span> + </template> - <template #cell(configuration)="{ item }"> - <span data-testid="cluster-agent-configuration-link"> - <!-- eslint-disable @gitlab/vue-require-i18n-strings --> - <gl-link v-if="item.configFolder" :href="item.configFolder.webPath"> - .gitlab/agents/{{ item.name }} - </gl-link> + <template #cell(configuration)="{ item }"> + <span data-testid="cluster-agent-configuration-link"> + <gl-link v-if="item.configFolder" :href="item.configFolder.webPath"> + {{ getAgentConfigPath(item.name) }} + </gl-link> - <span v-else>.gitlab/agents/{{ item.name }}</span> - <!-- eslint-enable @gitlab/vue-require-i18n-strings --> - </span> - </template> - </gl-table> - </div> + <span v-else>{{ getAgentConfigPath(item.name) }}</span> + </span> + </template> + </gl-table> </template> diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue index ed44c1f5fa7..fb5cf7d1206 100644 --- a/app/assets/javascripts/clusters_list/components/agents.vue +++ b/app/assets/javascripts/clusters_list/components/agents.vue @@ -4,7 +4,6 @@ import { MAX_LIST_COUNT, ACTIVE_CONNECTION_TIME } from '../constants'; import getAgentsQuery from '../graphql/queries/get_agents.query.graphql'; import AgentEmptyState from './agent_empty_state.vue'; import AgentTable from './agent_table.vue'; -import InstallAgentModal from './install_agent_modal.vue'; export default { apollo: { @@ -21,12 +20,14 @@ export default { this.updateTreeList(data); return data; }, + result() { + this.emitAgentsLoaded(); + }, }, }, components: { AgentEmptyState, AgentTable, - InstallAgentModal, GlAlert, GlKeysetPagination, GlLoadingIcon, @@ -38,11 +39,21 @@ export default { required: false, type: String, }, + isChildComponent: { + default: false, + required: false, + type: Boolean, + }, + limit: { + default: null, + required: false, + type: Number, + }, }, data() { return { cursor: { - first: MAX_LIST_COUNT, + first: this.limit ? this.limit : MAX_LIST_COUNT, last: null, }, folderList: {}, @@ -70,7 +81,7 @@ export default { return this.$apollo.queries.agents.loading; }, showPagination() { - return this.agentPageInfo.hasPreviousPage || this.agentPageInfo.hasNextPage; + return !this.limit && (this.agentPageInfo.hasPreviousPage || this.agentPageInfo.hasNextPage); }, treePageInfo() { return this.agents?.project?.repository?.tree?.trees?.pageInfo || {}; @@ -130,24 +141,31 @@ export default { } return 'unused'; }, + emitAgentsLoaded() { + const count = this.agents?.project?.clusterAgents?.count; + this.$emit('onAgentsLoad', count); + }, }, }; </script> <template> - <gl-loading-icon v-if="isLoading" size="md" class="gl-mt-3" /> + <gl-loading-icon v-if="isLoading" size="md" /> - <section v-else-if="agentList" class="gl-mt-3"> + <section v-else-if="agentList"> <div v-if="agentList.length"> - <AgentTable :agents="agentList" /> + <agent-table :agents="agentList" /> <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5"> <gl-keyset-pagination v-bind="agentPageInfo" @prev="prevPage" @next="nextPage" /> </div> </div> - <AgentEmptyState v-else :has-configurations="hasConfigurations" /> - <InstallAgentModal @agentRegistered="reloadAgents" /> + <agent-empty-state + v-else + :has-configurations="hasConfigurations" + :is-child-component="isChildComponent" + /> </section> <gl-alert v-else variant="danger" :dismissible="false"> diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index 0d1534d20e0..9c330045596 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -14,6 +14,7 @@ import { __, sprintf } from '~/locale'; import { CLUSTER_TYPES, STATUSES } from '../constants'; import AncestorNotice from './ancestor_notice.vue'; import NodeErrorHelpText from './node_error_help_text.vue'; +import ClustersEmptyState from './clusters_empty_state.vue'; export default { nodeMemoryText: __('%{totalMemory} (%{freeSpacePercentage}%{percentSymbol} free)'), @@ -28,10 +29,23 @@ export default { GlSprintf, GlTable, NodeErrorHelpText, + ClustersEmptyState, }, directives: { GlTooltip: GlTooltipDirective, }, + props: { + isChildComponent: { + default: false, + required: false, + type: Boolean, + }, + limit: { + default: null, + required: false, + type: Number, + }, + }, computed: { ...mapState([ 'clusters', @@ -40,7 +54,7 @@ export default { 'loadingNodes', 'page', 'providers', - 'totalCulsters', + 'totalClusters', ]), contentAlignClasses() { return 'gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start'; @@ -55,43 +69,57 @@ export default { }, }, fields() { + const tdClass = 'gl-py-5!'; return [ { key: 'name', label: __('Kubernetes cluster'), + tdClass, }, { key: 'environment_scope', label: __('Environment scope'), + tdClass, }, { key: 'node_size', label: __('Nodes'), + tdClass, }, { key: 'total_cpu', label: __('Total cores (CPUs)'), + tdClass, }, { key: 'total_memory', label: __('Total memory (GB)'), + tdClass, }, { key: 'cluster_type', label: __('Cluster level'), + tdClass, formatter: (value) => CLUSTER_TYPES[value], }, ]; }, - hasClusters() { + hasClustersPerPage() { return this.clustersPerPage > 0; }, + hasClusters() { + return this.totalClusters > 0; + }, }, mounted() { + if (this.limit) { + this.setClustersPerPage(this.limit); + } + this.fetchClusters(); }, methods: { - ...mapActions(['fetchClusters', 'reportSentryError', 'setPage']), + ...mapActions(['fetchClusters', 'reportSentryError', 'setPage', 'setClustersPerPage']), k8sQuantityToGb(quantity) { if (!quantity) { return 0; @@ -196,18 +224,20 @@ export default { </script> <template> - <gl-loading-icon v-if="loadingClusters" size="md" class="gl-mt-3" /> + <gl-loading-icon v-if="loadingClusters" size="md" /> <section v-else> <ancestor-notice /> <gl-table + v-if="hasClusters" :items="clusters" :fields="fields" + fixed stacked="md" head-variant="white" - thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100" - class="qa-clusters-table" + thead-class="gl-border-b-solid gl-border-b-2 gl-border-b-gray-100" + class="qa-clusters-table gl-mb-4!" data-testid="cluster_list_table" > <template #cell(name)="{ item }"> @@ -241,7 +271,7 @@ export default { <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> - <NodeErrorHelpText + <node-error-help-text v-else-if="item.kubernetes_errors" :class="contentAlignClasses" :error-type="item.kubernetes_errors.connection_error" @@ -262,7 +292,7 @@ export default { <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> - <NodeErrorHelpText + <node-error-help-text v-else-if="item.kubernetes_errors" :class="contentAlignClasses" :error-type="item.kubernetes_errors.node_connection_error" @@ -283,7 +313,7 @@ export default { <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> - <NodeErrorHelpText + <node-error-help-text v-else-if="item.kubernetes_errors" :class="contentAlignClasses" :error-type="item.kubernetes_errors.metrics_connection_error" @@ -298,11 +328,13 @@ export default { </template> </gl-table> + <clusters-empty-state v-else :is-child-component="isChildComponent" /> + <gl-pagination - v-if="hasClusters" + v-if="hasClustersPerPage && !limit" v-model="currentPage" :per-page="clustersPerPage" - :total-items="totalCulsters" + :total-items="totalClusters" :prev-text="__('Prev')" :next-text="__('Next')" align="center" diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue new file mode 100644 index 00000000000..25f67462223 --- /dev/null +++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue @@ -0,0 +1,44 @@ +<script> +import { GlDropdown, GlDropdownItem, GlModalDirective } from '@gitlab/ui'; +import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '../constants'; + +export default { + i18n: CLUSTERS_ACTIONS, + INSTALL_AGENT_MODAL_ID, + components: { + GlDropdown, + GlDropdownItem, + }, + directives: { + GlModalDirective, + }, + inject: ['newClusterPath', 'addClusterPath'], +}; +</script> + +<template> + <div class="nav-controls gl-ml-auto"> + <gl-dropdown + ref="dropdown" + v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID" + category="primary" + variant="confirm" + :text="$options.i18n.actionsButton" + split + right + > + <gl-dropdown-item :href="newClusterPath" data-testid="new-cluster-link" @click.stop> + {{ $options.i18n.createNewCluster }} + </gl-dropdown-item> + <gl-dropdown-item + v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID" + data-testid="connect-new-agent-link" + > + {{ $options.i18n.connectWithAgent }} + </gl-dropdown-item> + <gl-dropdown-item :href="addClusterPath" data-testid="connect-cluster-link" @click.stop> + {{ $options.i18n.connectExistingCluster }} + </gl-dropdown-item> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue new file mode 100644 index 00000000000..3879af6e9cb --- /dev/null +++ b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue @@ -0,0 +1,76 @@ +<script> +import { GlEmptyState, GlButton, GlLink, GlSprintf } from '@gitlab/ui'; +import { mapState } from 'vuex'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { I18N_CLUSTERS_EMPTY_STATE } from '../constants'; + +export default { + i18n: I18N_CLUSTERS_EMPTY_STATE, + components: { + GlEmptyState, + GlButton, + GlLink, + GlSprintf, + }, + inject: ['emptyStateHelpText', 'clustersEmptyStateImage', 'newClusterPath'], + props: { + isChildComponent: { + default: false, + required: false, + type: Boolean, + }, + }, + learnMoreHelpUrl: helpPagePath('user/project/clusters/index'), + multipleClustersHelpUrl: helpPagePath('user/project/clusters/multiple_kubernetes_clusters'), + computed: { + ...mapState(['canAddCluster']), + }, +}; +</script> + +<template> + <gl-empty-state :svg-path="clustersEmptyStateImage" title=""> + <template #description> + <p class="gl-text-left"> + {{ $options.i18n.description }} + </p> + <p class="gl-text-left"> + <gl-sprintf :message="$options.i18n.multipleClustersText"> + <template #link="{ content }"> + <gl-link + :href="$options.multipleClustersHelpUrl" + target="_blank" + data-testid="multiple-clusters-docs-link" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + + <p v-if="emptyStateHelpText" data-testid="clusters-empty-state-text"> + {{ emptyStateHelpText }} + </p> + + <p> + <gl-link :href="$options.learnMoreHelpUrl" target="_blank" data-testid="clusters-docs-link"> + {{ $options.i18n.learnMoreLinkText }} + </gl-link> + </p> + </template> + + <template #actions> + <gl-button + v-if="!isChildComponent" + data-testid="integration-primary-button" + data-qa-selector="add_kubernetes_cluster_link" + category="primary" + variant="confirm" + :disabled="!canAddCluster" + :href="newClusterPath" + > + {{ $options.i18n.buttonText }} + </gl-button> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/clusters_list/components/clusters_main_view.vue b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue new file mode 100644 index 00000000000..9e03093aa67 --- /dev/null +++ b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue @@ -0,0 +1,73 @@ +<script> +import { GlTabs, GlTab } from '@gitlab/ui'; +import { CLUSTERS_TABS, MAX_CLUSTERS_LIST, MAX_LIST_COUNT, AGENT } from '../constants'; +import Agents from './agents.vue'; +import InstallAgentModal from './install_agent_modal.vue'; +import ClustersActions from './clusters_actions.vue'; +import Clusters from './clusters.vue'; +import ClustersViewAll from './clusters_view_all.vue'; + +export default { + components: { + GlTabs, + GlTab, + ClustersActions, + ClustersViewAll, + Clusters, + Agents, + InstallAgentModal, + }, + CLUSTERS_TABS, + props: { + defaultBranchName: { + default: '.noBranch', + required: false, + type: String, + }, + }, + data() { + return { + selectedTabIndex: 0, + maxAgents: MAX_CLUSTERS_LIST, + }; + }, + methods: { + onTabChange(tabName) { + this.selectedTabIndex = CLUSTERS_TABS.findIndex((tab) => tab.queryParamValue === tabName); + + this.maxAgents = tabName === AGENT ? MAX_LIST_COUNT : MAX_CLUSTERS_LIST; + }, + }, +}; +</script> +<template> + <div> + <gl-tabs + v-model="selectedTabIndex" + sync-active-tab-with-query-params + nav-class="gl-flex-grow-1 gl-align-items-center" + lazy + > + <gl-tab + v-for="(tab, idx) in $options.CLUSTERS_TABS" + :key="idx" + :title="tab.title" + :query-param-value="tab.queryParamValue" + class="gl-line-height-20 gl-mt-5" + > + <component + :is="tab.component" + :default-branch-name="defaultBranchName" + data-testid="clusters-tab-component" + @changeTab="onTabChange" + /> + </gl-tab> + + <template #tabs-end> + <clusters-actions /> + </template> + </gl-tabs> + + <install-agent-modal :default-branch-name="defaultBranchName" :max-agents="maxAgents" /> + </div> +</template> diff --git a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue new file mode 100644 index 00000000000..285876e57d8 --- /dev/null +++ b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue @@ -0,0 +1,218 @@ +<script> +import { + GlCard, + GlSprintf, + GlPopover, + GlLink, + GlButton, + GlBadge, + GlLoadingIcon, + GlModalDirective, +} from '@gitlab/ui'; +import { mapState } from 'vuex'; +import { + AGENT_CARD_INFO, + CERTIFICATE_BASED_CARD_INFO, + MAX_CLUSTERS_LIST, + INSTALL_AGENT_MODAL_ID, +} from '../constants'; +import Clusters from './clusters.vue'; +import Agents from './agents.vue'; + +export default { + components: { + GlCard, + GlSprintf, + GlPopover, + GlLink, + GlButton, + GlBadge, + GlLoadingIcon, + Clusters, + Agents, + }, + directives: { + GlModalDirective, + }, + AGENT_CARD_INFO, + CERTIFICATE_BASED_CARD_INFO, + MAX_CLUSTERS_LIST, + INSTALL_AGENT_MODAL_ID, + inject: ['addClusterPath'], + props: { + defaultBranchName: { + default: '.noBranch', + required: false, + type: String, + }, + }, + data() { + return { + loadingAgents: true, + totalAgents: null, + }; + }, + computed: { + ...mapState(['loadingClusters', 'totalClusters']), + isLoading() { + return this.loadingAgents || this.loadingClusters; + }, + agentsCardTitle() { + let cardTitle; + if (this.totalAgents > 0) { + cardTitle = { + message: AGENT_CARD_INFO.title, + number: this.totalAgents < MAX_CLUSTERS_LIST ? this.totalAgents : MAX_CLUSTERS_LIST, + total: this.totalAgents, + }; + } else { + cardTitle = { + message: AGENT_CARD_INFO.emptyTitle, + }; + } + + return cardTitle; + }, + clustersCardTitle() { + let cardTitle; + if (this.totalClusters > 0) { + cardTitle = { + message: CERTIFICATE_BASED_CARD_INFO.title, + number: this.totalClusters < MAX_CLUSTERS_LIST ? this.totalClusters : MAX_CLUSTERS_LIST, + total: this.totalClusters, + }; + } else { + cardTitle = { + message: CERTIFICATE_BASED_CARD_INFO.emptyTitle, + }; + } + + return cardTitle; + }, + }, + methods: { + cardFooterNumber(number) { + return number > MAX_CLUSTERS_LIST ? number : ''; + }, + onAgentsLoad(number) { + this.totalAgents = number; + this.loadingAgents = false; + }, + changeTab($event, tab) { + $event.preventDefault(); + this.$emit('changeTab', tab); + }, + }, +}; +</script> +<template> + <div> + <gl-loading-icon v-if="isLoading" size="md" /> + <div v-show="!isLoading" data-testid="clusters-cards-container"> + <gl-card + header-class="gl-bg-white gl-display-flex gl-align-items-center gl-justify-content-space-between gl-py-4" + body-class="gl-pb-0" + footer-class="gl-text-right" + > + <template #header> + <h3 data-testid="agent-card-title" class="gl-my-0 gl-font-weight-normal gl-font-size-h2"> + <gl-sprintf :message="agentsCardTitle.message" + ><template #number>{{ agentsCardTitle.number }}</template> + <template #total>{{ agentsCardTitle.total }}</template> + </gl-sprintf> + </h3> + + <gl-badge id="clusters-recommended-badge" size="md" variant="info">{{ + $options.AGENT_CARD_INFO.tooltip.label + }}</gl-badge> + + <gl-popover + target="clusters-recommended-badge" + container="viewport" + placement="bottom" + :title="$options.AGENT_CARD_INFO.tooltip.title" + > + <p class="gl-mb-0"> + <gl-sprintf :message="$options.AGENT_CARD_INFO.tooltip.text"> + <template #link="{ content }"> + <gl-link + :href="$options.AGENT_CARD_INFO.tooltip.link" + target="_blank" + class="gl-font-sm" + > + {{ content }}</gl-link + > + </template> + </gl-sprintf> + </p> + </gl-popover> + </template> + + <agents + :limit="$options.MAX_CLUSTERS_LIST" + :default-branch-name="defaultBranchName" + :is-child-component="true" + @onAgentsLoad="onAgentsLoad" + /> + + <template #footer> + <gl-link + v-if="totalAgents" + data-testid="agents-tab-footer-link" + :href="`?tab=${$options.AGENT_CARD_INFO.tabName}`" + @click="changeTab($event, $options.AGENT_CARD_INFO.tabName)" + ><gl-sprintf :message="$options.AGENT_CARD_INFO.footerText" + ><template #number>{{ cardFooterNumber(totalAgents) }}</template></gl-sprintf + ></gl-link + ><gl-button + v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID" + class="gl-ml-4" + category="secondary" + variant="confirm" + >{{ $options.AGENT_CARD_INFO.actionText }}</gl-button + > + </template> + </gl-card> + + <gl-card + class="gl-mt-6" + header-class="gl-bg-white gl-display-flex gl-align-items-center gl-justify-content-space-between" + body-class="gl-pb-0" + footer-class="gl-text-right" + > + <template #header> + <h3 + class="gl-my-1 gl-font-weight-normal gl-font-size-h2" + data-testid="clusters-card-title" + > + <gl-sprintf :message="clustersCardTitle.message" + ><template #number>{{ clustersCardTitle.number }}</template> + <template #total>{{ clustersCardTitle.total }}</template> + </gl-sprintf> + </h3> + </template> + + <clusters :limit="$options.MAX_CLUSTERS_LIST" :is-child-component="true" /> + + <template #footer> + <gl-link + v-if="totalClusters" + data-testid="clusters-tab-footer-link" + :href="`?tab=${$options.CERTIFICATE_BASED_CARD_INFO.tabName}`" + @click="changeTab($event, $options.CERTIFICATE_BASED_CARD_INFO.tabName)" + ><gl-sprintf :message="$options.CERTIFICATE_BASED_CARD_INFO.footerText" + ><template #number>{{ cardFooterNumber(totalClusters) }}</template></gl-sprintf + ></gl-link + ><gl-button + category="secondary" + data-qa-selector="connect_existing_cluster_button" + variant="confirm" + class="gl-ml-4" + :href="addClusterPath" + >{{ $options.CERTIFICATE_BASED_CARD_INFO.actionText }}</gl-button + > + </template> + </gl-card> + </div> + </div> +</template> diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue index 5f192fe4d5a..6eb2e85ecea 100644 --- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue +++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue @@ -13,8 +13,10 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import CodeBlock from '~/vue_shared/components/code_block.vue'; import { generateAgentRegistrationCommand } from '../clusters_util'; import { INSTALL_AGENT_MODAL_ID, I18N_INSTALL_AGENT_MODAL } from '../constants'; +import { addAgentToStore } from '../graphql/cache_update'; import createAgent from '../graphql/mutations/create_agent.mutation.graphql'; import createAgentToken from '../graphql/mutations/create_agent_token.mutation.graphql'; +import getAgentsQuery from '../graphql/queries/get_agents.query.graphql'; import AvailableAgentsDropdown from './available_agents_dropdown.vue'; export default { @@ -33,12 +35,24 @@ export default { GlSprintf, }, inject: ['projectPath', 'kasAddress'], + props: { + defaultBranchName: { + default: '.noBranch', + required: false, + type: String, + }, + maxAgents: { + required: true, + type: Number, + }, + }, data() { return { registering: false, agentName: null, agentToken: null, error: null, + clusterAgent: null, }; }, computed: { @@ -55,27 +69,31 @@ export default { return generateAgentRegistrationCommand(this.agentToken, this.kasAddress); }, basicInstallPath() { - return helpPagePath('user/clusters/agent/index', { + return helpPagePath('user/clusters/agent/install/index', { anchor: 'install-the-agent-into-the-cluster', }); }, advancedInstallPath() { - return helpPagePath('user/clusters/agent/index', { anchor: 'advanced-installation' }); + return helpPagePath('user/clusters/agent/install/index', { anchor: 'advanced-installation' }); + }, + getAgentsQueryVariables() { + return { + defaultBranchName: this.defaultBranchName, + first: this.maxAgents, + last: null, + projectPath: this.projectPath, + }; }, }, methods: { setAgentName(name) { this.agentName = name; }, - cancelClicked() { - this.$refs.modal.hide(); - }, - doneClicked() { - this.$emit('agentRegistered'); + closeModal() { this.$refs.modal.hide(); }, resetModal() { - this.registering = null; + this.registering = false; this.agentName = null; this.agentToken = null; this.error = null; @@ -90,6 +108,14 @@ export default { projectPath: this.projectPath, }, }, + update: (store, { data: { createClusterAgent } }) => { + addAgentToStore( + store, + createClusterAgent, + getAgentsQuery, + this.getAgentsQueryVariables, + ); + }, }) .then(({ data: { createClusterAgent } }) => createClusterAgent); }, @@ -117,6 +143,8 @@ export default { throw new Error(agentErrors[0]); } + this.clusterAgent = clusterAgent; + const { errors: tokenErrors, secret } = await this.createAgentTokenMutation( clusterAgent.id, ); @@ -240,10 +268,10 @@ export default { </template> <template #modal-footer> - <gl-button v-if="canCancel" @click="cancelClicked">{{ $options.i18n.cancel }} </gl-button> + <gl-button v-if="canCancel" @click="closeModal">{{ $options.i18n.cancel }} </gl-button> - <gl-button v-if="registered" variant="confirm" category="primary" @click="doneClicked" - >{{ $options.i18n.done }} + <gl-button v-if="registered" variant="confirm" category="primary" @click="closeModal" + >{{ $options.i18n.close }} </gl-button> <gl-button @@ -252,7 +280,7 @@ export default { variant="confirm" category="primary" @click="registerAgent" - >{{ $options.i18n.next }} + >{{ $options.i18n.registerAgentButton }} </gl-button> </template> </gl-modal> diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 0bade1fc281..9fefdf450c4 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -1,10 +1,9 @@ import { __, s__, sprintf } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; export const MAX_LIST_COUNT = 25; export const INSTALL_AGENT_MODAL_ID = 'install-agent'; export const ACTIVE_CONNECTION_TIME = 480000; -export const TROUBLESHOOTING_LINK = - 'https://docs.gitlab.com/ee/user/clusters/agent/#troubleshooting'; export const CLUSTER_ERRORS = { default: { @@ -66,8 +65,8 @@ export const STATUSES = { }; export const I18N_INSTALL_AGENT_MODAL = { - next: __('Next'), - done: __('Done'), + registerAgentButton: s__('ClusterAgents|Register Agent'), + close: __('Close'), cancel: __('Cancel'), modalTitle: s__('ClusterAgents|Install new Agent'), @@ -91,7 +90,7 @@ export const I18N_INSTALL_AGENT_MODAL = { ), basicInstallTitle: s__('ClusterAgents|Recommended installation method'), - basicInstallBody: s__( + basicInstallBody: __( `Open a CLI and connect to the cluster you want to install the Agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`, ), @@ -100,7 +99,7 @@ export const I18N_INSTALL_AGENT_MODAL = { 'ClusterAgents|For alternative installation methods %{linkStart}go to the documentation%{linkEnd}.', ), - registrationErrorTitle: s__('Failed to register Agent'), + registrationErrorTitle: __('Failed to register Agent'), unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'), }; @@ -141,3 +140,86 @@ export const AGENT_STATUSES = { }, }, }; + +export const I18N_AGENTS_EMPTY_STATE = { + introText: s__( + 'ClusterAgents|Use GitLab Agents to more securely integrate with your clusters to deploy your applications, run your pipelines, use review apps and much more.', + ), + multipleClustersText: s__( + 'ClusterAgents|If you are setting up multiple clusters and are using Auto DevOps, %{linkStart}read about using multiple Kubernetes clusters first.%{linkEnd}', + ), + learnMoreText: s__('ClusterAgents|Learn more about the GitLab Kubernetes Agent.'), + warningText: s__( + 'ClusterAgents|To install an Agent you should create an agent directory in the Repository first. We recommend that you add the Agent configuration to the directory before you start the installation process.', + ), + readMoreText: s__('ClusterAgents|Read more about getting started'), + repositoryButtonText: s__('ClusterAgents|Go to the repository'), + primaryButtonText: s__('ClusterAgents|Connect with a GitLab Agent'), +}; + +export const I18N_CLUSTERS_EMPTY_STATE = { + description: s__( + 'ClusterIntegration|Use certificates to integrate with your clusters to deploy your applications, run your pipelines, use review apps and much more in an easy way.', + ), + multipleClustersText: s__( + 'ClusterIntegration|If you are setting up multiple clusters and are using Auto DevOps, %{linkStart}read about using multiple Kubernetes clusters first.%{linkEnd}', + ), + learnMoreLinkText: s__('ClusterIntegration|Learn more about the GitLab managed clusters'), + buttonText: s__('ClusterIntegration|Connect with a certificate'), +}; + +export const AGENT_CARD_INFO = { + tabName: 'agent', + title: sprintf(s__('ClusterAgents|%{number} of %{total} Agent based integrations')), + emptyTitle: s__('ClusterAgents|No Agent based integrations'), + tooltip: { + label: s__('ClusterAgents|Recommended'), + title: s__('ClusterAgents|GitLab Agents'), + text: sprintf( + s__( + 'ClusterAgents|GitLab Agents provide an increased level of security when integrating with clusters. %{linkStart}Learn more about the GitLab Kubernetes Agent.%{linkEnd}', + ), + ), + link: helpPagePath('user/clusters/agent/index'), + }, + actionText: s__('ClusterAgents|Install new Agent'), + footerText: sprintf(s__('ClusterAgents|View all %{number} Agent based integrations')), +}; + +export const CERTIFICATE_BASED_CARD_INFO = { + tabName: 'certificate_based', + title: sprintf(s__('ClusterAgents|%{number} of %{total} Certificate based integrations')), + emptyTitle: s__('ClusterAgents|No Certificate based integrations'), + actionText: s__('ClusterAgents|Connect existing cluster'), + footerText: sprintf(s__('ClusterAgents|View all %{number} Certificate based integrations')), +}; + +export const MAX_CLUSTERS_LIST = 6; + +export const CLUSTERS_TABS = [ + { + title: s__('ClusterAgents|All'), + component: 'ClustersViewAll', + queryParamValue: 'all', + }, + { + title: s__('ClusterAgents|Agent'), + component: 'agents', + queryParamValue: 'agent', + }, + { + title: s__('ClusterAgents|Certificate based'), + component: 'clusters', + queryParamValue: 'certificate_based', + }, +]; + +export const CLUSTERS_ACTIONS = { + actionsButton: s__('ClusterAgents|Actions'), + createNewCluster: s__('ClusterAgents|Create new cluster'), + connectWithAgent: s__('ClusterAgents|Connect with Agent'), + connectExistingCluster: s__('ClusterAgents|Connect with certificate'), +}; + +export const AGENT = 'agent'; +export const CERTIFICATE_BASED = 'certificate_based'; diff --git a/app/assets/javascripts/clusters_list/graphql/cache_update.js b/app/assets/javascripts/clusters_list/graphql/cache_update.js new file mode 100644 index 00000000000..dd633820952 --- /dev/null +++ b/app/assets/javascripts/clusters_list/graphql/cache_update.js @@ -0,0 +1,29 @@ +import produce from 'immer'; +import { getAgentConfigPath } from '../clusters_util'; + +export function addAgentToStore(store, createClusterAgent, query, variables) { + const { clusterAgent } = createClusterAgent; + const sourceData = store.readQuery({ + query, + variables, + }); + + const data = produce(sourceData, (draftData) => { + const configuration = { + name: clusterAgent.name, + path: getAgentConfigPath(clusterAgent.name), + webPath: clusterAgent.webPath, + __typename: 'TreeEntry', + }; + + draftData.project.clusterAgents.nodes.push(clusterAgent); + draftData.project.clusterAgents.count += 1; + draftData.project.repository.tree.trees.nodes.push(configuration); + }); + + store.writeQuery({ + query, + variables, + data, + }); +} diff --git a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql new file mode 100644 index 00000000000..9b40260471c --- /dev/null +++ b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql @@ -0,0 +1,10 @@ +fragment ClusterAgentFragment on ClusterAgent { + id + name + webPath + tokens { + nodes { + lastUsedAt + } + } +} diff --git a/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql index c29756159f5..996b388089b 100644 --- a/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql +++ b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql @@ -1,7 +1,9 @@ +#import "../fragments/cluster_agent.fragment.graphql" + mutation createClusterAgent($input: CreateClusterAgentInput!) { createClusterAgent(input: $input) { clusterAgent { - id + ...ClusterAgentFragment } errors } diff --git a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql index 61989e00d9e..47b25988877 100644 --- a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql +++ b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql @@ -1,4 +1,5 @@ #import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "../fragments/cluster_agent.fragment.graphql" query getAgents( $defaultBranchName: String! @@ -13,19 +14,14 @@ query getAgents( project(fullPath: $projectPath) { clusterAgents(first: $first, last: $last, before: $beforeAgent, after: $afterAgent) { nodes { - id - name - webPath - tokens { - nodes { - lastUsedAt - } - } + ...ClusterAgentFragment } pageInfo { ...PageInfo } + + count } repository { diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js index de18965abbd..7f1ef37814b 100644 --- a/app/assets/javascripts/clusters_list/index.js +++ b/app/assets/javascripts/clusters_list/index.js @@ -1,11 +1,11 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import loadClusters from './load_clusters'; -import loadAgents from './load_agents'; +import loadMainView from './load_main_view'; Vue.use(VueApollo); export default () => { loadClusters(Vue); - loadAgents(Vue, VueApollo); + loadMainView(Vue, VueApollo); }; diff --git a/app/assets/javascripts/clusters_list/load_agents.js b/app/assets/javascripts/clusters_list/load_agents.js deleted file mode 100644 index b77d386df20..00000000000 --- a/app/assets/javascripts/clusters_list/load_agents.js +++ /dev/null @@ -1,44 +0,0 @@ -import createDefaultClient from '~/lib/graphql'; -import Agents from './components/agents.vue'; - -export default (Vue, VueApollo) => { - const el = document.querySelector('#js-cluster-agents-list'); - - if (!el) { - return null; - } - - const defaultClient = createDefaultClient({}, { assumeImmutableResults: true }); - - const { - emptyStateImage, - defaultBranchName, - projectPath, - agentDocsUrl, - installDocsUrl, - getStartedDocsUrl, - integrationDocsUrl, - kasAddress, - } = el.dataset; - - return new Vue({ - el, - apolloProvider: new VueApollo({ defaultClient }), - provide: { - emptyStateImage, - projectPath, - agentDocsUrl, - installDocsUrl, - getStartedDocsUrl, - integrationDocsUrl, - kasAddress, - }, - render(createElement) { - return createElement(Agents, { - props: { - defaultBranchName, - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/clusters_list/load_clusters.js b/app/assets/javascripts/clusters_list/load_clusters.js index 01430230879..1bb3ea546b2 100644 --- a/app/assets/javascripts/clusters_list/load_clusters.js +++ b/app/assets/javascripts/clusters_list/load_clusters.js @@ -8,8 +8,15 @@ export default (Vue) => { return null; } + const { emptyStateHelpText, newClusterPath, clustersEmptyStateImage } = el.dataset; + return new Vue({ el, + provide: { + emptyStateHelpText, + newClusterPath, + clustersEmptyStateImage, + }, store: createStore(el.dataset), render(createElement) { return createElement(Clusters); diff --git a/app/assets/javascripts/clusters_list/load_main_view.js b/app/assets/javascripts/clusters_list/load_main_view.js new file mode 100644 index 00000000000..08c99b46e16 --- /dev/null +++ b/app/assets/javascripts/clusters_list/load_main_view.js @@ -0,0 +1,50 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import ClustersMainView from './components/clusters_main_view.vue'; +import { createStore } from './store'; + +Vue.use(VueApollo); + +export default () => { + const el = document.querySelector('.js-clusters-main-view'); + + if (!el) { + return null; + } + + const defaultClient = createDefaultClient(); + + const { + emptyStateImage, + defaultBranchName, + projectPath, + kasAddress, + newClusterPath, + addClusterPath, + emptyStateHelpText, + clustersEmptyStateImage, + } = el.dataset; + + return new Vue({ + el, + apolloProvider: new VueApollo({ defaultClient }), + provide: { + emptyStateImage, + projectPath, + kasAddress, + newClusterPath, + addClusterPath, + emptyStateHelpText, + clustersEmptyStateImage, + }, + store: createStore(el.dataset), + render(createElement) { + return createElement(ClustersMainView, { + props: { + defaultBranchName, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js index 5f35a0b26f3..d70b36e63bc 100644 --- a/app/assets/javascripts/clusters_list/store/actions.js +++ b/app/assets/javascripts/clusters_list/store/actions.js @@ -3,7 +3,7 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import Poll from '~/lib/utils/poll'; -import { __ } from '~/locale'; +import { s__ } from '~/locale'; import { MAX_REQUESTS } from '../constants'; import * as types from './mutation_types'; @@ -30,7 +30,13 @@ export const fetchClusters = ({ state, commit, dispatch }) => { const poll = new Poll({ resource: { - fetchClusters: (paginatedEndPoint) => axios.get(paginatedEndPoint), + fetchClusters: (paginatedEndPoint) => + axios.get(paginatedEndPoint, { + params: { + page: state.page, + per_page: state.clustersPerPage, + }, + }), }, data: `${state.endpoint}?page=${state.page}`, method: 'fetchClusters', @@ -65,7 +71,7 @@ export const fetchClusters = ({ state, commit, dispatch }) => { commit(types.SET_LOADING_CLUSTERS, false); commit(types.SET_LOADING_NODES, false); createFlash({ - message: __('Clusters|An error occurred while loading clusters'), + message: s__('Clusters|An error occurred while loading clusters'), }); dispatch('reportSentryError', { error: response, tag: 'fetchClustersErrorCallback' }); @@ -78,3 +84,7 @@ export const fetchClusters = ({ state, commit, dispatch }) => { export const setPage = ({ commit }, page) => { commit(types.SET_PAGE, page); }; + +export const setClustersPerPage = ({ commit }, limit) => { + commit(types.SET_CLUSTERS_PER_PAGE, limit); +}; diff --git a/app/assets/javascripts/clusters_list/store/mutation_types.js b/app/assets/javascripts/clusters_list/store/mutation_types.js index beb4388c93e..e88d4c74761 100644 --- a/app/assets/javascripts/clusters_list/store/mutation_types.js +++ b/app/assets/javascripts/clusters_list/store/mutation_types.js @@ -2,3 +2,4 @@ export const SET_CLUSTERS_DATA = 'SET_CLUSTERS_DATA'; export const SET_LOADING_CLUSTERS = 'SET_LOADING_CLUSTERS'; export const SET_LOADING_NODES = 'SET_LOADING_NODES'; export const SET_PAGE = 'SET_PAGE'; +export const SET_CLUSTERS_PER_PAGE = 'SET_CLUSTERS_PER_PAGE'; diff --git a/app/assets/javascripts/clusters_list/store/mutations.js b/app/assets/javascripts/clusters_list/store/mutations.js index 5b462928518..93156c9200f 100644 --- a/app/assets/javascripts/clusters_list/store/mutations.js +++ b/app/assets/javascripts/clusters_list/store/mutations.js @@ -12,10 +12,13 @@ export default { clusters: data.clusters, clustersPerPage: paginationInformation.perPage, hasAncestorClusters: data.has_ancestor_clusters, - totalCulsters: paginationInformation.total, + totalClusters: paginationInformation.total, }); }, [types.SET_PAGE](state, value) { state.page = Number(value) || 1; }, + [types.SET_CLUSTERS_PER_PAGE](state, value) { + state.clustersPerPage = Number(value) || 1; + }, }; diff --git a/app/assets/javascripts/clusters_list/store/state.js b/app/assets/javascripts/clusters_list/store/state.js index 51fafd49479..763d7389d0f 100644 --- a/app/assets/javascripts/clusters_list/store/state.js +++ b/app/assets/javascripts/clusters_list/store/state.js @@ -1,9 +1,11 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; + export default (initialState = {}) => ({ ancestorHelperPath: initialState.ancestorHelpPath, endpoint: initialState.endpoint, hasAncestorClusters: false, clusters: [], - clustersPerPage: 0, + clustersPerPage: 20, loadingClusters: true, loadingNodes: true, page: 1, @@ -12,5 +14,6 @@ export default (initialState = {}) => ({ default: { path: initialState.imgTagsDefaultPath, text: initialState.imgTagsDefaultText }, gcp: { path: initialState.imgTagsGcpPath, text: initialState.imgTagsGcpText }, }, - totalCulsters: 0, + totalClusters: 0, + canAddCluster: parseBoolean(initialState.canAddCluster), }); diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js index 5b9e70e3c09..ad70d9be16f 100644 --- a/app/assets/javascripts/confirm_danger_modal.js +++ b/app/assets/javascripts/confirm_danger_modal.js @@ -3,14 +3,14 @@ import { Rails } from '~/lib/utils/rails_ujs'; import { rstrip } from './lib/utils/common_utils'; function openConfirmDangerModal($form, $modal, text) { - const $input = $('.js-confirm-danger-input', $modal); + const $input = $('.js-legacy-confirm-danger-input', $modal); $input.val(''); $('.js-confirm-text', $modal).text(text || ''); $modal.modal('show'); - const confirmTextMatch = $('.js-confirm-danger-match', $modal).text(); - const $submit = $('.js-confirm-danger-submit', $modal); + const confirmTextMatch = $('.js-legacy-confirm-danger-match', $modal).text(); + const $submit = $('.js-legacy-confirm-danger-submit', $modal); $submit.disable(); $input.focus(); @@ -25,7 +25,7 @@ function openConfirmDangerModal($form, $modal, text) { }); // eslint-disable-next-line @gitlab/no-global-event-off - $('.js-confirm-danger-submit', $modal) + $('.js-legacy-confirm-danger-submit', $modal) .off('click') .on('click', () => { if ($form.data('remote')) { @@ -47,7 +47,7 @@ function getModal($btn) { } export default function initConfirmDangerModal() { - $(document).on('click', '.js-confirm-danger', (e) => { + $(document).on('click', '.js-legacy-confirm-danger', (e) => { const $btn = $(e.target); const checkFieldName = $btn.data('checkFieldName'); const checkFieldCompareValue = $btn.data('checkCompareValue'); diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 02ab34447ca..a8405fe37c7 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -3,7 +3,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2'; import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants'; import { createContentEditor } from '../services/create_content_editor'; -import ContentEditorError from './content_editor_error.vue'; +import ContentEditorAlert from './content_editor_alert.vue'; import ContentEditorProvider from './content_editor_provider.vue'; import EditorStateObserver from './editor_state_observer.vue'; import FormattingBubbleMenu from './formatting_bubble_menu.vue'; @@ -12,7 +12,7 @@ import TopToolbar from './top_toolbar.vue'; export default { components: { GlLoadingIcon, - ContentEditorError, + ContentEditorAlert, ContentEditorProvider, TiptapEditorContent, TopToolbar, @@ -92,7 +92,7 @@ export default { <content-editor-provider :content-editor="contentEditor"> <div> <editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" /> - <content-editor-error /> + <content-editor-alert /> <div data-testid="content-editor" data-qa-selector="content_editor_container" diff --git a/app/assets/javascripts/content_editor/components/content_editor_alert.vue b/app/assets/javascripts/content_editor/components/content_editor_alert.vue new file mode 100644 index 00000000000..c6737da1d77 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/content_editor_alert.vue @@ -0,0 +1,33 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import EditorStateObserver from './editor_state_observer.vue'; + +export default { + components: { + GlAlert, + EditorStateObserver, + }, + data() { + return { + message: null, + variant: 'danger', + }; + }, + methods: { + displayAlert({ message, variant }) { + this.message = message; + this.variant = variant; + }, + dismissAlert() { + this.message = null; + }, + }, +}; +</script> +<template> + <editor-state-observer @alert="displayAlert"> + <gl-alert v-if="message" class="gl-mb-6" :variant="variant" @dismiss="dismissAlert"> + {{ message }} + </gl-alert> + </editor-state-observer> +</template> diff --git a/app/assets/javascripts/content_editor/components/content_editor_error.vue b/app/assets/javascripts/content_editor/components/content_editor_error.vue deleted file mode 100644 index 031ea92a7e9..00000000000 --- a/app/assets/javascripts/content_editor/components/content_editor_error.vue +++ /dev/null @@ -1,31 +0,0 @@ -<script> -import { GlAlert } from '@gitlab/ui'; -import EditorStateObserver from './editor_state_observer.vue'; - -export default { - components: { - GlAlert, - EditorStateObserver, - }, - data() { - return { - error: null, - }; - }, - methods: { - displayError({ error }) { - this.error = error; - }, - dismissError() { - this.error = null; - }, - }, -}; -</script> -<template> - <editor-state-observer @error="displayError"> - <gl-alert v-if="error" class="gl-mb-6" variant="danger" @dismiss="dismissError"> - {{ error }} - </gl-alert> - </editor-state-observer> -</template> diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue index 2eeb0719096..0604047a953 100644 --- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue +++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue @@ -7,7 +7,7 @@ export const tiptapToComponentMap = { transaction: 'transaction', focus: 'focus', blur: 'blur', - error: 'error', + alert: 'alert', }; const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName]; diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue index c44e8145982..41c083111c5 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue @@ -26,8 +26,8 @@ export default { type: Object, required: true, }, - getPos: { - type: Function, + node: { + type: Object, required: true, }, }, @@ -61,7 +61,17 @@ export default { const { state } = this.editor; const { $cursor } = state.selection; - this.displayActionsDropdown = $cursor?.pos - $cursor?.parentOffset - 1 === this.getPos(); + if (!$cursor) return; + + this.displayActionsDropdown = false; + + for (let level = 0; level < $cursor.depth; level += 1) { + if ($cursor.node(level) === this.node) { + this.displayActionsDropdown = true; + break; + } + } + if (this.displayActionsDropdown) { this.selectedRect = getSelectedRect(state); } @@ -99,7 +109,11 @@ export default { :as="cellType" @click="hideDropdown" > - <span v-if="displayActionsDropdown" class="gl-absolute gl-right-0 gl-top-0"> + <span + v-if="displayActionsDropdown" + contenteditable="false" + class="gl-absolute gl-right-0 gl-top-0" + > <gl-dropdown ref="dropdown" dropup diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue index 6b4343dd5b8..47cd837d060 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue @@ -11,8 +11,8 @@ export default { type: Object, required: true, }, - getPos: { - type: Function, + node: { + type: Object, required: true, }, }, diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue index 5f9889374f6..150f78bc84f 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue @@ -11,8 +11,8 @@ export default { type: Object, required: true, }, - getPos: { - type: Function, + node: { + type: Object, required: true, }, }, diff --git a/app/assets/javascripts/content_editor/extensions/blockquote.js b/app/assets/javascripts/content_editor/extensions/blockquote.js index 4512ead44bc..5632bc28592 100644 --- a/app/assets/javascripts/content_editor/extensions/blockquote.js +++ b/app/assets/javascripts/content_editor/extensions/blockquote.js @@ -1,10 +1,8 @@ import { Blockquote } from '@tiptap/extension-blockquote'; -import { wrappingInputRule } from 'prosemirror-inputrules'; +import { wrappingInputRule } from '@tiptap/core'; import { getParents } from '~/lib/utils/dom_utils'; import { getMarkdownSource } from '../services/markdown_sourcemap'; -export const multilineInputRegex = /^\s*>>>\s$/gm; - export default Blockquote.extend({ addAttributes() { return { @@ -25,9 +23,15 @@ export default Blockquote.extend({ }, addInputRules() { + const multilineInputRegex = /^\s*>>>\s$/gm; + return [ ...this.parent?.(), - wrappingInputRule(multilineInputRegex, this.type, () => ({ multiline: true })), + wrappingInputRule({ + find: multilineInputRegex, + type: this.type, + getAttributes: () => ({ multiline: true }), + }), ]; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/description_list.js b/app/assets/javascripts/content_editor/extensions/description_list.js index a516dfad2b8..8f5b145cfa3 100644 --- a/app/assets/javascripts/content_editor/extensions/description_list.js +++ b/app/assets/javascripts/content_editor/extensions/description_list.js @@ -1,7 +1,4 @@ -import { Node, mergeAttributes } from '@tiptap/core'; -import { wrappingInputRule } from 'prosemirror-inputrules'; - -export const inputRegex = /^\s*(<dl>)$/; +import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core'; export default Node.create({ name: 'descriptionList', @@ -18,6 +15,8 @@ export default Node.create({ }, addInputRules() { - return [wrappingInputRule(inputRegex, this.type)]; + const inputRegex = /^\s*(<dl>)$/; + + return [wrappingInputRule({ find: inputRegex, type: this.type })]; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/details.js b/app/assets/javascripts/content_editor/extensions/details.js index e3d54ed01fd..46c906d45b1 100644 --- a/app/assets/javascripts/content_editor/extensions/details.js +++ b/app/assets/javascripts/content_editor/extensions/details.js @@ -1,10 +1,7 @@ -import { Node } from '@tiptap/core'; +import { Node, wrappingInputRule } from '@tiptap/core'; import { VueNodeViewRenderer } from '@tiptap/vue-2'; -import { wrappingInputRule } from 'prosemirror-inputrules'; import DetailsWrapper from '../components/wrappers/details.vue'; -export const inputRegex = /^\s*(<details>)$/; - export default Node.create({ name: 'details', content: 'detailsContent+', @@ -24,7 +21,9 @@ export default Node.create({ }, addInputRules() { - return [wrappingInputRule(inputRegex, this.type)]; + const inputRegex = /^\s*(<details>)$/; + + return [wrappingInputRule({ find: inputRegex, type: this.type })]; }, addCommands() { diff --git a/app/assets/javascripts/content_editor/extensions/emoji.js b/app/assets/javascripts/content_editor/extensions/emoji.js index de608c3aaa2..7f8b5da5f46 100644 --- a/app/assets/javascripts/content_editor/extensions/emoji.js +++ b/app/assets/javascripts/content_editor/extensions/emoji.js @@ -1,9 +1,6 @@ -import { Node } from '@tiptap/core'; -import { InputRule } from 'prosemirror-inputrules'; +import { Node, InputRule } from '@tiptap/core'; import { initEmojiMap, getAllEmoji } from '~/emoji'; -export const emojiInputRegex = /(?:^|\s)((?::)((?:\w+))(?::))$/; - export default Node.create({ name: 'emoji', @@ -54,23 +51,28 @@ export default Node.create({ }, addInputRules() { + const emojiInputRegex = /(?:^|\s)(:(\w+):)$/; + return [ - new InputRule(emojiInputRegex, (state, match, start, end) => { - const [, , name] = match; - const emojis = getAllEmoji(); - const emoji = emojis[name]; - const { tr } = state; + new InputRule({ + find: emojiInputRegex, + handler: ({ state, range: { from, to }, match }) => { + const [, , name] = match; + const emojis = getAllEmoji(); + const emoji = emojis[name]; + const { tr } = state; - if (emoji) { - tr.replaceWith(start, end, [ - state.schema.text(' '), - this.type.create({ name, moji: emoji.e, unicodeVersion: emoji.u, title: emoji.d }), - ]); + if (emoji) { + tr.replaceWith(from, to, [ + state.schema.text(' '), + this.type.create({ name, moji: emoji.e, unicodeVersion: emoji.u, title: emoji.d }), + ]); - return tr; - } + return tr; + } - return null; + return null; + }, }), ]; }, diff --git a/app/assets/javascripts/content_editor/extensions/frontmatter.js b/app/assets/javascripts/content_editor/extensions/frontmatter.js index 64c84fe046b..c09c10bc524 100644 --- a/app/assets/javascripts/content_editor/extensions/frontmatter.js +++ b/app/assets/javascripts/content_editor/extensions/frontmatter.js @@ -17,4 +17,7 @@ export default CodeBlockHighlight.extend({ addNodeView() { return new VueNodeViewRenderer(FrontmatterWrapper); }, + addInputRules() { + return []; + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js index c8ec45d835c..c4f31e5f981 100644 --- a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js +++ b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js @@ -1,10 +1,10 @@ import { nodeInputRule } from '@tiptap/core'; import { HorizontalRule } from '@tiptap/extension-horizontal-rule'; -export const hrInputRuleRegExp = /^---$/; - export default HorizontalRule.extend({ addInputRules() { - return [nodeInputRule(hrInputRuleRegExp, this.type)]; + const hrInputRuleRegExp = /^---$/; + + return [nodeInputRule({ find: hrInputRuleRegExp, type: this.type })]; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/html_marks.js b/app/assets/javascripts/content_editor/extensions/html_marks.js index 54adb9efa0c..3abf0e3eee2 100644 --- a/app/assets/javascripts/content_editor/extensions/html_marks.js +++ b/app/assets/javascripts/content_editor/extensions/html_marks.js @@ -60,7 +60,13 @@ export default marks.map((name) => }, addInputRules() { - return [markInputRule(markInputRegex(name), this.type, extractMarkAttributesFromMatch)]; + return [ + markInputRule({ + find: markInputRegex(name), + type: this.type, + getAttributes: extractMarkAttributesFromMatch, + }), + ]; }, }), ); diff --git a/app/assets/javascripts/content_editor/extensions/inline_diff.js b/app/assets/javascripts/content_editor/extensions/inline_diff.js index 3bd328958df..22bb1ac072e 100644 --- a/app/assets/javascripts/content_editor/extensions/inline_diff.js +++ b/app/assets/javascripts/content_editor/extensions/inline_diff.js @@ -1,8 +1,5 @@ import { Mark, markInputRule, mergeAttributes } from '@tiptap/core'; -export const inputRegexAddition = /(\{\+(.+?)\+\})$/gm; -export const inputRegexDeletion = /(\{-(.+?)-\})$/gm; - export default Mark.create({ name: 'inlineDiff', @@ -38,9 +35,20 @@ export default Mark.create({ }, addInputRules() { + const inputRegexAddition = /(\{\+(.+?)\+\})$/gm; + const inputRegexDeletion = /(\{-(.+?)-\})$/gm; + return [ - markInputRule(inputRegexAddition, this.type, () => ({ type: 'addition' })), - markInputRule(inputRegexDeletion, this.type, () => ({ type: 'deletion' })), + markInputRule({ + find: inputRegexAddition, + type: this.type, + getAttributes: () => ({ type: 'addition' }), + }), + markInputRule({ + find: inputRegexDeletion, + type: this.type, + getAttributes: () => ({ type: 'deletion' }), + }), ]; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js index fc0f38e6935..27bc05dce6f 100644 --- a/app/assets/javascripts/content_editor/extensions/link.js +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -1,9 +1,6 @@ import { markInputRule } from '@tiptap/core'; import { Link } from '@tiptap/extension-link'; -export const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm; -export const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim; - const extractHrefFromMatch = (match) => { return { href: match.groups.href }; }; @@ -26,9 +23,20 @@ export default Link.extend({ openOnClick: false, }, addInputRules() { + const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm; + const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim; + return [ - markInputRule(markdownLinkSyntaxInputRuleRegExp, this.type, extractHrefFromMarkdownLink), - markInputRule(urlSyntaxRegExp, this.type, extractHrefFromMatch), + markInputRule({ + find: markdownLinkSyntaxInputRuleRegExp, + type: this.type, + getAttributes: extractHrefFromMarkdownLink, + }), + markInputRule({ + find: urlSyntaxRegExp, + type: this.type, + getAttributes: extractHrefFromMatch, + }), ]; }, addAttributes() { diff --git a/app/assets/javascripts/content_editor/extensions/math_inline.js b/app/assets/javascripts/content_editor/extensions/math_inline.js index 60f5288dcf6..4844f6feb29 100644 --- a/app/assets/javascripts/content_editor/extensions/math_inline.js +++ b/app/assets/javascripts/content_editor/extensions/math_inline.js @@ -2,8 +2,6 @@ import { Mark, markInputRule } from '@tiptap/core'; import { __ } from '~/locale'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; -export const inputRegex = /(?:^|\s)\$`([^`]+)`\$$/gm; - export default Mark.create({ name: 'mathInline', @@ -30,6 +28,8 @@ export default Mark.create({ }, addInputRules() { - return [markInputRule(inputRegex, this.type)]; + const inputRegex = /(?:^|\s)\$`([^`]+)`\$$/gm; + + return [markInputRule({ find: inputRegex, type: this.type })]; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/subscript.js b/app/assets/javascripts/content_editor/extensions/subscript.js index d0766f42308..a8c087e8bf0 100644 --- a/app/assets/javascripts/content_editor/extensions/subscript.js +++ b/app/assets/javascripts/content_editor/extensions/subscript.js @@ -4,6 +4,12 @@ import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark export default Subscript.extend({ addInputRules() { - return [markInputRule(markInputRegex('sub'), this.type, extractMarkAttributesFromMatch)]; + return [ + markInputRule({ + find: markInputRegex('sub'), + type: this.type, + getAttributes: extractMarkAttributesFromMatch, + }), + ]; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/superscript.js b/app/assets/javascripts/content_editor/extensions/superscript.js index 6cd814977ea..b86906f01f2 100644 --- a/app/assets/javascripts/content_editor/extensions/superscript.js +++ b/app/assets/javascripts/content_editor/extensions/superscript.js @@ -4,6 +4,12 @@ import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark export default Superscript.extend({ addInputRules() { - return [markInputRule(markInputRegex('sup'), this.type, extractMarkAttributesFromMatch)]; + return [ + markInputRule({ + find: markInputRegex('sup'), + type: this.type, + getAttributes: extractMarkAttributesFromMatch, + }), + ]; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/table.js b/app/assets/javascripts/content_editor/extensions/table.js index 0f0477cba2e..004bb8b815c 100644 --- a/app/assets/javascripts/content_editor/extensions/table.js +++ b/app/assets/javascripts/content_editor/extensions/table.js @@ -1 +1,42 @@ -export { Table as default } from '@tiptap/extension-table'; +import { Table } from '@tiptap/extension-table'; +import { debounce } from 'lodash'; +import { __ } from '~/locale'; +import { getMarkdownSource } from '../services/markdown_sourcemap'; +import { shouldRenderHTMLTable } from '../services/serialization_helpers'; + +let alertShown = false; +const onUpdate = debounce((editor) => { + if (alertShown) return; + + editor.state.doc.descendants((node) => { + if (node.type.name === 'table' && node.attrs.isMarkdown && shouldRenderHTMLTable(node)) { + editor.emit('alert', { + message: __( + 'The content editor may change the markdown formatting style of the document, which may not match your original markdown style.', + ), + variant: 'warning', + }); + + alertShown = true; + + return false; + } + + return true; + }); +}, 1000); + +export default Table.extend({ + addAttributes() { + return { + isMarkdown: { + default: null, + parseHTML: (element) => Boolean(getMarkdownSource(element)), + }, + }; + }, + + onUpdate({ editor }) { + onUpdate(editor); + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/table_cell.js b/app/assets/javascripts/content_editor/extensions/table_cell.js index befc33e669f..9f437ce066c 100644 --- a/app/assets/javascripts/content_editor/extensions/table_cell.js +++ b/app/assets/javascripts/content_editor/extensions/table_cell.js @@ -1,10 +1,9 @@ import { TableCell } from '@tiptap/extension-table-cell'; import { VueNodeViewRenderer } from '@tiptap/vue-2'; import TableCellBodyWrapper from '../components/wrappers/table_cell_body.vue'; -import { isBlockTablesFeatureEnabled } from '../services/feature_flags'; export default TableCell.extend({ - content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*', + content: 'block+', addNodeView() { return VueNodeViewRenderer(TableCellBodyWrapper); diff --git a/app/assets/javascripts/content_editor/extensions/table_header.js b/app/assets/javascripts/content_editor/extensions/table_header.js index 829b06fc14b..045fd03199b 100644 --- a/app/assets/javascripts/content_editor/extensions/table_header.js +++ b/app/assets/javascripts/content_editor/extensions/table_header.js @@ -1,10 +1,9 @@ import { TableHeader } from '@tiptap/extension-table-header'; import { VueNodeViewRenderer } from '@tiptap/vue-2'; import TableCellHeaderWrapper from '../components/wrappers/table_cell_header.vue'; -import { isBlockTablesFeatureEnabled } from '../services/feature_flags'; export default TableHeader.extend({ - content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*', + content: 'block+', addNodeView() { return VueNodeViewRenderer(TableCellHeaderWrapper); }, diff --git a/app/assets/javascripts/content_editor/extensions/table_of_contents.js b/app/assets/javascripts/content_editor/extensions/table_of_contents.js index 9e31158837e..a8882f9ede4 100644 --- a/app/assets/javascripts/content_editor/extensions/table_of_contents.js +++ b/app/assets/javascripts/content_editor/extensions/table_of_contents.js @@ -1,10 +1,7 @@ -import { Node } from '@tiptap/core'; -import { InputRule } from 'prosemirror-inputrules'; +import { Node, InputRule } from '@tiptap/core'; import { s__ } from '~/locale'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; -export const inputRuleRegExps = [/^\[\[_TOC_\]\]$/, /^\[TOC\]$/]; - export default Node.create({ name: 'tableOfContents', @@ -34,17 +31,21 @@ export default Node.create({ addInputRules() { const { type } = this; + const inputRuleRegExps = [/^\[\[_TOC_\]\]$/, /^\[TOC\]$/]; return inputRuleRegExps.map( (regex) => - new InputRule(regex, (state, match, start, end) => { - const { tr } = state; + new InputRule({ + find: regex, + handler: ({ state, range: { from, to }, match }) => { + const { tr } = state; - if (match) { - tr.replaceWith(start - 1, end, type.create()); - } + if (match) { + tr.replaceWith(from - 1, to, type.create()); + } - return tr; + return tr; + }, }), ); }, diff --git a/app/assets/javascripts/content_editor/extensions/word_break.js b/app/assets/javascripts/content_editor/extensions/word_break.js index 93b42466850..fa7e02f8cc8 100644 --- a/app/assets/javascripts/content_editor/extensions/word_break.js +++ b/app/assets/javascripts/content_editor/extensions/word_break.js @@ -1,7 +1,5 @@ import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core'; -export const inputRegex = /^<wbr>$/; - export default Node.create({ name: 'wordBreak', inline: true, @@ -24,6 +22,8 @@ export default Node.create({ }, addInputRules() { - return [nodeInputRule(inputRegex, this.type)]; + const inputRegex = /^<wbr>$/; + + return [nodeInputRule({ find: inputRegex, type: this.type })]; }, }); diff --git a/app/assets/javascripts/content_editor/services/feature_flags.js b/app/assets/javascripts/content_editor/services/feature_flags.js deleted file mode 100644 index 5f7a4595938..00000000000 --- a/app/assets/javascripts/content_editor/services/feature_flags.js +++ /dev/null @@ -1,3 +0,0 @@ -export function isBlockTablesFeatureEnabled() { - return gon.features?.contentEditorBlockTables; -} diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index b2327555b45..ed5910fca18 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -1,5 +1,4 @@ import { uniq } from 'lodash'; -import { isBlockTablesFeatureEnabled } from './feature_flags'; const defaultAttrs = { td: { colspan: 1, rowspan: 1, colwidth: null }, @@ -75,7 +74,7 @@ function getChildren(node) { return children; } -function shouldRenderHTMLTable(table) { +export function shouldRenderHTMLTable(table) { const { rows, cells } = getRowsAndCells(table); const cellChildCount = Math.max(...cells.map((cell) => cell.childCount)); @@ -282,11 +281,6 @@ export function renderOrderedList(state, node) { } export function renderTableCell(state, node) { - if (!isBlockTablesFeatureEnabled()) { - state.renderInline(node); - return; - } - if (!isInBlockTable(node) || containsParagraphWithOnlyText(node)) { state.renderInline(node.child(0)); } else { @@ -303,9 +297,7 @@ export function renderTableRow(state, node) { } export function renderTable(state, node) { - if (isBlockTablesFeatureEnabled()) { - setIsInBlockTable(node, shouldRenderHTMLTable(node)); - } + setIsInBlockTable(node, shouldRenderHTMLTable(node)); if (isInBlockTable(node)) renderTagOpen(state, 'table'); @@ -317,9 +309,7 @@ export function renderTable(state, node) { state.closeBlock(node); state.flushClose(); - if (isBlockTablesFeatureEnabled()) { - unsetIsInBlockTable(node); - } + unsetIsInBlockTable(node); } export function renderHardBreak(state, node, parent, index) { diff --git a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js index d26f32a7e7a..9b1cb76f845 100644 --- a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js +++ b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js @@ -1,5 +1,5 @@ import { mapValues } from 'lodash'; -import { InputRule } from 'prosemirror-inputrules'; +import { InputRule } from '@tiptap/core'; import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys'; import Tracking from '~/tracking'; import { @@ -17,17 +17,20 @@ const trackKeyboardShortcut = (contentType, commandFn, shortcut) => () => { }; const trackInputRule = (contentType, inputRule) => { - return new InputRule(inputRule.match, (...args) => { - const result = inputRule.handler(...args); + return new InputRule({ + find: inputRule.find, + handler: (...args) => { + const result = inputRule.handler(...args); - if (result) { - Tracking.event(undefined, INPUT_RULE_TRACKING_ACTION, { - label: CONTENT_EDITOR_TRACKING_LABEL, - property: contentType, - }); - } + if (result !== null) { + Tracking.event(undefined, INPUT_RULE_TRACKING_ACTION, { + label: CONTENT_EDITOR_TRACKING_LABEL, + property: contentType, + }); + } - return result; + return result; + }, }); }; diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js index 8ac3f719309..f5bf2742748 100644 --- a/app/assets/javascripts/content_editor/services/upload_helpers.js +++ b/app/assets/javascripts/content_editor/services/upload_helpers.js @@ -72,8 +72,9 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => { ); } catch (e) { editor.commands.deleteRange({ from: position, to: position + 1 }); - editor.emit('error', { - error: __('An error occurred while uploading the image. Please try again.'), + editor.emit('alert', { + message: __('An error occurred while uploading the image. Please try again.'), + variant: 'danger', }); } }; @@ -102,8 +103,9 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) = ); } catch (e) { editor.commands.deleteRange({ from, to: from + 1 }); - editor.emit('error', { - error: __('An error occurred while uploading the file. Please try again.'), + editor.emit('alert', { + message: __('An error occurred while uploading the file. Please try again.'), + variant: 'danger', }); } }; diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index 08cf0197993..08942374120 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -2,6 +2,8 @@ import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; import Cookies from 'js-cookie'; import { debounce } from 'lodash'; +import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; +import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { parseBoolean } from '~/lib/utils/common_utils'; export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed'; @@ -112,5 +114,8 @@ export default class ContextualSidebar { const collapse = parseBoolean(Cookies.get('sidebar_collapsed')); this.toggleCollapsedSidebar(collapse, true); } + + initInviteMembersModal(); + initInviteMembersTrigger(); } } diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue index 9d4eddc510a..73458a463f2 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue @@ -84,7 +84,7 @@ export default { ), subnetDropdownHelpPath: 'https://console.aws.amazon.com/vpc/home?#subnets', securityGroupDropdownHelpText: s__( - 'ClusterIntegration|Choose the %{linkStart}security group %{linkEnd} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.', + 'ClusterIntegration|Choose the %{linkStart}security group%{linkEnd} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.', ), securityGroupDropdownHelpPath: 'https://console.aws.amazon.com/vpc/home?#securityGroups', instanceTypesDropdownHelpText: s__( diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index f4a27dc7d1f..ae6e6bf02e4 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -54,6 +54,7 @@ export default class CreateMergeRequestDropdown { this.isCreatingBranch = false; this.isCreatingMergeRequest = false; this.isGettingRef = false; + this.refCancelToken = null; this.mergeRequestCreated = false; this.refDebounce = debounce((value, target) => this.getRef(value, target), 500); this.refIsValid = true; @@ -101,9 +102,18 @@ export default class CreateMergeRequestDropdown { 'click', this.onClickCreateMergeRequestButton.bind(this), ); + this.branchInput.addEventListener('input', this.onChangeInput.bind(this)); this.branchInput.addEventListener('keyup', this.onChangeInput.bind(this)); this.dropdownToggle.addEventListener('click', this.onClickSetFocusOnBranchNameInput.bind(this)); + // Detect for example when user pastes ref using the mouse + this.refInput.addEventListener('input', this.onChangeInput.bind(this)); + // Detect for example when user presses right arrow to apply the suggested ref this.refInput.addEventListener('keyup', this.onChangeInput.bind(this)); + // Detect when user clicks inside the input to apply the suggested ref + this.refInput.addEventListener('click', this.onChangeInput.bind(this)); + // Detect when user clicks outside the input to apply the suggested ref + this.refInput.addEventListener('blur', this.onChangeInput.bind(this)); + // Detect when user presses tab to apply the suggested ref this.refInput.addEventListener('keydown', CreateMergeRequestDropdown.processTab.bind(this)); } @@ -247,8 +257,12 @@ export default class CreateMergeRequestDropdown { getRef(ref, target = 'all') { if (!ref) return false; + this.refCancelToken = axios.CancelToken.source(); + return axios - .get(`${createEndpoint(this.projectPath, this.refsPath)}${encodeURIComponent(ref)}`) + .get(`${createEndpoint(this.projectPath, this.refsPath)}${encodeURIComponent(ref)}`, { + cancelToken: this.refCancelToken.token, + }) .then(({ data }) => { const branches = data[Object.keys(data)[0]]; const tags = data[Object.keys(data)[1]]; @@ -267,7 +281,10 @@ export default class CreateMergeRequestDropdown { return this.updateInputState(target, ref, result); }) - .catch(() => { + .catch((thrown) => { + if (axios.isCancel(thrown)) { + return false; + } this.unavailable(); this.disable(); createFlash({ @@ -325,14 +342,23 @@ export default class CreateMergeRequestDropdown { let target; let value; + // User changed input, cancel to prevent previous request from interfering + if (this.refCancelToken !== null) { + this.refCancelToken.cancel(); + } + if (event.target === this.branchInput) { target = 'branch'; ({ value } = this.branchInput); } else if (event.target === this.refInput) { target = 'ref'; - value = - event.target.value.slice(0, event.target.selectionStart) + - event.target.value.slice(event.target.selectionEnd); + if (event.target === document.activeElement) { + value = + event.target.value.slice(0, event.target.selectionStart) + + event.target.value.slice(event.target.selectionEnd); + } else { + value = event.target.value; + } } else { return false; } @@ -358,6 +384,7 @@ export default class CreateMergeRequestDropdown { this.enable(); this.showAvailableMessage(target); + this.refDebounce(value, target); return true; } @@ -414,7 +441,8 @@ export default class CreateMergeRequestDropdown { if (!selectedText || this.refInput.dataset.value === this.suggestedRef) return; event.preventDefault(); - window.getSelection().removeAllRanges(); + const caretPositionEnd = this.refInput.value.length; + this.refInput.setSelectionRange(caretPositionEnd, caretPositionEnd); } removeMessage(target) { diff --git a/app/assets/javascripts/crm/components/contacts_root.vue b/app/assets/javascripts/crm/components/contacts_root.vue new file mode 100644 index 00000000000..83c02f7d5fe --- /dev/null +++ b/app/assets/javascripts/crm/components/contacts_root.vue @@ -0,0 +1,80 @@ +<script> +import { GlLoadingIcon, GlTable } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { s__, __ } from '~/locale'; +import getGroupContactsQuery from './queries/get_group_contacts.query.graphql'; + +export default { + components: { + GlLoadingIcon, + GlTable, + }, + inject: ['groupFullPath'], + data() { + return { contacts: [] }; + }, + apollo: { + contacts: { + query() { + return getGroupContactsQuery; + }, + variables() { + return { + groupFullPath: this.groupFullPath, + }; + }, + update(data) { + return this.extractContacts(data); + }, + error(error) { + createFlash({ + message: __('Something went wrong. Please try again.'), + error, + captureError: true, + }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.contacts.loading; + }, + }, + methods: { + extractContacts(data) { + const contacts = data?.group?.contacts?.nodes || []; + return contacts.slice().sort((a, b) => a.firstName.localeCompare(b.firstName)); + }, + }, + fields: [ + { key: 'firstName', sortable: true }, + { key: 'lastName', sortable: true }, + { key: 'email', sortable: true }, + { key: 'phone', sortable: true }, + { key: 'description', sortable: true }, + { + key: 'organization', + formatter: (organization) => { + return organization?.name; + }, + sortable: true, + }, + ], + i18n: { + emptyText: s__('Crm|No contacts found'), + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" /> + <gl-table + v-else + :items="contacts" + :fields="$options.fields" + :empty-text="$options.i18n.emptyText" + show-empty + /> + </div> +</template> diff --git a/app/assets/javascripts/crm/components/organizations_root.vue b/app/assets/javascripts/crm/components/organizations_root.vue new file mode 100644 index 00000000000..98b45d0a042 --- /dev/null +++ b/app/assets/javascripts/crm/components/organizations_root.vue @@ -0,0 +1,71 @@ +<script> +import { GlLoadingIcon, GlTable } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { s__, __ } from '~/locale'; +import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql'; + +export default { + components: { + GlLoadingIcon, + GlTable, + }, + inject: ['groupFullPath'], + data() { + return { organizations: [] }; + }, + apollo: { + organizations: { + query() { + return getGroupOrganizationsQuery; + }, + variables() { + return { + groupFullPath: this.groupFullPath, + }; + }, + update(data) { + return this.extractOrganizations(data); + }, + error(error) { + createFlash({ + message: __('Something went wrong. Please try again.'), + error, + captureError: true, + }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.organizations.loading; + }, + }, + methods: { + extractOrganizations(data) { + const organizations = data?.group?.organizations?.nodes || []; + return organizations.slice().sort((a, b) => a.name.localeCompare(b.name)); + }, + }, + fields: [ + { key: 'name', sortable: true }, + { key: 'defaultRate', sortable: true }, + { key: 'description', sortable: true }, + ], + i18n: { + emptyText: s__('Crm|No organizations found'), + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" /> + <gl-table + v-else + :items="organizations" + :fields="$options.fields" + :empty-text="$options.i18n.emptyText" + show-empty + /> + </div> +</template> diff --git a/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql b/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql new file mode 100644 index 00000000000..f6acd258585 --- /dev/null +++ b/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql @@ -0,0 +1,22 @@ +query contacts($groupFullPath: ID!) { + group(fullPath: $groupFullPath) { + __typename + id + contacts { + nodes { + __typename + id + firstName + lastName + email + phone + description + organization { + __typename + id + name + } + } + } + } +} diff --git a/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql b/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql new file mode 100644 index 00000000000..7c4ec6ec585 --- /dev/null +++ b/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql @@ -0,0 +1,15 @@ +query organizations($groupFullPath: ID!) { + group(fullPath: $groupFullPath) { + __typename + id + organizations { + nodes { + __typename + id + name + defaultRate + description + } + } + } +} diff --git a/app/assets/javascripts/crm/contacts_bundle.js b/app/assets/javascripts/crm/contacts_bundle.js new file mode 100644 index 00000000000..6438953596e --- /dev/null +++ b/app/assets/javascripts/crm/contacts_bundle.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import CrmContactsRoot from './components/contacts_root.vue'; + +Vue.use(VueApollo); + +export default () => { + const el = document.getElementById('js-crm-contacts-app'); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + if (!el) { + return false; + } + + return new Vue({ + el, + apolloProvider, + provide: { groupFullPath: el.dataset.groupFullPath }, + render(createElement) { + return createElement(CrmContactsRoot); + }, + }); +}; diff --git a/app/assets/javascripts/crm/organizations_bundle.js b/app/assets/javascripts/crm/organizations_bundle.js new file mode 100644 index 00000000000..ac9990b9fb4 --- /dev/null +++ b/app/assets/javascripts/crm/organizations_bundle.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import CrmOrganizationsRoot from './components/organizations_root.vue'; + +Vue.use(VueApollo); + +export default () => { + const el = document.getElementById('js-crm-organizations-app'); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + if (!el) { + return false; + } + + return new Vue({ + el, + apolloProvider, + provide: { groupFullPath: el.dataset.groupFullPath }, + render(createElement) { + return createElement(CrmOrganizationsRoot); + }, + }); +}; diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue index 1d98a42ce58..36430e51dd2 100644 --- a/app/assets/javascripts/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/cycle_analytics/components/base.vue @@ -2,10 +2,12 @@ import { GlLoadingIcon } from '@gitlab/ui'; import Cookies from 'js-cookie'; import { mapActions, mapState, mapGetters } from 'vuex'; +import { toYmd } from '~/analytics/shared/utils'; import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; import StageTable from '~/cycle_analytics/components/stage_table.vue'; import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue'; import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue'; +import UrlSync from '~/vue_shared/components/url_sync.vue'; import { __ } from '~/locale'; import { SUMMARY_METRICS_REQUEST, METRICS_REQUESTS } from '../constants'; @@ -19,6 +21,7 @@ export default { StageTable, ValueStreamFilters, ValueStreamMetrics, + UrlSync, }, props: { noDataSvgPath: { @@ -54,6 +57,9 @@ export default { 'pagination', ]), ...mapGetters(['pathNavigationData', 'filterParams']), + isLoaded() { + return !this.isLoading && !this.isLoadingStage; + }, displayStageEvents() { const { selectedStageEvents, isLoadingStage, isEmptyStage } = this; return selectedStageEvents.length && !isLoadingStage && !isEmptyStage; @@ -98,6 +104,16 @@ export default { metricsRequests() { return this.features?.cycleAnalyticsForGroups ? METRICS_REQUESTS : SUMMARY_METRICS_REQUEST; }, + query() { + return { + created_after: toYmd(this.createdAfter), + created_before: toYmd(this.createdBefore), + stage_id: this.selectedStage?.id || null, + sort: this.pagination?.sort || null, + direction: this.pagination?.direction || null, + page: this.pagination?.page || null, + }; + }, }, methods: { ...mapActions([ @@ -176,5 +192,6 @@ export default { :pagination="pagination" @handleUpdatePagination="onHandleUpdatePagination" /> + <url-sync v-if="isLoaded" :query="query" /> </div> </template> diff --git a/app/assets/javascripts/cycle_analytics/components/metric_popover.vue b/app/assets/javascripts/cycle_analytics/components/metric_popover.vue new file mode 100644 index 00000000000..8d90e7b2392 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/metric_popover.vue @@ -0,0 +1,61 @@ +<script> +import { GlPopover, GlLink, GlIcon } from '@gitlab/ui'; + +export default { + name: 'MetricPopover', + components: { + GlPopover, + GlLink, + GlIcon, + }, + props: { + metric: { + type: Object, + required: true, + }, + target: { + type: String, + required: true, + }, + }, + computed: { + metricLinks() { + return this.metric.links?.filter((link) => !link.docs_link) || []; + }, + docsLink() { + return this.metric.links?.find((link) => link.docs_link); + }, + }, +}; +</script> + +<template> + <gl-popover :target="target" placement="bottom"> + <template #title> + <span class="gl-display-block gl-text-left" data-testid="metric-label">{{ + metric.label + }}</span> + </template> + <div + v-for="(link, idx) in metricLinks" + :key="`link-${idx}`" + class="gl-display-flex gl-justify-content-space-between gl-text-right gl-py-1" + data-testid="metric-link" + > + <span>{{ link.label }}</span> + <gl-link :href="link.url" class="gl-font-sm"> + {{ link.name }} + </gl-link> + </div> + <span v-if="metric.description" data-testid="metric-description">{{ metric.description }}</span> + <gl-link + v-if="docsLink" + :href="docsLink.url" + class="gl-font-sm" + target="_blank" + data-testid="metric-docs-link" + >{{ docsLink.label }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + </gl-popover> +</template> diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue b/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue index 7371ffd2c7c..9671742e564 100644 --- a/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue +++ b/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue @@ -1,11 +1,13 @@ <script> -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlPopover } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import { GlSingleStat } from '@gitlab/ui/dist/charts'; import { flatten } from 'lodash'; import createFlash from '~/flash'; import { sprintf, s__ } from '~/locale'; +import { redirectTo } from '~/lib/utils/url_utility'; import { METRICS_POPOVER_CONTENT } from '../constants'; import { removeFlash, prepareTimeMetricsData } from '../utils'; +import MetricPopover from './metric_popover.vue'; const requestData = ({ request, endpoint, path, params, name }) => { return request({ endpoint, params, requestPath: path }) @@ -31,9 +33,9 @@ const fetchMetricsData = (reqs = [], path, params) => { export default { name: 'ValueStreamMetrics', components: { - GlPopover, GlSingleStat, GlSkeletonLoading, + MetricPopover, }, props: { requestPath: { @@ -76,32 +78,33 @@ export default { this.isLoading = false; }); }, + hasLinks(links) { + return links?.length && links[0].url; + }, + clickHandler({ links }) { + if (this.hasLinks(links)) { + redirectTo(links[0].url); + } + }, }, }; </script> <template> <div class="gl-display-flex gl-flex-wrap" data-testid="vsa-time-metrics"> - <div v-if="isLoading" class="gl-h-auto gl-py-3 gl-pr-9 gl-my-6"> - <gl-skeleton-loading /> + <gl-skeleton-loading v-if="isLoading" class="gl-h-auto gl-py-3 gl-pr-9 gl-my-6" /> + <div v-for="metric in metrics" v-show="!isLoading" :key="metric.key" class="gl-my-6 gl-pr-9"> + <gl-single-stat + :id="metric.key" + :value="`${metric.value}`" + :title="metric.label" + :unit="metric.unit || ''" + :should-animate="true" + :animation-decimal-places="1" + :class="{ 'gl-hover-cursor-pointer': hasLinks(metric.links) }" + tabindex="0" + @click="clickHandler(metric)" + /> + <metric-popover :metric="metric" :target="metric.key" /> </div> - <template v-else> - <div v-for="metric in metrics" :key="metric.key" class="gl-my-6 gl-pr-9"> - <gl-single-stat - :id="metric.key" - :value="`${metric.value}`" - :title="metric.label" - :unit="metric.unit || ''" - :should-animate="true" - :animation-decimal-places="1" - tabindex="0" - /> - <gl-popover :target="metric.key" placement="bottom"> - <template #title> - <span class="gl-display-block gl-text-left">{{ metric.label }}</span> - </template> - <span v-if="metric.description">{{ metric.description }}</span> - </gl-popover> - </div> - </template> </div> </template> diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js index c205aa1e831..7d5822b0824 100644 --- a/app/assets/javascripts/cycle_analytics/constants.js +++ b/app/assets/javascripts/cycle_analytics/constants.js @@ -5,8 +5,6 @@ import { } from '~/api/analytics_api'; import { __, s__ } from '~/locale'; -export const DEFAULT_DAYS_IN_PAST = 30; -export const DEFAULT_DAYS_TO_DISPLAY = 30; export const OVERVIEW_STAGE_ID = 'overview'; export const DEFAULT_VALUE_STREAM = { @@ -47,6 +45,11 @@ export const METRICS_POPOVER_CONTENT = { "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.", ), }, + 'lead-time-for-changes': { + description: s__( + 'ValueStreamAnalytics|Median time between merge request merge and deployment to a production environment for all MRs deployed in the given time period.', + ), + }, 'new-issue': { description: s__('ValueStreamAnalytics|Number of new issues created.') }, 'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') }, deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') }, diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js index 34ef03409b8..3da8696edeb 100644 --- a/app/assets/javascripts/cycle_analytics/index.js +++ b/app/assets/javascripts/cycle_analytics/index.js @@ -1,44 +1,36 @@ import Vue from 'vue'; +import { + extractFilterQueryParameters, + extractPaginationQueryParameters, +} from '~/analytics/shared/utils'; import Translate from '../vue_shared/translate'; import CycleAnalytics from './components/base.vue'; -import { DEFAULT_DAYS_TO_DISPLAY } from './constants'; import createStore from './store'; -import { calculateFormattedDayInPast } from './utils'; +import { buildCycleAnalyticsInitialData } from './utils'; Vue.use(Translate); export default () => { const store = createStore(); const el = document.querySelector('#js-cycle-analytics'); - const { - noAccessSvgPath, - noDataSvgPath, - requestPath, - fullPath, - projectId, - groupId, - groupPath, - labelsPath, - milestonesPath, - } = el.dataset; + const { noAccessSvgPath, noDataSvgPath } = el.dataset; + const initialData = buildCycleAnalyticsInitialData({ ...el.dataset, gon }); - const { now, past } = calculateFormattedDayInPast(DEFAULT_DAYS_TO_DISPLAY); + const pagination = extractPaginationQueryParameters(window.location.search); + const { + selectedAuthor, + selectedMilestone, + selectedAssigneeList, + selectedLabelList, + } = extractFilterQueryParameters(window.location.search); store.dispatch('initializeVsa', { - projectId: parseInt(projectId, 10), - endpoints: { - requestPath, - fullPath, - labelsPath, - milestonesPath, - groupId: parseInt(groupId, 10), - groupPath, - }, - features: { - cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups), - }, - createdBefore: new Date(now), - createdAfter: new Date(past), + ...initialData, + selectedAuthor, + selectedMilestone, + selectedAssigneeList, + selectedLabelList, + pagination, }); // eslint-disable-next-line no-new @@ -52,7 +44,6 @@ export default () => { props: { noDataSvgPath, noAccessSvgPath, - fullPath, }, }), }); diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js index 24b62849db7..e0156b24f9d 100644 --- a/app/assets/javascripts/cycle_analytics/store/actions.js +++ b/app/assets/javascripts/cycle_analytics/store/actions.js @@ -14,7 +14,7 @@ import * as types from './mutation_types'; export const setSelectedValueStream = ({ commit, dispatch }, valueStream) => { commit(types.SET_SELECTED_VALUE_STREAM, valueStream); - return Promise.all([dispatch('fetchValueStreamStages'), dispatch('fetchCycleAnalyticsData')]); + return dispatch('fetchValueStreamStages'); }; export const fetchValueStreamStages = ({ commit, state }) => { @@ -46,10 +46,8 @@ export const fetchValueStreams = ({ commit, dispatch, state }) => { } = state; commit(types.REQUEST_VALUE_STREAMS); - const stageRequests = ['setSelectedStage', 'fetchStageMedians', 'fetchStageCountValues']; return getProjectValueStreams(fullPath) .then(({ data }) => dispatch('receiveValueStreamsSuccess', data)) - .then(() => Promise.all(stageRequests.map((r) => dispatch(r)))) .catch(({ response: { status } }) => { commit(types.RECEIVE_VALUE_STREAMS_ERROR, status); }); @@ -153,33 +151,36 @@ export const fetchStageCountValues = ({ }); }; -export const setSelectedStage = ({ dispatch, commit, state: { stages } }, selectedStage = null) => { - const stage = selectedStage || stages[0]; - commit(types.SET_SELECTED_STAGE, stage); - return dispatch('fetchStageData'); +export const fetchValueStreamStageData = ({ dispatch }) => + Promise.all([ + dispatch('fetchCycleAnalyticsData'), + dispatch('fetchStageData'), + dispatch('fetchStageMedians'), + dispatch('fetchStageCountValues'), + ]); + +export const refetchStageData = async ({ dispatch, commit }) => { + commit(types.SET_LOADING, true); + await dispatch('fetchValueStreamStageData'); + commit(types.SET_LOADING, false); }; -export const setLoading = ({ commit }, value) => commit(types.SET_LOADING, value); - -const refetchStageData = (dispatch) => { - return Promise.resolve() - .then(() => dispatch('setLoading', true)) - .then(() => - Promise.all([ - dispatch('fetchCycleAnalyticsData'), - dispatch('fetchStageData'), - dispatch('fetchStageMedians'), - dispatch('fetchStageCountValues'), - ]), - ) - .finally(() => dispatch('setLoading', false)); +export const setSelectedStage = ({ dispatch, commit }, selectedStage = null) => { + commit(types.SET_SELECTED_STAGE, selectedStage); + return dispatch('refetchStageData'); }; -export const setFilters = ({ dispatch }) => refetchStageData(dispatch); +export const setFilters = ({ dispatch }) => dispatch('refetchStageData'); export const setDateRange = ({ dispatch, commit }, { createdAfter, createdBefore }) => { commit(types.SET_DATE_RANGE, { createdAfter, createdBefore }); - return refetchStageData(dispatch); + return dispatch('refetchStageData'); +}; + +export const setInitialStage = ({ dispatch, commit, state: { stages } }, stage) => { + const selectedStage = stage || stages[0]; + commit(types.SET_SELECTED_STAGE, selectedStage); + return dispatch('fetchValueStreamStageData'); }; export const updateStageTablePagination = ( @@ -190,12 +191,18 @@ export const updateStageTablePagination = ( return dispatch('fetchStageData', selectedStage.id); }; -export const initializeVsa = ({ commit, dispatch }, initialData = {}) => { +export const initializeVsa = async ({ commit, dispatch }, initialData = {}) => { commit(types.INITIALIZE_VSA, initialData); const { endpoints: { fullPath, groupPath, milestonesPath = '', labelsPath = '' }, + selectedAuthor, + selectedMilestone, + selectedAssigneeList, + selectedLabelList, + selectedStage = null, } = initialData; + dispatch('filters/setEndpoints', { labelsEndpoint: labelsPath, milestonesEndpoint: milestonesPath, @@ -203,7 +210,15 @@ export const initializeVsa = ({ commit, dispatch }, initialData = {}) => { projectEndpoint: fullPath, }); - return dispatch('setLoading', true) - .then(() => dispatch('fetchValueStreams')) - .finally(() => dispatch('setLoading', false)); + dispatch('filters/initialize', { + selectedAuthor, + selectedMilestone, + selectedAssigneeList, + selectedLabelList, + }); + + commit(types.SET_LOADING, true); + await dispatch('fetchValueStreams'); + await dispatch('setInitialStage', selectedStage); + commit(types.SET_LOADING, false); }; diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js index 3c6267bac06..9af63f5f9cc 100644 --- a/app/assets/javascripts/cycle_analytics/utils.js +++ b/app/assets/javascripts/cycle_analytics/utils.js @@ -1,7 +1,4 @@ -import dateFormat from 'dateformat'; -import { dateFormats } from '~/analytics/shared/constants'; import { hideFlash } from '~/flash'; -import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility'; import { parseSeconds } from '~/lib/utils/datetime_utility'; import { formatTimeAsSummary } from '~/lib/utils/datetime/date_format_utility'; import { slugify } from '~/lib/utils/text_utility'; @@ -74,23 +71,6 @@ export const formatMedianValues = (medians = []) => export const filterStagesByHiddenStatus = (stages = [], isHidden = true) => stages.filter(({ hidden = false }) => hidden === isHidden); -const toIsoFormat = (d) => dateFormat(d, dateFormats.isoDate); - -/** - * Takes an integer specifying the number of days to subtract - * from the date specified will return the 2 dates, formatted as ISO dates - * - * @param {Number} daysInPast - Number of days in the past to subtract - * @param {Date} [today=new Date] - Date to subtract days from, defaults to today - * @returns {Object} Returns 'now' and the 'past' date formatted as ISO dates - */ -export const calculateFormattedDayInPast = (daysInPast, today = new Date()) => { - return { - now: toIsoFormat(today), - past: toIsoFormat(getDateInPast(today, daysInPast)), - }; -}; - /** * @typedef {Object} MetricData * @property {String} title - Title of the metric measured @@ -123,3 +103,43 @@ export const prepareTimeMetricsData = (data = [], popoverContent = {}) => description: popoverContent[key]?.description || '', }; }); + +const extractFeatures = (gon) => ({ + cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups), +}); + +/** + * Builds the initial data object for Value Stream Analytics with data loaded from the backend + * + * @param {Object} dataset - dataset object paseed to the frontend via data-* properties + * @returns {Object} - The initial data to load the app with + */ +export const buildCycleAnalyticsInitialData = ({ + fullPath, + requestPath, + projectId, + groupId, + groupPath, + labelsPath, + milestonesPath, + stage, + createdAfter, + createdBefore, + gon, +} = {}) => { + return { + projectId: parseInt(projectId, 10), + endpoints: { + requestPath, + fullPath, + labelsPath, + milestonesPath, + groupId: parseInt(groupId, 10), + groupPath, + }, + createdAfter: new Date(createdAfter), + createdBefore: new Date(createdBefore), + selectedStage: stage ? JSON.parse(stage) : null, + features: extractFeatures(gon), + }; +}; diff --git a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue index e026391ae22..fdf8b7796bf 100644 --- a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue +++ b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue @@ -62,7 +62,7 @@ export default { </gl-sprintf> {{ s__('DeployTokens|This action cannot be undone.') }} <template #modal-footer> - <gl-button category="secondary" @click="cancelHandler">{{ s__('Cancel') }}</gl-button> + <gl-button category="secondary" @click="cancelHandler">{{ __('Cancel') }}</gl-button> <gl-button category="primary" variant="danger" diff --git a/app/assets/javascripts/design_management/graphql.js b/app/assets/javascripts/design_management/graphql.js index fa57537f74e..5cf32cb7fe3 100644 --- a/app/assets/javascripts/design_management/graphql.js +++ b/app/assets/javascripts/design_management/graphql.js @@ -88,7 +88,6 @@ const defaultClient = createDefaultClient( fragmentMatcher, }, typeDefs, - assumeImmutableResults: true, }, ); diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index 5092c30aa60..42d5d8fb359 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -4,7 +4,7 @@ import VueDraggable from 'vuedraggable'; import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql'; import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql'; import createFlash, { FLASH_TYPES } from '~/flash'; -import { getFilename } from '~/lib/utils/file_upload'; +import { getFilename, validateImageName } from '~/lib/utils/file_upload'; import { __, s__, sprintf } from '~/locale'; import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; import DeleteButton from '../components/delete_button.vue'; @@ -284,12 +284,16 @@ export default { return; } event.preventDefault(); - let filename = getFilename(event); - if (!filename || filename === 'image.png') { - filename = `design_${Date.now()}.png`; - } - const newFile = new File([files[0]], filename); - this.onUploadDesign([newFile]); + const fileList = [...files]; + fileList.forEach((file) => { + let filename = getFilename(file); + filename = validateImageName(file); + if (!filename || filename === 'image.png') { + filename = `design_${Date.now()}.png`; + } + const newFile = new File([file], filename); + this.onUploadDesign([newFile]); + }); } }, toggleOnPasteListener() { diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 465f9836140..f405b82b05b 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -44,6 +44,7 @@ import { TRACKING_MULTIPLE_FILES_MODE, } from '../constants'; +import { discussionIntersectionObserverHandlerFactory } from '../utils/discussions'; import diffsEventHub from '../event_hub'; import { reviewStatuses } from '../utils/file_reviews'; import { diffsApp } from '../utils/performance'; @@ -86,6 +87,9 @@ export default { ALERT_MERGE_CONFLICT, ALERT_COLLAPSED_FILES, }, + provide: { + discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), + }, props: { endpoint: { type: String, @@ -392,8 +396,6 @@ export default { diffsApp.instrument(); }, created() { - this.mergeRequestContainers = document.querySelectorAll('.merge-request-container'); - this.adjustView(); this.subscribeToEvents(); @@ -521,13 +523,6 @@ export default { } else { this.removeEventListeners(); } - - if (!this.isFluidLayout && this.glFeatures.mrChangesFluidLayout) { - this.mergeRequestContainers.forEach((el) => { - el.classList.toggle('limit-container-width', !this.shouldShow); - el.classList.toggle('container-limited', !this.shouldShow); - }); - } }, setEventListeners() { Mousetrap.bind(keysFor(MR_PREVIOUS_FILE_IN_DIFF), () => this.jumpToFile(-1)); @@ -579,7 +574,7 @@ export default { jumpToFile(step) { const targetIndex = this.currentDiffIndex + step; if (targetIndex >= 0 && targetIndex < this.diffFiles.length) { - this.scrollToFile(this.diffFiles[targetIndex].file_path); + this.scrollToFile({ path: this.diffFiles[targetIndex].file_path }); } }, setTreeDisplay() { diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 4435a533591..e54fde72847 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -1,5 +1,5 @@ <script> -import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { GlButtonGroup, GlButton, GlTooltipDirective, GlSafeHtmlDirective } from '@gitlab/ui'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; @@ -34,6 +34,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, + SafeHtml: GlSafeHtmlDirective, }, mixins: [glFeatureFlagsMixin()], props: { @@ -88,6 +89,9 @@ export default { initUserPopovers(this.$el.querySelectorAll('.js-user-link')); }); }, + safeHtmlConfig: { + ADD_TAGS: ['gl-emoji'], + }, }; </script> @@ -101,7 +105,7 @@ export default { > <div v-if="commit.signature_html" - v-html="commit.signature_html /* eslint-disable-line vue/no-v-html */" + v-safe-html:[$options.safeHtmlConfig]="commit.signature_html" ></div> <commit-pipeline-status v-if="commit.pipeline_status_path" @@ -142,9 +146,9 @@ export default { <div class="commit-detail flex-list"> <div class="commit-content" data-qa-selector="commit_content"> <a + v-safe-html:[$options.safeHtmlConfig]="commit.title_html" :href="commit.commit_url" class="commit-row-message item-title" - v-html="commit.title_html /* eslint-disable-line vue/no-v-html */" ></a> <span class="commit-row-message d-block d-sm-none">· {{ commit.short_id }}</span> @@ -174,9 +178,9 @@ export default { <div> <pre v-if="commit.description_html" + v-safe-html:[$options.safeHtmlConfig]="commitDescription" :class="{ 'js-toggle-content': collapsible, 'd-block': !collapsible }" class="commit-row-description gl-mb-3 gl-text-body" - v-html="commitDescription /* eslint-disable-line vue/no-v-html */" ></pre> </div> </li> diff --git a/app/assets/javascripts/diffs/components/diff_comment_cell.vue b/app/assets/javascripts/diffs/components/diff_comment_cell.vue index 4af4b46f94c..a4fae652d02 100644 --- a/app/assets/javascripts/diffs/components/diff_comment_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_comment_cell.vue @@ -29,6 +29,11 @@ export default { required: false, default: false, }, + lineRange: { + type: Object, + required: false, + default: null, + }, linePosition: { type: String, required: false, @@ -59,6 +64,7 @@ export default { <diff-line-note-form :diff-file-hash="diffFileHash" :line="line" + :range="lineRange" :note-target-line="line" :help-page-path="helpPagePath" :line-position="linePosition" diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 4bcb99424db..238f07ac22c 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -14,7 +14,6 @@ import { import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import { IdState } from 'vendor/vue-virtual-scroller'; -import { diffViewerModes } from '~/ide/constants'; import { scrollToElement } from '~/lib/utils/common_utils'; import { truncateSha } from '~/lib/utils/text_utility'; import { __, s__, sprintf } from '~/locale'; @@ -50,7 +49,7 @@ export default { mixins: [glFeatureFlagsMixin(), IdState({ idProp: (vm) => vm.diffFile.file_hash })], i18n: { ...DIFF_FILE_HEADER, - compareButtonLabel: s__('Compare submodule commit revisions'), + compareButtonLabel: __('Compare submodule commit revisions'), }, props: { discussionPath: { @@ -130,7 +129,7 @@ export default { const truncatedOldSha = escape(truncateSha(this.diffFile.submodule_compare.old_sha)); const truncatedNewSha = escape(truncateSha(this.diffFile.submodule_compare.new_sha)); return sprintf( - s__('Compare %{oldCommitId}...%{newCommitId}'), + __('Compare %{oldCommitId}...%{newCommitId}'), { oldCommitId: `<span class="commit-sha">${truncatedOldSha}</span>`, newCommitId: `<span class="commit-sha">${truncatedNewSha}</span>`, @@ -181,7 +180,7 @@ export default { return this.diffFile.renamed_file; }, isModeChanged() { - return this.diffFile.viewer.name === diffViewerModes.mode_changed; + return this.diffFile.mode_changed; }, expandDiffToFullFileTitle() { if (this.diffFile.isShowingFullFile) { @@ -221,7 +220,7 @@ export default { 'toggleFileDiscussions', 'toggleFileDiscussionWrappers', 'toggleFullDiff', - 'toggleActiveFileByHash', + 'setCurrentFileHash', 'reviewFile', 'setFileCollapsedByUser', ]), @@ -244,7 +243,7 @@ export default { scrollToElement(document.querySelector(selector)); window.location.hash = selector; if (!this.viewDiffsFileByFile) { - this.toggleActiveFileByHash(this.diffFile.file_hash); + this.setCurrentFileHash(this.diffFile.file_hash); } } }, diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index c445989f143..9d355c96af1 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -2,6 +2,7 @@ import { mapState, mapGetters, mapActions } from 'vuex'; import { s__ } from '~/locale'; import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import MultilineCommentForm from '../../notes/components/multiline_comment_form.vue'; import { @@ -32,6 +33,11 @@ export default { type: Object, required: true, }, + range: { + type: Object, + required: false, + default: null, + }, linePosition: { type: String, required: false, @@ -49,6 +55,7 @@ export default { }, data() { return { + lines: null, commentLineStart: { line_code: this.line.line_code, type: this.line.type, @@ -116,10 +123,8 @@ export default { return commentLineOptions(lines, this.line, this.line.line_code, side); }, commentLines() { - if (!this.selectedCommentPosition) return []; - const lines = []; - const { start, end } = this.selectedCommentPosition; + const { start, end } = this.lines; const diffLines = this.diffFile[INLINE_DIFF_LINES_KEY]; let isAdding = false; @@ -144,6 +149,13 @@ export default { return lines; }, }, + created() { + if (this.range) { + this.lines = { ...this.range }; + } else if (this.line) { + this.lines = { start: this.line, end: this.line }; + } + }, mounted() { if (this.isLoggedIn) { const keys = [ @@ -166,16 +178,16 @@ export default { 'saveDiffDiscussion', 'setSuggestPopoverDismissed', ]), - handleCancelCommentForm(shouldConfirm, isDirty) { + async handleCancelCommentForm(shouldConfirm, isDirty) { if (shouldConfirm && isDirty) { const msg = s__('Notes|Are you sure you want to cancel creating this comment?'); - // eslint-disable-next-line no-alert - if (!window.confirm(msg)) { + const confirmed = await confirmAction(msg); + + if (!confirmed) { return; } } - this.cancelCommentForm({ lineCode: this.line.line_code, fileHash: this.diffFileHash, @@ -189,6 +201,9 @@ export default { this.handleCancelCommentForm(), ); }, + updateStartLine(line) { + this.lines.start = line; + }, }, }; </script> @@ -199,7 +214,9 @@ export default { <multiline-comment-form v-model="commentLineStart" :line="line" + :line-range="lines" :comment-line-options="commentLineOptions" + @input="updateStartLine" /> </div> <note-form diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index 64ded1ca8ca..55c796182ee 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -6,6 +6,7 @@ import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; import { hide } from '~/tooltips'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { pickDirection } from '../utils/diff_line'; import DiffCommentCell from './diff_comment_cell.vue'; import DiffExpansionCell from './diff_expansion_cell.vue'; import DiffRow from './diff_row.vue'; @@ -106,6 +107,16 @@ export default { }); this.idState.dragStart = null; }, + singleLineComment(code, line) { + const lineDir = pickDirection({ line, code }); + + this.idState.updatedLineRange = { + start: lineDir, + end: lineDir, + }; + + this.showCommentForm({ lineCode: lineDir.line_code, fileHash: this.diffFile.file_hash }); + }, isHighlighted(line) { return isHighlighted( this.highlightedRow, @@ -169,7 +180,7 @@ export default { :index="index" :is-highlighted="isHighlighted(line)" :file-line-coverage="fileLineCoverage" - @showCommentForm="(lineCode) => showCommentForm({ lineCode, fileHash: diffFile.file_hash })" + @showCommentForm="(code) => singleLineComment(code, line)" @setHighlightedRow="setHighlightedRow" @toggleLineDiscussions=" ({ lineCode, expanded }) => @@ -193,6 +204,7 @@ export default { <diff-comment-cell v-if="line.left && (line.left.renderDiscussion || line.left.hasCommentForm)" :line="line.left" + :line-range="idState.updatedLineRange" :diff-file-hash="diffFile.file_hash" :help-page-path="helpPagePath" line-position="left" @@ -206,6 +218,7 @@ export default { <diff-comment-cell v-if="line.right && (line.right.renderDiscussion || line.right.hasCommentForm)" :line="line.right" + :line-range="idState.updatedLineRange" :diff-file-hash="diffFile.file_hash" :line-index="index" :help-page-path="helpPagePath" diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 41d885d3dc1..85e4199d1c1 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -98,7 +98,7 @@ export default { :file-row-component="$options.DiffFileRow" :current-diff-file-id="currentDiffFileId" @toggleTreeOpen="toggleTreeOpen" - @clickFile="scrollToFile" + @clickFile="(path) => scrollToFile({ path })" /> </template> <p v-else class="prepend-top-20 append-bottom-20 text-center"> diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 1b1ab59b2b4..260ebdf2141 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -138,7 +138,7 @@ export default function initDiffsApp(store) { ...mapActions('diffs', ['toggleFileFinder', 'scrollToFile']), openFile(file) { window.mrTabs.tabShown('diffs'); - this.scrollToFile(file.path); + this.scrollToFile({ path: file.path }); }, }, render(createElement) { diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 5c94c6b803b..692cb913a57 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -85,6 +85,12 @@ export const setBaseConfig = ({ commit }, options) => { viewDiffsFileByFile, mrReviews, }); + + Array.from(new Set(Object.values(mrReviews).flat())).forEach((id) => { + const viewedId = id.replace(/^hash:/, ''); + + commit(types.SET_DIFF_FILE_VIEWED, { id: viewedId, seen: true }); + }); }; export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { @@ -127,7 +133,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { } if (!isNoteLink && !state.currentDiffFileId) { - commit(types.VIEW_DIFF_FILE, diff_files[0]?.file_hash); + commit(types.SET_CURRENT_DIFF_FILE, diff_files[0]?.file_hash); } if (isNoteLink) { @@ -143,7 +149,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { !state.diffFiles.some((f) => f.file_hash === state.currentDiffFileId) && !isNoteLink ) { - commit(types.VIEW_DIFF_FILE, state.diffFiles[0].file_hash); + commit(types.SET_CURRENT_DIFF_FILE, state.diffFiles[0].file_hash); } if (state.diffFiles?.length) { @@ -248,7 +254,7 @@ export const fetchCoverageFiles = ({ commit, state }) => { export const setHighlightedRow = ({ commit }, lineCode) => { const fileHash = lineCode.split('_')[0]; commit(types.SET_HIGHLIGHTED_ROW, lineCode); - commit(types.VIEW_DIFF_FILE, fileHash); + commit(types.SET_CURRENT_DIFF_FILE, fileHash); handleLocationHash(); }; @@ -514,23 +520,25 @@ export const toggleTreeOpen = ({ commit }, path) => { commit(types.TOGGLE_FOLDER_OPEN, path); }; -export const toggleActiveFileByHash = ({ commit }, hash) => { - commit(types.VIEW_DIFF_FILE, hash); +export const setCurrentFileHash = ({ commit }, hash) => { + commit(types.SET_CURRENT_DIFF_FILE, hash); }; -export const scrollToFile = ({ state, commit, getters }, path) => { +export const scrollToFile = ({ state, commit, getters }, { path, setHash = true }) => { if (!state.treeEntries[path]) return; const { fileHash } = state.treeEntries[path]; - commit(types.VIEW_DIFF_FILE, fileHash); + commit(types.SET_CURRENT_DIFF_FILE, fileHash); if (getters.isVirtualScrollingEnabled) { eventHub.$emit('scrollToFileHash', fileHash); - setTimeout(() => { - window.history.replaceState(null, null, `#${fileHash}`); - }); + if (setHash) { + setTimeout(() => { + window.history.replaceState(null, null, `#${fileHash}`); + }); + } } else { document.location.hash = fileHash; @@ -804,7 +812,7 @@ export const setCurrentDiffFileIdFromNote = ({ commit, state, rootGetters }, not const fileHash = rootGetters.getDiscussion(note.discussion_id).diff_file?.file_hash; if (fileHash && state.diffFiles.some((f) => f.file_hash === fileHash)) { - commit(types.VIEW_DIFF_FILE, fileHash); + commit(types.SET_CURRENT_DIFF_FILE, fileHash); } }; @@ -812,7 +820,7 @@ export const navigateToDiffFileIndex = ({ commit, state }, index) => { const fileHash = state.diffFiles[index].file_hash; document.location.hash = fileHash; - commit(types.VIEW_DIFF_FILE, fileHash); + commit(types.SET_CURRENT_DIFF_FILE, fileHash); }; export const setFileByFile = ({ state, commit }, { fileByFile }) => { @@ -848,6 +856,8 @@ export function reviewFile({ commit, state }, { file, reviewed = true }) { const reviews = markFileReview(state.mrReviews, file, reviewed); setReviewsForMergeRequest(mrPath, reviews); + + commit(types.SET_DIFF_FILE_VIEWED, { id: file.file_hash, seen: reviewed }); commit(types.SET_MR_FILE_REVIEWS, reviews); } diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 60836f747f5..51c21c1bfc4 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -20,7 +20,8 @@ export const SET_LINE_DISCUSSIONS_FOR_FILE = 'SET_LINE_DISCUSSIONS_FOR_FILE'; export const REMOVE_LINE_DISCUSSIONS_FOR_FILE = 'REMOVE_LINE_DISCUSSIONS_FOR_FILE'; export const TOGGLE_FOLDER_OPEN = 'TOGGLE_FOLDER_OPEN'; export const SET_SHOW_TREE_LIST = 'SET_SHOW_TREE_LIST'; -export const VIEW_DIFF_FILE = 'VIEW_DIFF_FILE'; +export const SET_CURRENT_DIFF_FILE = 'SET_CURRENT_DIFF_FILE'; +export const SET_DIFF_FILE_VIEWED = 'SET_DIFF_FILE_VIEWED'; export const OPEN_DIFF_FILE_COMMENT_FORM = 'OPEN_DIFF_FILE_COMMENT_FORM'; export const UPDATE_DIFF_FILE_COMMENT_FORM = 'UPDATE_DIFF_FILE_COMMENT_FORM'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 6bc927b9d1f..4a9df0eafcc 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -254,9 +254,11 @@ export default { [types.SET_SHOW_TREE_LIST](state, showTreeList) { state.showTreeList = showTreeList; }, - [types.VIEW_DIFF_FILE](state, fileId) { + [types.SET_CURRENT_DIFF_FILE](state, fileId) { state.currentDiffFileId = fileId; - Vue.set(state.viewedDiffFileIds, fileId, true); + }, + [types.SET_DIFF_FILE_VIEWED](state, { id, seen }) { + Vue.set(state.viewedDiffFileIds, id, seen); }, [types.OPEN_DIFF_FILE_COMMENT_FORM](state, formData) { state.commentForms.push({ diff --git a/app/assets/javascripts/diffs/utils/diff_line.js b/app/assets/javascripts/diffs/utils/diff_line.js new file mode 100644 index 00000000000..a248cc6318b --- /dev/null +++ b/app/assets/javascripts/diffs/utils/diff_line.js @@ -0,0 +1,10 @@ +export function pickDirection({ line, code } = {}) { + const { left, right } = line; + let direction = left || right; + + if (right?.line_code === code) { + direction = right; + } + + return direction; +} diff --git a/app/assets/javascripts/diffs/utils/discussions.js b/app/assets/javascripts/diffs/utils/discussions.js new file mode 100644 index 00000000000..c404705d209 --- /dev/null +++ b/app/assets/javascripts/diffs/utils/discussions.js @@ -0,0 +1,76 @@ +function normalize(processable) { + const { entry } = processable; + const offset = entry.rootBounds.bottom - entry.boundingClientRect.top; + const direction = + offset < 0 ? 'Up' : 'Down'; /* eslint-disable-line @gitlab/require-i18n-strings */ + + return { + ...processable, + entry: { + time: entry.time, + type: entry.isIntersecting ? 'intersection' : `scroll${direction}`, + }, + }; +} + +function sort({ entry: alpha }, { entry: beta }) { + const diff = alpha.time - beta.time; + let order = 0; + + if (diff < 0) { + order = -1; + } else if (diff > 0) { + order = 1; + } else if (alpha.type === 'intersection' && beta.type === 'scrollUp') { + order = 2; + } else if (alpha.type === 'scrollUp' && beta.type === 'intersection') { + order = -2; + } + + return order; +} + +function filter(entry) { + return entry.type !== 'scrollDown'; +} + +export function discussionIntersectionObserverHandlerFactory() { + let unprocessed = []; + let timer = null; + + return (processable) => { + unprocessed.push(processable); + + if (timer) { + clearTimeout(timer); + } + + timer = setTimeout(() => { + unprocessed + .map(normalize) + .filter(filter) + .sort(sort) + .forEach((discussionObservationContainer) => { + const { + entry: { type }, + currentDiscussion, + isFirstUnresolved, + isDiffsPage, + functions: { setCurrentDiscussionId, getPreviousUnresolvedDiscussionId }, + } = discussionObservationContainer; + + if (type === 'intersection') { + setCurrentDiscussionId(currentDiscussion.id); + } else if (type === 'scrollUp') { + setCurrentDiscussionId( + isFirstUnresolved + ? null + : getPreviousUnresolvedDiscussionId(currentDiscussion.id, isDiffsPage), + ); + } + }); + + unprocessed = []; + }, 0); + }; +} diff --git a/app/assets/javascripts/diffs/utils/file_reviews.js b/app/assets/javascripts/diffs/utils/file_reviews.js index 7a4b1aa6b17..227be4e4a6c 100644 --- a/app/assets/javascripts/diffs/utils/file_reviews.js +++ b/app/assets/javascripts/diffs/utils/file_reviews.js @@ -52,8 +52,10 @@ export function markFileReview(reviews, file, reviewed = true) { if (reviewed) { fileReviews.add(file.id); + fileReviews.add(`hash:${file.file_hash}`); } else { fileReviews.delete(file.id); + fileReviews.delete(`hash:${file.file_hash}`); } updatedReviews[file.file_identifier_hash] = Array.from(fileReviews); diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index f98f63529fc..f404fa4e0e8 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -43,7 +43,6 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { let pasteText; let addFileToForm; let updateAttachingMessage; - let isImage; let uploadFile; formTextarea.wrap('<div class="div-dropzone"></div>'); @@ -173,7 +172,7 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { return dropzoneInstance.addFile(file); }); }); - // eslint-disable-next-line consistent-return + handlePaste = (event) => { const pasteEvent = event.originalEvent; const { clipboardData } = pasteEvent; @@ -186,32 +185,22 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { const text = converter.convertToTableMarkdown(); pasteText(text); } else { - const image = isImage(pasteEvent); - - if (image) { - event.preventDefault(); - const MAX_FILE_NAME_LENGTH = 246; - const filename = getFilename(pasteEvent) || 'image.png'; - const truncateFilename = truncate(filename, MAX_FILE_NAME_LENGTH); - const text = `{{${truncateFilename}}}`; - pasteText(text); - - return uploadFile(image.getAsFile(), truncateFilename); - } - } - } - }; - - isImage = (data) => { - let i = 0; - while (i < data.clipboardData.items.length) { - const item = data.clipboardData.items[i]; - if (item.type.indexOf('image') !== -1) { - return item; + const fileList = [...clipboardData.files]; + fileList.forEach((file) => { + if (file.type.indexOf('image') !== -1) { + event.preventDefault(); + const MAX_FILE_NAME_LENGTH = 246; + + const filename = getFilename(file) || 'image.png'; + const truncateFilename = truncate(filename, MAX_FILE_NAME_LENGTH); + const text = `{{${truncateFilename}}}`; + pasteText(text); + + uploadFile(file, truncateFilename); + } + }); } - i += 1; } - return false; }; pasteText = (text, shouldPad) => { diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index d40d19000fb..e855e304d27 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -1,17 +1,9 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { __ } from '~/locale'; - -export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = __( - '"el" parameter is required for createInstance()', -); +import { s__ } from '~/locale'; export const URI_PREFIX = 'gitlab'; export const CONTENT_UPDATE_DEBOUNCE = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; -export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = __( - 'Source Editor instance is required to set up an extension.', -); - export const EDITOR_READY_EVENT = 'editor-ready'; export const EDITOR_TYPE_CODE = 'vs.editor.ICodeEditor'; @@ -20,6 +12,32 @@ export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor'; export const EDITOR_CODE_INSTANCE_FN = 'createInstance'; export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance'; +export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = s__( + 'SourceEditor|"el" parameter is required for createInstance()', +); +export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = s__( + 'SourceEditor|Source Editor instance is required to set up an extension.', +); +export const EDITOR_EXTENSION_DEFINITION_ERROR = s__( + 'SourceEditor|Extension definition should be either a class or a function', +); +export const EDITOR_EXTENSION_NO_DEFINITION_ERROR = s__( + 'SourceEditor|`definition` property is expected on the extension.', +); +export const EDITOR_EXTENSION_DEFINITION_TYPE_ERROR = s__( + 'SourceEditor|Extension definition should be either class, function, or an Array of definitions.', +); +export const EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR = s__( + 'SourceEditor|No extension for unuse has been specified.', +); +export const EDITOR_EXTENSION_NOT_REGISTERED_ERROR = s__('SourceEditor|%{name} is not registered.'); +export const EDITOR_EXTENSION_NAMING_CONFLICT_ERROR = s__( + 'SourceEditor|Name conflict for "%{prop}()" method.', +); +export const EDITOR_EXTENSION_STORE_IS_MISSING_ERROR = s__( + 'SourceEditor|Extensions Store is required to check for an extension.', +); + // // EXTENSIONS' CONSTANTS // diff --git a/app/assets/javascripts/editor/extensions/example_source_editor_extension.js b/app/assets/javascripts/editor/extensions/example_source_editor_extension.js new file mode 100644 index 00000000000..119a2aea9eb --- /dev/null +++ b/app/assets/javascripts/editor/extensions/example_source_editor_extension.js @@ -0,0 +1,116 @@ +// THIS IS AN EXAMPLE +// +// This file contains a basic documented example of the Source Editor extensions' +// API for your convenience. You can copy/paste it into your own file +// and adjust as you see fit +// + +export class MyFancyExtension { + /** + * THE LIFE-CYCLE CALLBACKS + */ + + /** + * Is called before the extension gets used by an instance, + * Use `onSetup` to setup Monaco directly: + * actions, keystrokes, update options, etc. + * Is called only once before the extension gets registered + * + * @param { Object } [setupOptions] The setupOptions object + * @param { Object } [instance] The Source Editor instance + */ + // eslint-disable-next-line class-methods-use-this,no-unused-vars + onSetup(setupOptions, instance) {} + + /** + * The first thing called after the extension is + * registered and used by an instance. + * Is called every time the extension is applied + * + * @param { Object } [instance] The Source Editor instance + */ + // eslint-disable-next-line class-methods-use-this,no-unused-vars + onUse(instance) {} + + /** + * Is called before un-using an extension. Can be used for time-critical + * actions like cleanup, reverting visual changes, and other user-facing + * updates. + * + * @param { Object } [instance] The Source Editor instance + */ + // eslint-disable-next-line class-methods-use-this,no-unused-vars + onBeforeUnuse(instance) {} + + /** + * Is called right after an extension is removed from an instance (un-used) + * Can be used for non time-critical tasks like cleanup on the Monaco level + * (removing actions, keystrokes, etc.). + * onUnuse() will be executed during the browser's idle period + * (https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback) + * + * @param { Object } [instance] The Source Editor instance + */ + // eslint-disable-next-line class-methods-use-this,no-unused-vars + onUnuse(instance) {} + + /** + * The public API of the extension: these are the methods that will be exposed + * to the end user + * @returns {Object} + */ + provides() { + return { + basic: () => { + // The most basic method not depending on anything + // Use: instance.basic(); + // eslint-disable-next-line @gitlab/require-i18n-strings + return 'Foo Bar'; + }, + basicWithProp: () => { + // The methods with access to the props of the extension. + // The props can be either hardcoded (for example in `onSetup`), or + // can be dynamically passed as part of `setupOptions` object when + // using the extension. + // Use: instance.use({ definition: MyFancyExtension, setupOptions: { foo: 'bar' }}); + return this.foo; + }, + basicWithPropsAsList: (prop1, prop2) => { + // Just a simple method with local props + // The props are passed as usually. + // Use: instance.basicWithPropsAsList(prop1, prop2); + // eslint-disable-next-line @gitlab/require-i18n-strings + return `The prop1 is ${prop1}; the prop2 is ${prop2}`; + }, + basicWithInstance: (instance) => { + // The method accessing the instance methods: either own or provided + // by previously-registered extensions + // `instance` is always supplied to all methods in provides() as THE LAST + // argument. + // You don't need to explicitly pass instance to this method: + // Use: instance.basicWithInstance(); + // eslint-disable-next-line @gitlab/require-i18n-strings + return `We have access to the whole Instance! ${instance.alpha()}`; + }, + advancedWithInstanceAndProps: ({ author, book } = {}, firstname, lastname, instance) => { + // Advanced method where + // { author, book } — are the props passed as an object + // prop1, prop2 — are the props passed as simple list + // instance — is automatically supplied, no need to pass it to + // the method explicitly + // Use: instance.advancedWithInstanceAndProps( + // { + // author: 'Franz Kafka', + // book: 'The Transformation' + // }, + // 'Franz', + // 'Kafka' + // ); + return ` +The author is ${author}; the book is ${book} +The author's name is ${firstname}; the last name is ${lastname} +We have access to the whole Instance! For example, 'instance.alpha()': ${instance.alpha()}`; + }, + }; + } +} diff --git a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js index 5fa01f03f7e..03c68fed3b1 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js +++ b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js @@ -36,12 +36,24 @@ export class SourceEditorExtension { }); } - static highlightLines(instance) { - const { hash } = window.location; - if (!hash) { - return; - } - const [start, end] = hash.replace(hashRegexp, '').split('-'); + static removeHighlights(instance) { + Object.assign(instance, { + lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], []), + }); + } + + /** + * Returns a function that can only be invoked once between + * each browser screen repaint. + * @param {Object} instance - The Source Editor instance + * @param {Array} bounds - The [start, end] array with start + * and end coordinates for highlighting + */ + static highlightLines(instance, bounds = null) { + const [start, end] = + bounds && Array.isArray(bounds) + ? bounds + : window.location.hash?.replace(hashRegexp, '').split('-'); let startLine = start ? parseInt(start, 10) : null; let endLine = end ? parseInt(end, 10) : startLine; if (endLine < startLine) { @@ -51,15 +63,12 @@ export class SourceEditorExtension { window.requestAnimationFrame(() => { instance.revealLineInCenter(startLine); Object.assign(instance, { - lineDecorations: instance.deltaDecorations( - [], - [ - { - range: new Range(startLine, 1, endLine, 1), - options: { isWholeLine: true, className: 'active-line-text' }, - }, - ], - ), + lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], [ + { + range: new Range(startLine, 1, endLine, 1), + options: { isWholeLine: true, className: 'active-line-text' }, + }, + ]), }); }); } diff --git a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js new file mode 100644 index 00000000000..212e09c8724 --- /dev/null +++ b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js @@ -0,0 +1,293 @@ +import { toPath } from 'lodash'; +import { parseDocument, Document, visit, isScalar, isCollection, isMap } from 'yaml'; +import { findPair } from 'yaml/util'; +import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; + +export class YamlEditorExtension extends SourceEditorExtension { + /** + * Extends the source editor with capabilities for yaml files. + * + * @param { Instance } instance Source Editor Instance + * @param { boolean } enableComments Convert model nodes with the comment + * pattern to comments? + * @param { string } highlightPath Add a line highlight to the + * node specified by this e.g. `"foo.bar[0]"` + * @param { * } model Any JS Object that will be stringified and used as the + * editor's value. Equivalent to using `setDataModel()` + * @param options SourceEditorExtension Options + */ + constructor({ + instance, + enableComments = false, + highlightPath = null, + model = null, + ...options + } = {}) { + super({ + instance, + options: { + ...options, + enableComments, + highlightPath, + }, + }); + + if (model) { + YamlEditorExtension.initFromModel(instance, model); + } + + instance.onDidChangeModelContent(() => instance.onUpdate()); + } + + /** + * @private + */ + static initFromModel(instance, model) { + const doc = new Document(model); + if (instance.options.enableComments) { + YamlEditorExtension.transformComments(doc); + } + instance.setValue(doc.toString()); + } + + /** + * @private + * This wraps long comments to a maximum line length of 80 chars. + * + * The `yaml` package does not currently wrap comments. This function + * is a local workaround and should be deprecated if + * https://github.com/eemeli/yaml/issues/322 + * is resolved. + */ + static wrapCommentString(string, level = 0) { + if (!string) { + return null; + } + if (level < 0 || Number.isNaN(parseInt(level, 10))) { + throw Error(`Invalid value "${level}" for variable \`level\``); + } + const maxLineWidth = 80; + const indentWidth = 2; + const commentMarkerWidth = '# '.length; + const maxLength = maxLineWidth - commentMarkerWidth - level * indentWidth; + const lines = [[]]; + string.split(' ').forEach((word) => { + const currentLine = lines.length - 1; + if ([...lines[currentLine], word].join(' ').length <= maxLength) { + lines[currentLine].push(word); + } else { + lines.push([word]); + } + }); + return lines.map((line) => ` ${line.join(' ')}`).join('\n'); + } + + /** + * @private + * + * This utilizes `yaml`'s `visit` function to transform nodes with a + * comment key pattern to actual comments. + * + * In Objects, a key of '#' will be converted to a comment at the top of a + * property. Any key following the pattern `#|<some key>` will be placed + * right before `<some key>`. + * + * In Arrays, any string that starts with # (including the space), will + * be converted to a comment at the position it was in. + * + * @param { Document } doc + * @returns { Document } + */ + static transformComments(doc) { + const getLevel = (path) => { + const { length } = path.filter((x) => isCollection(x)); + return length ? length - 1 : 0; + }; + + visit(doc, { + Pair(_, pair, path) { + const key = pair.key.value; + // If the key is = '#', we add the value as a comment to the parent + // We can then remove the node. + if (key === '#') { + Object.assign(path[path.length - 1], { + commentBefore: YamlEditorExtension.wrapCommentString(pair.value.value, getLevel(path)), + }); + return visit.REMOVE; + } + // If the key starts with `#|`, we want to add a comment to the + // corresponding property. We can then remove the node. + if (key.startsWith('#|')) { + const targetProperty = key.split('|')[1]; + const target = findPair(path[path.length - 1].items, targetProperty); + if (target) { + target.key.commentBefore = YamlEditorExtension.wrapCommentString( + pair.value.value, + getLevel(path), + ); + } + return visit.REMOVE; + } + return undefined; // If the node is not a comment, do nothing with it + }, + // Sequence is basically an array + Seq(_, node, path) { + let comment = null; + const items = node.items.flatMap((child) => { + if (comment) { + Object.assign(child, { commentBefore: comment }); + comment = null; + } + if ( + isScalar(child) && + child.value && + child.value.startsWith && + child.value.startsWith('#') + ) { + const commentValue = child.value.replace(/^#\s?/, ''); + comment = YamlEditorExtension.wrapCommentString(commentValue, getLevel(path)); + return []; + } + return child; + }); + Object.assign(node, { items }); + // Adding a comment in case the last one is a comment + if (comment) { + Object.assign(node, { comment }); + } + }, + }); + return doc; + } + + /** + * Get the editor's value parsed as a `Document` as defined by the `yaml` + * package + * @returns {Document} + */ + getDoc() { + return parseDocument(this.getValue()); + } + + /** + * Accepts a `Document` as defined by the `yaml` package and + * sets the Editor's value to a stringified version of it. + * @param { Document } doc + */ + setDoc(doc) { + if (this.options.enableComments) { + YamlEditorExtension.transformComments(doc); + } + + if (!this.getValue()) { + this.setValue(doc.toString()); + } else { + this.updateValue(doc.toString()); + } + } + + /** + * Returns the parsed value of the Editor's content as JS. + * @returns {*} + */ + getDataModel() { + return this.getDoc().toJS(); + } + + /** + * Accepts any JS Object and sets the Editor's value to a stringified version + * of that value. + * + * @param value + */ + setDataModel(value) { + this.setDoc(new Document(value)); + } + + /** + * Method to be executed when the Editor's <TextModel> was updated + */ + onUpdate() { + if (this.options.highlightPath) { + this.highlight(this.options.highlightPath); + } + } + + /** + * Set the editors content to the input without recreating the content model. + * + * @param blob + */ + updateValue(blob) { + // Using applyEdits() instead of setValue() ensures that tokens such as + // highlighted lines aren't deleted/recreated which causes a flicker. + const model = this.getModel(); + model.applyEdits([ + { + // A nice improvement would be to replace getFullModelRange() with + // a range of the actual diff, avoiding re-formatting the document, + // but that's something for a later iteration. + range: model.getFullModelRange(), + text: blob, + }, + ]); + } + + /** + * Add a line highlight style to the node specified by the path. + * + * @param {string|null|false} path A path to a node of the Editor's value, + * e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all + * highlights. + */ + highlight(path) { + if (this.options.highlightPath === path) return; + if (!path) { + SourceEditorExtension.removeHighlights(this); + } else { + const res = this.locate(path); + SourceEditorExtension.highlightLines(this, res); + } + this.options.highlightPath = path || null; + } + + /** + * Return the line numbers of a certain node identified by `path` within + * the yaml. + * + * @param {string} path A path to a node, eg. `foo.bar[0]` + * @returns {number[]} Array following the schema `[firstLine, lastLine]` + * (both inclusive) + * + * @throws {Error} Will throw if the path is not found inside the document + */ + locate(path) { + if (!path) throw Error(`No path provided.`); + const blob = this.getValue(); + const doc = parseDocument(blob); + const pathArray = toPath(path); + + if (!doc.getIn(pathArray)) { + throw Error(`The node ${path} could not be found inside the document.`); + } + + const parentNode = doc.getIn(pathArray.slice(0, pathArray.length - 1)); + let startChar; + let endChar; + if (isMap(parentNode)) { + const node = parentNode.items.find( + (item) => item.key.value === pathArray[pathArray.length - 1], + ); + [startChar] = node.key.range; + [, , endChar] = node.value.range; + } else { + const node = doc.getIn(pathArray); + [startChar, , endChar] = node.range; + } + const startSlice = blob.slice(0, startChar); + const endSlice = blob.slice(0, endChar); + const startLine = (startSlice.match(/\n/g) || []).length + 1; + const endLine = (endSlice.match(/\n/g) || []).length; + return [startLine, endLine]; + } +} diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 0052bc00406..f0db3e5594b 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -63,9 +63,9 @@ "items": { "type": "object", "properties": { - "if": { - "type": "string" - }, + "if": { "$ref": "#/definitions/if" }, + "changes": { "$ref": "#/definitions/changes" }, + "exists": { "$ref": "#/definitions/exists" }, "variables": { "$ref": "#/definitions/variables" }, "when": { "type": "string", @@ -497,24 +497,9 @@ "type": "object", "additionalProperties": false, "properties": { - "if": { - "type": "string", - "description": "Expression to evaluate whether additional attributes should be provided to the job" - }, - "changes": { - "type": "array", - "description": "Additional attributes will be provided to job if any of the provided paths matches a modified file", - "items": { - "type": "string" - } - }, - "exists": { - "type": "array", - "description": "Additional attributes will be provided to job if any of the provided paths matches an existing file in the repository", - "items": { - "type": "string" - } - }, + "if": { "$ref": "#/definitions/if" }, + "changes": { "$ref": "#/definitions/changes" }, + "exists": { "$ref": "#/definitions/exists" }, "variables": { "$ref": "#/definitions/variables" }, "when": { "$ref": "#/definitions/when" }, "start_in": { "$ref": "#/definitions/start_in" }, @@ -541,6 +526,24 @@ ] } }, + "if": { + "type": "string", + "description": "Expression to evaluate whether additional attributes should be provided to the job" + }, + "changes": { + "type": "array", + "description": "Additional attributes will be provided to job if any of the provided paths matches a modified file", + "items": { + "type": "string" + } + }, + "exists": { + "type": "array", + "description": "Additional attributes will be provided to job if any of the provided paths matches an existing file in the repository", + "items": { + "type": "string" + } + }, "variables": { "type": "object", "description": "Defines environment variables for specific jobs. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off.", @@ -555,7 +558,7 @@ }, "start_in": { "type": "string", - "description": "Used in conjunction with 'when: delayed' to set how long to delay before starting a job.", + "description": "Used in conjunction with 'when: delayed' to set how long to delay before starting a job. e.g. '5', 5 seconds, 30 minutes, 1 week, etc. Read more: https://docs.gitlab.com/ee/ci/jobs/job_control.html#run-a-job-after-a-delay", "minLength": 1 }, "allow_failure": { @@ -939,7 +942,7 @@ "stage": { "type": "string", "description": "Define what stage the job will run in.", - "default": "test" + "minLength": 1 }, "only": { "$ref": "#/definitions/filter", diff --git a/app/assets/javascripts/editor/source_editor_extension.js b/app/assets/javascripts/editor/source_editor_extension.js new file mode 100644 index 00000000000..f6bc62a1c09 --- /dev/null +++ b/app/assets/javascripts/editor/source_editor_extension.js @@ -0,0 +1,17 @@ +import { EDITOR_EXTENSION_DEFINITION_ERROR } from './constants'; + +export default class EditorExtension { + constructor({ definition, setupOptions } = {}) { + if (typeof definition !== 'function') { + throw new Error(EDITOR_EXTENSION_DEFINITION_ERROR); + } + this.name = definition.name; // both class- and fn-based extensions have a name + this.setupOptions = setupOptions; + // eslint-disable-next-line new-cap + this.obj = new definition(); + } + + get api() { + return this.obj.provides?.(); + } +} diff --git a/app/assets/javascripts/editor/source_editor_instance.js b/app/assets/javascripts/editor/source_editor_instance.js new file mode 100644 index 00000000000..e0ca4ea518b --- /dev/null +++ b/app/assets/javascripts/editor/source_editor_instance.js @@ -0,0 +1,271 @@ +/** + * @module source_editor_instance + */ + +/** + * A Source Editor Extension definition + * @typedef {Object} SourceEditorExtensionDefinition + * @property {Object} definition + * @property {Object} setupOptions + */ + +/** + * A Source Editor Extension + * @typedef {Object} SourceEditorExtension + * @property {Object} obj + * @property {string} name + * @property {Object} api + */ + +import { isEqual } from 'lodash'; +import { editor as monacoEditor } from 'monaco-editor'; +import { getBlobLanguage } from '~/editor/utils'; +import { logError } from '~/lib/logger'; +import { sprintf } from '~/locale'; +import EditorExtension from './source_editor_extension'; +import { + EDITOR_EXTENSION_DEFINITION_TYPE_ERROR, + EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, + EDITOR_EXTENSION_NO_DEFINITION_ERROR, + EDITOR_EXTENSION_NOT_REGISTERED_ERROR, + EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR, + EDITOR_EXTENSION_STORE_IS_MISSING_ERROR, +} from './constants'; + +const utils = { + removeExtFromMethod: (method, extensionName, container) => { + if (!container) { + return; + } + if (Object.prototype.hasOwnProperty.call(container, method)) { + // eslint-disable-next-line no-param-reassign + delete container[method]; + } + }, + + getStoredExtension: (extensionsStore, name) => { + if (!extensionsStore) { + logError(EDITOR_EXTENSION_STORE_IS_MISSING_ERROR); + return undefined; + } + return extensionsStore.get(name); + }, +}; + +/** Class representing a Source Editor Instance */ +export default class EditorInstance { + /** + * Create a Source Editor Instance + * @param {Object} rootInstance - Monaco instance to build on top of + * @param {Map} extensionsStore - The global registry for the extension instances + * @returns {Object} - A Proxy returning props/methods from either registered extensions, or Source Editor instance, or underlying Monaco instance + */ + constructor(rootInstance = {}, extensionsStore = new Map()) { + /** The methods provided by extensions. */ + this.methods = {}; + + const seInstance = this; + const getHandler = { + get(target, prop, receiver) { + const methodExtension = + Object.prototype.hasOwnProperty.call(seInstance.methods, prop) && + seInstance.methods[prop]; + if (methodExtension) { + const extension = extensionsStore.get(methodExtension); + + return (...args) => { + return extension.api[prop].call(seInstance, ...args, receiver); + }; + } + return Reflect.get(seInstance[prop] ? seInstance : target, prop, receiver); + }, + set(target, prop, value) { + Object.assign(seInstance, { + [prop]: value, + }); + return true; + }, + }; + const instProxy = new Proxy(rootInstance, getHandler); + + /** + * Main entry point to apply an extension to the instance + * @param {SourceEditorExtensionDefinition} + */ + this.use = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.useExtension); + + /** + * Main entry point to un-use an extension and remove it from the instance + * @param {SourceEditorExtension} + */ + this.unuse = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.unuseExtension); + + return instProxy; + } + + /** + * A private dispatcher function for both `use` and `unuse` + * @param {Map} extensionsStore - The global registry for the extension instances + * @param {Function} fn - A function to route to. Either `this.useExtension` or `this.unuseExtension` + * @param {SourceEditorExtensionDefinition[]} extensions - The extensions to use/unuse. + * @returns {Function} + */ + static useUnuse(extensionsStore, fn, extensions) { + if (Array.isArray(extensions)) { + /** + * We cut short if the Array is empty and let the destination function to throw + * Otherwise, we run the destination function on every entry of the Array + */ + return extensions.length + ? extensions.map(fn.bind(this, extensionsStore)) + : fn.call(this, extensionsStore); + } + return fn.call(this, extensionsStore, extensions); + } + + // + // REGISTERING NEW EXTENSION + // + + /** + * Run all registrations when using an extension + * @param {Map} extensionsStore - The global registry for the extension instances + * @param {SourceEditorExtensionDefinition} extension - The extension definition to use. + * @returns {EditorExtension|*} + */ + useExtension(extensionsStore, extension = {}) { + const { definition } = extension; + if (!definition) { + throw new Error(EDITOR_EXTENSION_NO_DEFINITION_ERROR); + } + if (typeof definition !== 'function') { + throw new Error(EDITOR_EXTENSION_DEFINITION_TYPE_ERROR); + } + + // Existing Extension Path + const existingExt = utils.getStoredExtension(extensionsStore, definition.name); + if (existingExt) { + if (isEqual(extension.setupOptions, existingExt.setupOptions)) { + return existingExt; + } + this.unuseExtension(extensionsStore, existingExt); + } + + // New Extension Path + const extensionInstance = new EditorExtension(extension); + const { setupOptions, obj: extensionObj } = extensionInstance; + if (extensionObj.onSetup) { + extensionObj.onSetup(setupOptions, this); + } + if (extensionsStore) { + this.registerExtension(extensionInstance, extensionsStore); + } + this.registerExtensionMethods(extensionInstance); + return extensionInstance; + } + + /** + * Register extension in the global extensions store + * @param {SourceEditorExtension} extension - Instance of Source Editor extension + * @param {Map} extensionsStore - The global registry for the extension instances + */ + registerExtension(extension, extensionsStore) { + const { name } = extension; + const hasExtensionRegistered = + extensionsStore.has(name) && + isEqual(extension.setupOptions, extensionsStore.get(name).setupOptions); + if (hasExtensionRegistered) { + return; + } + extensionsStore.set(name, extension); + const { obj: extensionObj } = extension; + if (extensionObj.onUse) { + extensionObj.onUse(this); + } + } + + /** + * Register extension methods in the registry on the instance + * @param {SourceEditorExtension} extension - Instance of Source Editor extension + */ + registerExtensionMethods(extension) { + const { api, name } = extension; + + if (!api) { + return; + } + + Object.keys(api).forEach((prop) => { + if (this[prop]) { + logError(sprintf(EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, { prop })); + } else { + this.methods[prop] = name; + } + }, this); + } + + // + // UNREGISTERING AN EXTENSION + // + + /** + * Unregister extension with the cleanup + * @param {Map} extensionsStore - The global registry for the extension instances + * @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use + */ + unuseExtension(extensionsStore, extension) { + if (!extension) { + throw new Error(EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR); + } + const { name } = extension; + const existingExt = utils.getStoredExtension(extensionsStore, name); + if (!existingExt) { + throw new Error(sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { name })); + } + const { obj: extensionObj } = existingExt; + if (extensionObj.onBeforeUnuse) { + extensionObj.onBeforeUnuse(this); + } + this.unregisterExtensionMethods(existingExt); + if (extensionObj.onUnuse) { + extensionObj.onUnuse(this); + } + } + + /** + * Remove all methods associated with this extension from the registry on the instance + * @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use + */ + unregisterExtensionMethods(extension) { + const { api, name } = extension; + if (!api) { + return; + } + Object.keys(api).forEach((method) => { + utils.removeExtFromMethod(method, name, this.methods); + }); + } + + /** + * PUBLIC API OF AN INSTANCE + */ + + /** + * Updates model language based on the path + * @param {String} path - blob path + */ + updateModelLanguage(path) { + const lang = getBlobLanguage(path); + const model = this.getModel(); + // return monacoEditor.setModelLanguage(model, lang); + monacoEditor.setModelLanguage(model, lang); + } + + /** + * Get the methods returned by extensions. + * @returns {Array} + */ + get extensionsAPI() { + return Object.keys(this.methods); + } +} diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index 7672151af2a..478e3f6aed9 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -9,6 +9,7 @@ let emojiMap = null; let validEmojiNames = null; export const FALLBACK_EMOJI_KEY = 'grey_question'; +// Keep the version in sync with `lib/gitlab/emoji.rb` export const EMOJI_VERSION = '1'; const isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage(); diff --git a/app/assets/javascripts/environments/components/delete_environment_modal.vue b/app/assets/javascripts/environments/components/delete_environment_modal.vue index 2eb2be351b3..26ec882472b 100644 --- a/app/assets/javascripts/environments/components/delete_environment_modal.vue +++ b/app/assets/javascripts/environments/components/delete_environment_modal.vue @@ -1,6 +1,6 @@ <script> import { GlTooltipDirective, GlModal } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import eventHub from '../event_hub'; export default { @@ -27,7 +27,7 @@ export default { }, cancelProps() { return { - text: s__('Cancel'), + text: __('Cancel'), }; }, confirmDeleteMessage() { diff --git a/app/assets/javascripts/environments/components/new_environment_folder.vue b/app/assets/javascripts/environments/components/new_environment_folder.vue new file mode 100644 index 00000000000..0615bdef537 --- /dev/null +++ b/app/assets/javascripts/environments/components/new_environment_folder.vue @@ -0,0 +1,69 @@ +<script> +import { GlCollapse, GlIcon, GlBadge, GlLink } from '@gitlab/ui'; +import folderQuery from '../graphql/queries/folder.query.graphql'; + +export default { + components: { + GlCollapse, + GlIcon, + GlBadge, + GlLink, + }, + props: { + nestedEnvironment: { + type: Object, + required: true, + }, + }, + data() { + return { visible: false }; + }, + apollo: { + folder: { + query: folderQuery, + variables() { + return { environment: this.nestedEnvironment.latest }; + }, + }, + }, + computed: { + icons() { + return this.visible + ? { caret: 'angle-down', folder: 'folder-open' } + : { caret: 'angle-right', folder: 'folder-o' }; + }, + count() { + return this.folder?.availableCount ?? 0; + }, + folderClass() { + return { 'gl-font-weight-bold': this.visible }; + }, + folderPath() { + return this.nestedEnvironment.latest.folderPath; + }, + }, + methods: { + toggleCollapse() { + this.visible = !this.visible; + }, + }, +}; +</script> +<template> + <div class="gl-border-b-solid gl-border-gray-100 gl-border-1 gl-px-3 gl-pt-3 gl-pb-5"> + <div class="gl-w-full gl-display-flex gl-align-items-center" @click="toggleCollapse"> + <gl-icon + class="gl-mr-2 gl-fill-current-color gl-text-gray-500" + :name="icons.caret" + :size="12" + /> + <gl-icon class="gl-mr-2 gl-fill-current-color gl-text-gray-500" :name="icons.folder" /> + <div class="gl-mr-2 gl-text-gray-500" :class="folderClass"> + {{ nestedEnvironment.name }} + </div> + <gl-badge size="sm" class="gl-mr-auto">{{ count }}</gl-badge> + <gl-link v-if="visible" :href="folderPath">{{ s__('Environments|Show all') }}</gl-link> + </div> + <gl-collapse :visible="visible" /> + </div> +</template> diff --git a/app/assets/javascripts/environments/components/new_environments_app.vue b/app/assets/javascripts/environments/components/new_environments_app.vue new file mode 100644 index 00000000000..a5526f9cd71 --- /dev/null +++ b/app/assets/javascripts/environments/components/new_environments_app.vue @@ -0,0 +1,47 @@ +<script> +import { GlBadge, GlTab, GlTabs } from '@gitlab/ui'; +import environmentAppQuery from '../graphql/queries/environmentApp.query.graphql'; +import EnvironmentFolder from './new_environment_folder.vue'; + +export default { + components: { + EnvironmentFolder, + GlBadge, + GlTab, + GlTabs, + }, + apollo: { + environmentApp: { + query: environmentAppQuery, + }, + }, + computed: { + folders() { + return this.environmentApp?.environments.filter((e) => e.size > 1) ?? []; + }, + availableCount() { + return this.environmentApp?.availableCount; + }, + }, +}; +</script> +<template> + <div> + <gl-tabs> + <gl-tab> + <template #title> + <span>{{ __('Available') }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge"> + {{ availableCount }} + </gl-badge> + </template> + <environment-folder + v-for="folder in folders" + :key="folder.name" + class="gl-mb-3" + :nested-environment="folder" + /> + </gl-tab> + </gl-tabs> + </div> +</template> diff --git a/app/assets/javascripts/environments/components/rollback_modal_manager.vue b/app/assets/javascripts/environments/components/rollback_modal_manager.vue index 6aa7d96fdfd..3a8b9ebcb84 100644 --- a/app/assets/javascripts/environments/components/rollback_modal_manager.vue +++ b/app/assets/javascripts/environments/components/rollback_modal_manager.vue @@ -15,7 +15,6 @@ export default { data() { return { environment: null, - retryPath: '', visible: false, }; }, @@ -35,9 +34,9 @@ export default { name: environmentName, commitShortSha, commitUrl, + retryUrl: retryPath, isLastDeployment: parseBoolean(isLastDeployment), }; - this.retryPath = retryPath; this.visible = true; }); }); @@ -51,7 +50,5 @@ export default { v-model="visible" :environment="environment" :has-multiple-commits="false" - :retry-url="retryPath" /> - <div v-else></div> </template> diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js index f248e9ec079..206381e0b7e 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -8,7 +8,7 @@ Vue.use(Translate); Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), + defaultClient: createDefaultClient(), }); export default () => { diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js new file mode 100644 index 00000000000..c734c2fba0c --- /dev/null +++ b/app/assets/javascripts/environments/graphql/client.js @@ -0,0 +1,25 @@ +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import environmentApp from './queries/environmentApp.query.graphql'; +import { resolvers } from './resolvers'; +import typeDefs from './typedefs.graphql'; + +export const apolloProvider = (endpoint) => { + const defaultClient = createDefaultClient(resolvers(endpoint), { + typeDefs, + }); + const { cache } = defaultClient; + + cache.writeQuery({ + query: environmentApp, + data: { + availableCount: 0, + environments: [], + reviewApp: {}, + stoppedCount: 0, + }, + }); + return new VueApollo({ + defaultClient, + }); +}; diff --git a/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql new file mode 100644 index 00000000000..22dfb8a7a89 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/mutations/cancel_auto_stop.mutation.graphql @@ -0,0 +1,5 @@ +mutation cancelAutoStop($environment: LocalEnvironment) { + cancelAutoStop(environment: $environment) @client { + errors + } +} diff --git a/app/assets/javascripts/environments/graphql/mutations/delete_environment.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/delete_environment.mutation.graphql new file mode 100644 index 00000000000..9bb68857923 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/mutations/delete_environment.mutation.graphql @@ -0,0 +1,5 @@ +mutation deleteEnvironment($environment: LocalEnvironment) { + deleteEnvironment(environment: $environment) @client { + errors + } +} diff --git a/app/assets/javascripts/environments/graphql/mutations/rollback_environment.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/rollback_environment.mutation.graphql new file mode 100644 index 00000000000..3db4dc2b9a5 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/mutations/rollback_environment.mutation.graphql @@ -0,0 +1,5 @@ +mutation rollbackEnvironment($environment: LocalEnvironment) { + rollbackEnvironment(environment: $environment) @client { + errors + } +} diff --git a/app/assets/javascripts/environments/graphql/mutations/stop_environment.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/stop_environment.mutation.graphql new file mode 100644 index 00000000000..7eae0ef4ce4 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/mutations/stop_environment.mutation.graphql @@ -0,0 +1,5 @@ +mutation stopEnvironment($environment: LocalEnvironment) { + stopEnvironment(environment: $environment) @client { + errors + } +} diff --git a/app/assets/javascripts/environments/graphql/mutations/update_canary_ingress.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/update_canary_ingress.mutation.graphql index 04ea5cbcaef..936bf49a1ac 100644 --- a/app/assets/javascripts/environments/graphql/mutations/update_canary_ingress.mutation.graphql +++ b/app/assets/javascripts/environments/graphql/mutations/update_canary_ingress.mutation.graphql @@ -1,4 +1,4 @@ -mutation($input: EnvironmentsCanaryIngressUpdateInput!) { +mutation updateCanaryIngress($input: EnvironmentsCanaryIngressUpdateInput!) { environmentsCanaryIngressUpdate(input: $input) { errors } diff --git a/app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql b/app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql new file mode 100644 index 00000000000..faa76c0a42c --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql @@ -0,0 +1,8 @@ +query getEnvironmentApp { + environmentApp @client { + availableCount + environments + reviewApp + stoppedCount + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/folder.query.graphql b/app/assets/javascripts/environments/graphql/queries/folder.query.graphql new file mode 100644 index 00000000000..3292c916b2e --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/folder.query.graphql @@ -0,0 +1,7 @@ +query getEnvironmentFolder($environment: NestedLocalEnvironment) { + folder(environment: $environment) @client { + availableCount + environments + stoppedCount + } +} diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js new file mode 100644 index 00000000000..8322b806370 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -0,0 +1,50 @@ +import axios from '~/lib/utils/axios_utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +const mapNestedEnvironment = (env) => ({ + ...convertObjectPropsToCamelCase(env, { deep: true }), + __typename: 'NestedLocalEnvironment', +}); +const mapEnvironment = (env) => ({ + ...convertObjectPropsToCamelCase(env), + __typename: 'LocalEnvironment', +}); + +export const resolvers = (endpoint) => ({ + Query: { + environmentApp() { + return axios.get(endpoint, { params: { nested: true } }).then((res) => ({ + availableCount: res.data.available_count, + environments: res.data.environments.map(mapNestedEnvironment), + reviewApp: { + ...convertObjectPropsToCamelCase(res.data.review_app), + __typename: 'ReviewApp', + }, + stoppedCount: res.data.stopped_count, + __typename: 'LocalEnvironmentApp', + })); + }, + folder(_, { environment: { folderPath } }) { + return axios.get(folderPath, { params: { per_page: 3 } }).then((res) => ({ + availableCount: res.data.available_count, + environments: res.data.environments.map(mapEnvironment), + stoppedCount: res.data.stopped_count, + __typename: 'LocalEnvironmentFolder', + })); + }, + }, + Mutations: { + stopEnvironment(_, { environment: { stopPath } }) { + return axios.post(stopPath); + }, + deleteEnvironment(_, { environment: { deletePath } }) { + return axios.delete(deletePath); + }, + rollbackEnvironment(_, { environment: { retryUrl } }) { + return axios.post(retryUrl); + }, + cancelAutoStop(_, { environment: { autoStopPath } }) { + return axios.post(autoStopPath); + }, + }, +}); diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql new file mode 100644 index 00000000000..49ea719449e --- /dev/null +++ b/app/assets/javascripts/environments/graphql/typedefs.graphql @@ -0,0 +1,35 @@ +type LocalEnvironment { + id: Int! + globalId: ID! + name: String! + folderPath: String + stopPath: String + deletePath: String + retryUrl: String + autoStopPath: String +} + +type NestedLocalEnvironment { + name: String! + size: Int! + latest: LocalEnvironment! +} + +type LocalEnvironmentFolder { + environments: [LocalEnvironment!]! + availableCount: Int! + stoppedCount: Int! +} + +type ReviewApp { + canSetupReviewApp: Boolean! + allClustersEmpty: Boolean! + reviewSnippet: String +} + +type LocalEnvironmentApp { + stoppedCount: Int! + availableCount: Int! + environments: [NestedLocalEnvironment!]! + reviewApp: ReviewApp! +} diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index 5e33923d518..3b1d35c1f22 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -9,40 +9,43 @@ Vue.use(Translate); Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), + defaultClient: createDefaultClient(), }); -export default () => { - const el = document.getElementById('environments-list-view'); - return new Vue({ - el, - components: { - environmentsComponent, - }, - apolloProvider, - provide: { - projectPath: el.dataset.projectPath, - defaultBranchName: el.dataset.defaultBranchName, - }, - data() { - const environmentsData = el.dataset; +export default (el) => { + if (el) { + return new Vue({ + el, + components: { + environmentsComponent, + }, + apolloProvider, + provide: { + projectPath: el.dataset.projectPath, + defaultBranchName: el.dataset.defaultBranchName, + }, + data() { + const environmentsData = el.dataset; - return { - endpoint: environmentsData.environmentsDataEndpoint, - newEnvironmentPath: environmentsData.newEnvironmentPath, - helpPagePath: environmentsData.helpPagePath, - canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment), - }; - }, - render(createElement) { - return createElement('environments-component', { - props: { - endpoint: this.endpoint, - newEnvironmentPath: this.newEnvironmentPath, - helpPagePath: this.helpPagePath, - canCreateEnvironment: this.canCreateEnvironment, - }, - }); - }, - }); + return { + endpoint: environmentsData.environmentsDataEndpoint, + newEnvironmentPath: environmentsData.newEnvironmentPath, + helpPagePath: environmentsData.helpPagePath, + canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment), + }; + }, + render(createElement) { + return createElement('environments-component', { + props: { + endpoint: this.endpoint, + newEnvironmentPath: this.newEnvironmentPath, + helpPagePath: this.helpPagePath, + canCreateEnvironment: this.canCreateEnvironment, + }, + }); + }, + }); + } + + return null; }; diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index 85cff73cc3e..0f9741784d6 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -6,7 +6,7 @@ import Visibility from 'visibilityjs'; import createFlash from '~/flash'; import Poll from '../../lib/utils/poll'; import { getParameterByName } from '../../lib/utils/url_utility'; -import { s__ } from '../../locale'; +import { s__, __ } from '../../locale'; import tabs from '../../vue_shared/components/navigation_tabs.vue'; import tablePagination from '../../vue_shared/components/pagination/table_pagination.vue'; import container from '../components/container.vue'; @@ -207,13 +207,13 @@ export default { tabs() { return [ { - name: s__('Available'), + name: __('Available'), scope: 'available', count: this.state.availableCounter, isActive: this.scope === 'available', }, { - name: s__('Stopped'), + name: __('Stopped'), scope: 'stopped', count: this.state.stoppedCounter, isActive: this.scope === 'stopped', diff --git a/app/assets/javascripts/environments/new_index.js b/app/assets/javascripts/environments/new_index.js new file mode 100644 index 00000000000..dd5c709c75a --- /dev/null +++ b/app/assets/javascripts/environments/new_index.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { parseBoolean } from '../lib/utils/common_utils'; +import { apolloProvider } from './graphql/client'; +import EnvironmentsApp from './components/new_environments_app.vue'; + +Vue.use(VueApollo); + +export default (el) => { + if (el) { + const { + canCreateEnvironment, + endpoint, + newEnvironmentPath, + helpPagePath, + projectPath, + defaultBranchName, + } = el.dataset; + + return new Vue({ + el, + apolloProvider: apolloProvider(endpoint), + provide: { + projectPath, + defaultBranchName, + endpoint, + newEnvironmentPath, + helpPagePath, + canCreateEnvironment: parseBoolean(canCreateEnvironment), + }, + render(h) { + return h(EnvironmentsApp); + }, + }); + } + + return null; +}; diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue index 2b8a31da50f..34d01f21da2 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltip, GlSprintf, GlIcon } from '@gitlab/ui'; +import { GlTooltip, GlSprintf, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; @@ -12,6 +12,7 @@ export default { }, directives: { GlTooltip, + SafeHtml, }, props: { lines: { @@ -129,9 +130,9 @@ export default { {{ lineNum(line) }} </td> <td + v-safe-html="lineCode(line)" class="line_content" :class="{ old: isHighlighted(lineNum(line)) }" - v-html="lineCode(line) /* eslint-disable-line vue/no-v-html */" ></td> </tr> </template> diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js index 624a04fd7c2..dcb6a8e20a3 100644 --- a/app/assets/javascripts/experimentation/utils.js +++ b/app/assets/javascripts/experimentation/utils.js @@ -3,11 +3,24 @@ import { get } from 'lodash'; import { DEFAULT_VARIANT, CANDIDATE_VARIANT, TRACKING_CONTEXT_SCHEMA } from './constants'; function getExperimentsData() { - return get(window, ['gon', 'experiment'], {}); + // Pull from deprecated window.gon.experiment + const experimentsFromGon = get(window, ['gon', 'experiment'], {}); + // Pull from preferred window.gl.experiments + const experimentsFromGl = get(window, ['gl', 'experiments'], {}); + + return { ...experimentsFromGon, ...experimentsFromGl }; } function convertExperimentDataToExperimentContext(experimentData) { - return { schema: TRACKING_CONTEXT_SCHEMA, data: experimentData }; + // Bandaid to allow-list only the properties which the current gitlab_experiment context schema suppports. + // See TRACKING_CONTEXT_SCHEMA for current version (1-0-0) + // https://gitlab.com/gitlab-org/iglu/-/blob/master/public/schemas/com.gitlab/gitlab_experiment/jsonschema/1-0-0 + const { experiment: experimentName, key, variant, migration_keys } = experimentData; + + return { + schema: TRACKING_CONTEXT_SCHEMA, + data: { experiment: experimentName, key, variant, migration_keys }, + }; } export function getExperimentData(experimentName) { @@ -26,14 +39,14 @@ export function getExperimentVariant(experimentName) { return getExperimentData(experimentName)?.variant || DEFAULT_VARIANT; } -export function experiment(experimentName, variants) { +export function experiment(experimentName, { use, control, candidate, ...variants }) { const variant = getExperimentVariant(experimentName); switch (variant) { case DEFAULT_VARIANT: - return variants.use.call(); + return (use || control).call(); case CANDIDATE_VARIANT: - return variants.try.call(); + return (variants.try || candidate).call(); default: return variants[variant].call(); } diff --git a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue index d86e13ce722..366ee6bb05b 100644 --- a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue +++ b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue @@ -213,6 +213,7 @@ export default { <div v-if="hasRotateError" class="gl-text-red-500 gl-display-flex gl-align-items-center gl-font-weight-normal gl-mb-3" + data-testid="rotate-error" > <gl-icon name="warning" class="gl-mr-2" /> <span>{{ $options.translations.instanceIdRegenerateError }}</span> diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 741171b185a..1287a7ed746 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -9,6 +9,8 @@ const FLASH_TYPES = { WARNING: 'warning', }; +const FLASH_CLOSED_EVENT = 'flashClosed'; + const getCloseEl = (flashEl) => { return flashEl.querySelector('.js-close-icon'); }; @@ -26,6 +28,7 @@ const hideFlash = (flashEl, fadeTransition = true) => { () => { flashEl.remove(); window.dispatchEvent(new Event('resize')); + flashEl.dispatchEvent(new Event(FLASH_CLOSED_EVENT)); if (document.body.classList.contains('flash-shown')) document.body.classList.remove('flash-shown'); }, @@ -132,4 +135,5 @@ export { hideFlash, removeFlashClickListener, FLASH_TYPES, + FLASH_CLOSED_EVENT, }; diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue index 5dac315d345..1700437aa84 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -1,5 +1,4 @@ <script> -/* eslint-disable vue/require-default-prop */ import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; @@ -23,6 +22,7 @@ export default { matcher: { type: String, required: false, + default: '', }, itemId: { type: Number, @@ -35,6 +35,7 @@ export default { namespace: { type: String, required: false, + default: '', }, webUrl: { type: String, diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index cb63c86a4fa..69331ff1a06 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import '~/lib/utils/jquery_at_who'; -import { escape as lodashEscape, sortBy, template } from 'lodash'; +import { escape as lodashEscape, sortBy, template, escapeRegExp } from 'lodash'; import * as Emoji from '~/emoji'; import axios from '~/lib/utils/axios_utils'; import { s__, __, sprintf } from '~/locale'; @@ -65,6 +65,17 @@ export function membersBeforeSave(members) { }); } +export const highlighter = (li, query) => { + // override default behaviour to escape dot character + // see https://github.com/ichord/At.js/pull/576 + if (!query) { + return li; + } + const escapedQuery = escapeRegExp(query); + const regexp = new RegExp(`>\\s*([^<]*?)(${escapedQuery})([^<]*)\\s*<`, 'ig'); + return li.replace(regexp, (str, $1, $2, $3) => `> ${$1}<strong>${$2}</strong>${$3} <`); +}; + export const defaultAutocompleteConfig = { emojis: true, members: true, @@ -664,16 +675,7 @@ class GfmAutoComplete { } return null; }, - highlighter(li, query) { - // override default behaviour to escape dot character - // see https://github.com/ichord/At.js/pull/576 - if (!query) { - return li; - } - const escapedQuery = query.replace(/[.+]/, '\\$&'); - const regexp = new RegExp(`>\\s*([^<]*?)(${escapedQuery})([^<]*)\\s*<`, 'ig'); - return li.replace(regexp, (str, $1, $2, $3) => `> ${$1}<strong>${$2}</strong>${$3} <`); - }, + highlighter, }; } diff --git a/app/assets/javascripts/google_cloud/components/app.vue b/app/assets/javascripts/google_cloud/components/app.vue new file mode 100644 index 00000000000..1e5be9df019 --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/app.vue @@ -0,0 +1,50 @@ +<script> +import { GlTab, GlTabs } from '@gitlab/ui'; +import IncubationBanner from './incubation_banner.vue'; +import ServiceAccounts from './service_accounts.vue'; + +export default { + components: { GlTab, GlTabs, IncubationBanner, ServiceAccounts }, + props: { + serviceAccounts: { + type: Array, + required: true, + }, + createServiceAccountUrl: { + type: String, + required: true, + }, + emptyIllustrationUrl: { + type: String, + required: true, + }, + }, + methods: { + feedbackUrl(template) { + return `https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/meta/-/issues/new?issuable_template=${template}`; + }, + }, +}; +</script> + +<template> + <div> + <incubation-banner + :share-feedback-url="feedbackUrl('general_feedback')" + :report-bug-url="feedbackUrl('report_bug')" + :feature-request-url="feedbackUrl('feature_request')" + /> + <gl-tabs> + <gl-tab :title="__('Configuration')"> + <service-accounts + class="gl-mx-3" + :list="serviceAccounts" + :create-url="createServiceAccountUrl" + :empty-illustration-url="emptyIllustrationUrl" + /> + </gl-tab> + <gl-tab :title="__('Deployments')" disabled /> + <gl-tab :title="__('Services')" disabled /> + </gl-tabs> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/components/incubation_banner.vue b/app/assets/javascripts/google_cloud/components/incubation_banner.vue new file mode 100644 index 00000000000..652b8c1aecb --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/incubation_banner.vue @@ -0,0 +1,44 @@ +<script> +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; + +export default { + components: { GlAlert, GlLink, GlSprintf }, + props: { + shareFeedbackUrl: { + required: true, + type: String, + }, + reportBugUrl: { + required: true, + type: String, + }, + featureRequestUrl: { + required: true, + type: String, + }, + }, +}; +</script> + +<template> + <gl-alert :dismissible="false" variant="info"> + {{ __('This is an experimental feature developed by GitLab Incubation Engineering.') }} + <gl-sprintf + :message=" + __( + 'We invite you to %{featureLinkStart}request a feature%{featureLinkEnd}, %{bugLinkStart}report a bug%{bugLinkEnd} or %{feedbackLinkStart}share feedback%{feedbackLinkEnd}', + ) + " + > + <template #featureLink="{ content }"> + <gl-link :href="featureRequestUrl">{{ content }}</gl-link> + </template> + <template #bugLink="{ content }"> + <gl-link :href="reportBugUrl">{{ content }}</gl-link> + </template> + <template #feedbackLink="{ content }"> + <gl-link :href="shareFeedbackUrl">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> +</template> diff --git a/app/assets/javascripts/google_cloud/components/service_accounts.vue b/app/assets/javascripts/google_cloud/components/service_accounts.vue new file mode 100644 index 00000000000..b70b25a5dc3 --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/service_accounts.vue @@ -0,0 +1,65 @@ +<script> +import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { GlButton, GlEmptyState, GlTable }, + props: { + list: { + type: Array, + required: true, + }, + createUrl: { + type: String, + required: true, + }, + emptyIllustrationUrl: { + type: String, + required: true, + }, + }, + data() { + return { + tableFields: [ + { key: 'environment', label: __('Environment'), sortable: true }, + { key: 'gcp_project', label: __('Google Cloud Project'), sortable: true }, + { key: 'service_account_exists', label: __('Service Account'), sortable: true }, + { key: 'service_account_key_exists', label: __('Service Account Key'), sortable: true }, + ], + }; + }, +}; +</script> + +<template> + <div> + <gl-empty-state + v-if="list.length === 0" + :title="__('No service accounts')" + :description=" + __('Service Accounts keys authorize GitLab to deploy your Google Cloud project') + " + :primary-button-link="createUrl" + :primary-button-text="__('Create service account')" + :svg-path="emptyIllustrationUrl" + /> + + <div v-else> + <h2 class="gl-font-size-h2">{{ __('Service Accounts') }}</h2> + <p>{{ __('Service Accounts keys authorize GitLab to deploy your Google Cloud project') }}</p> + + <gl-table :items="list" :fields="tableFields"> + <template #cell(service_account_exists)="{ value }"> + {{ value ? '✔' : __('Not found') }} + </template> + <template #cell(service_account_key_exists)="{ value }"> + {{ value ? '✔' : __('Not found') }} + </template> + </gl-table> + + <gl-button :href="createUrl" category="primary" variant="info"> + {{ __('Create service account') }} + </gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/index.js b/app/assets/javascripts/google_cloud/index.js new file mode 100644 index 00000000000..a156a632e9a --- /dev/null +++ b/app/assets/javascripts/google_cloud/index.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; +import App from './components/app.vue'; + +const elementRenderer = (element, props = {}) => (createElement) => + createElement(element, { props }); + +export default () => { + const root = document.querySelector('#js-google-cloud'); + const props = JSON.parse(root.getAttribute('data')); + return new Vue({ el: root, render: elementRenderer(App, props) }); +}; diff --git a/app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql new file mode 100644 index 00000000000..78a368089a8 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql @@ -0,0 +1,4 @@ +fragment Iteration on Iteration { + id + title +} diff --git a/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql index e345fe97281..c5f99a1657e 100644 --- a/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql @@ -1,9 +1,10 @@ #import "../fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user_availability.fragment.graphql" -query usersSearch($search: String!, $fullPath: ID!) { +query groupUsersSearch($search: String!, $fullPath: ID!) { workspace: group(fullPath: $fullPath) { - users: groupMembers(search: $search, relations: [DIRECT, INHERITED]) { + id + users: groupMembers(search: $search, relations: [DIRECT, DESCENDANTS, INHERITED]) { nodes { user { ...User diff --git a/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql index 1d9497d65ce..62ce27815c7 100644 --- a/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql @@ -1,5 +1,6 @@ query searchProjectMembers($fullPath: ID!, $search: String) { project(fullPath: $fullPath) { + id projectMembers(search: $search) { nodes { user { diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql index e18eea33041..d04a49f8b3a 100644 --- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql @@ -1,7 +1,7 @@ #import "../fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user_availability.fragment.graphql" -query usersSearch($search: String!, $fullPath: ID!) { +query projectUsersSearch($search: String!, $fullPath: ID!) { workspace: project(fullPath: $fullPath) { users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) { nodes { diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js index 828ddd95ffc..8fb70eb59bd 100644 --- a/app/assets/javascripts/graphql_shared/utils.js +++ b/app/assets/javascripts/graphql_shared/utils.js @@ -15,6 +15,8 @@ export const isGid = (id) => { return false; }; +const parseGid = (gid) => parseInt(`${gid}`.replace(/gid:\/\/gitlab\/.*\//g, ''), 10); + /** * Ids generated by GraphQL endpoints are usually in the format * gid://gitlab/Environments/123. This method extracts Id number @@ -23,8 +25,10 @@ export const isGid = (id) => { * @param {String} gid GraphQL global ID * @returns {Number} */ -export const getIdFromGraphQLId = (gid = '') => - parseInt(`${gid}`.replace(/gid:\/\/gitlab\/.*\//g, ''), 10) || null; +export const getIdFromGraphQLId = (gid = '') => { + const parsedGid = parseGid(gid); + return Number.isInteger(parsedGid) ? parsedGid : null; +}; export const MutationOperationMode = { Append: 'APPEND', diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js index b6a1f41afb5..f255f8a084c 100644 --- a/app/assets/javascripts/group.js +++ b/app/assets/javascripts/group.js @@ -1,6 +1,6 @@ import createFlash from '~/flash'; import { __ } from '~/locale'; -import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability'; +import { getGroupPathAvailability } from '~/rest_api'; import { slugify } from './lib/utils/text_utility'; export default class Group { @@ -51,7 +51,7 @@ export default class Group { const slug = this.groupPaths[0]?.value || slugify(value); if (!slug) return; - fetchGroupPathAvailability(slug, this.parentId?.value) + getGroupPathAvailability(slug, this.parentId?.value) .then(({ data }) => data) .then(({ exists, suggests }) => { if (exists && suggests.length) { diff --git a/app/assets/javascripts/group_settings/components/shared_runners_form.vue b/app/assets/javascripts/group_settings/components/shared_runners_form.vue index f61d96b3dfd..dcac337c6ef 100644 --- a/app/assets/javascripts/group_settings/components/shared_runners_form.vue +++ b/app/assets/javascripts/group_settings/components/shared_runners_form.vue @@ -3,13 +3,7 @@ import { GlToggle, GlLoadingIcon, GlTooltip, GlAlert } from '@gitlab/ui'; import { debounce } from 'lodash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; -import { - DEBOUNCE_TOGGLE_DELAY, - ERROR_MESSAGE, - ENABLED, - DISABLED, - ALLOW_OVERRIDE, -} from '../constants'; +import { DEBOUNCE_TOGGLE_DELAY, ERROR_MESSAGE } from '../constants'; export default { components: { @@ -18,21 +12,14 @@ export default { GlTooltip, GlAlert, }, - props: { - updatePath: { - type: String, - required: true, - }, - sharedRunnersAvailability: { - type: String, - required: true, - }, - parentSharedRunnersAvailability: { - type: String, - required: false, - default: '', - }, - }, + inject: [ + 'updatePath', + 'sharedRunnersAvailability', + 'parentSharedRunnersAvailability', + 'runnerEnabled', + 'runnerDisabled', + 'runnerAllowOverride', + ], data() { return { isLoading: false, @@ -43,21 +30,21 @@ export default { }, computed: { toggleDisabled() { - return this.parentSharedRunnersAvailability === DISABLED || this.isLoading; + return this.parentSharedRunnersAvailability === this.runnerDisabled || this.isLoading; }, enabledOrDisabledSetting() { - return this.enabled ? ENABLED : DISABLED; + return this.enabled ? this.runnerEnabled : this.runnerDisabled; }, disabledWithOverrideSetting() { - return this.allowOverride ? ALLOW_OVERRIDE : DISABLED; + return this.allowOverride ? this.runnerAllowOverride : this.runnerDisabled; }, }, created() { - if (this.sharedRunnersAvailability !== ENABLED) { + if (this.sharedRunnersAvailability !== this.runnerEnabled) { this.enabled = false; } - if (this.sharedRunnersAvailability === ALLOW_OVERRIDE) { + if (this.sharedRunnersAvailability === this.runnerAllowOverride) { this.allowOverride = true; } }, diff --git a/app/assets/javascripts/group_settings/constants.js b/app/assets/javascripts/group_settings/constants.js index c7bb851c06b..4067b6b52a3 100644 --- a/app/assets/javascripts/group_settings/constants.js +++ b/app/assets/javascripts/group_settings/constants.js @@ -4,8 +4,3 @@ import { __ } from '~/locale'; export const DEBOUNCE_TOGGLE_DELAY = 1000; export const ERROR_MESSAGE = __('Refresh the page and try again.'); - -// runner setting options -export const ENABLED = 'enabled'; -export const DISABLED = 'disabled_and_unoverridable'; -export const ALLOW_OVERRIDE = 'disabled_with_override'; diff --git a/app/assets/javascripts/group_settings/mount_shared_runners.js b/app/assets/javascripts/group_settings/mount_shared_runners.js index 44284204c41..21a2373e2b1 100644 --- a/app/assets/javascripts/group_settings/mount_shared_runners.js +++ b/app/assets/javascripts/group_settings/mount_shared_runners.js @@ -4,12 +4,27 @@ import UpdateSharedRunnersForm from './components/shared_runners_form.vue'; export default (containerId = 'update-shared-runners-form') => { const containerEl = document.getElementById(containerId); + const { + updatePath, + sharedRunnersAvailability, + parentSharedRunnersAvailability, + runnerEnabled, + runnerDisabled, + runnerAllowOverride, + } = containerEl.dataset; + return new Vue({ el: containerEl, + provide: { + updatePath, + sharedRunnersAvailability, + parentSharedRunnersAvailability, + runnerEnabled, + runnerDisabled, + runnerAllowOverride, + }, render(createElement) { - return createElement(UpdateSharedRunnersForm, { - props: containerEl.dataset, - }); + return createElement(UpdateSharedRunnersForm); }, }); }; diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue index 9c379d7bf9b..a51edd385dd 100644 --- a/app/assets/javascripts/groups/components/item_caret.vue +++ b/app/assets/javascripts/groups/components/item_caret.vue @@ -22,6 +22,6 @@ export default { <template> <span class="folder-caret gl-mr-2"> - <gl-icon :size="10" :name="iconClass" use-deprecated-sizes /> + <gl-icon :size="12" :name="iconClass" /> </span> </template> diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue index 96cb4f3d495..55ae5501cdb 100644 --- a/app/assets/javascripts/ide/components/jobs/detail.vue +++ b/app/assets/javascripts/ide/components/jobs/detail.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective, GlButton, GlIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlButton, GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; import { throttle } from 'lodash'; import { mapActions, mapState } from 'vuex'; import { __ } from '../../../locale'; @@ -14,6 +14,7 @@ const scrollPositions = { export default { directives: { GlTooltip: GlTooltipDirective, + SafeHtml: GlSafeHtmlDirective, }, components: { GlButton, @@ -100,8 +101,8 @@ export default { <pre ref="buildJobLog" class="build-log mb-0 h-100 mr-3" @scroll="scrollBuildLog"> <code v-show="!detailJob.isLoading" + v-safe-html="jobOutput" class="bash" - v-html="jobOutput /* eslint-disable-line vue/no-v-html */" > </code> <div diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index e8541d3a4c3..1c5a00568eb 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -2,7 +2,7 @@ import { GlModal, GlButton } from '@gitlab/ui'; import { mapActions, mapState, mapGetters } from 'vuex'; import createFlash from '~/flash'; -import { __, sprintf, s__ } from '~/locale'; +import { __, sprintf } from '~/locale'; import { modalTypes } from '../../constants'; import { trimPathComponents, getPathParent } from '../../utils'; @@ -58,7 +58,7 @@ export default { if (this.modalType === modalTypes.rename) { if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) { createFlash({ - message: sprintf(s__('The name "%{name}" is already taken in this directory.'), { + message: sprintf(__('The name "%{name}" is already taken in this directory.'), { name: this.entryName, }), fadeTransition: false, diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 907ac496982..e1caf1ba44a 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -87,7 +87,7 @@ export default { v-if="!latestPipeline" :empty-state-svg-path="pipelinesEmptyStateSvgPath" :can-set-ci="true" - class="mb-auto mt-auto" + class="gl-p-5" /> <gl-alert v-else-if="latestPipeline.yamlError" diff --git a/app/assets/javascripts/ide/components/shared/commit_message_field.vue b/app/assets/javascripts/ide/components/shared/commit_message_field.vue new file mode 100644 index 00000000000..7fca7429ad7 --- /dev/null +++ b/app/assets/javascripts/ide/components/shared/commit_message_field.vue @@ -0,0 +1,137 @@ +<script> +import { GlIcon, GlPopover } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import { MAX_TITLE_LENGTH, MAX_BODY_LENGTH } from '../../constants'; + +export default { + components: { + GlIcon, + GlPopover, + }, + props: { + text: { + type: String, + required: true, + }, + placeholder: { + type: String, + required: true, + }, + }, + data() { + return { + scrollTop: 0, + isFocused: false, + }; + }, + computed: { + allLines() { + return this.text.split('\n').map((line, i) => ({ + text: line.substr(0, this.getLineLength(i)) || ' ', + highlightedText: line.substr(this.getLineLength(i)), + })); + }, + }, + methods: { + handleScroll() { + if (this.$refs.textarea) { + this.$nextTick(() => { + this.scrollTop = this.$refs.textarea.scrollTop; + }); + } + }, + getLineLength(i) { + return i === 0 ? MAX_TITLE_LENGTH : MAX_BODY_LENGTH; + }, + onInput(e) { + this.$emit('input', e.target.value); + }, + onCtrlEnter() { + if (!this.isFocused) return; + this.$emit('submit'); + }, + updateIsFocused(isFocused) { + this.isFocused = isFocused; + }, + }, + popoverOptions: { + triggers: 'hover', + placement: 'top', + content: sprintf( + __(` + The character highlighter helps you keep the subject line to %{titleLength} characters + and wrap the body at %{bodyLength} so they are readable in git. + `), + { titleLength: MAX_TITLE_LENGTH, bodyLength: MAX_BODY_LENGTH }, + ), + }, +}; +</script> + +<template> + <fieldset + class="gl-rounded-base gl-inset-border-1-gray-400 gl-py-4 gl-px-5" + :class="{ + 'gl-outline-none! gl-focus-ring-border-1-gray-900!': isFocused, + }" + > + <div + v-once + class="gl-display-flex gl-align-items-center gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-mb-3" + > + <div>{{ __('Commit Message') }}</div> + <div id="commit-message-popover-container"> + <span id="commit-message-question" class="gl-gray-700 gl-ml-3"> + <gl-icon name="question" /> + </span> + <gl-popover + target="commit-message-question" + container="commit-message-popover-container" + v-bind="$options.popoverOptions" + /> + </div> + </div> + <div class="gl-relative gl-w-full gl-h-13 gl-overflow-hidden"> + <div class="gl-absolute gl-z-index-1 gl-font-monospace gl-text-transparent"> + <div + data-testid="highlights" + :style="{ + transform: `translate3d(0, ${-scrollTop}px, 0)`, + }" + > + <div v-for="(line, index) in allLines" :key="index"> + <span + data-testid="highlights-text" + class="gl-white-space-pre-wrap gl-word-break-word" + v-text="line.text" + > + </span + ><mark + v-show="line.highlightedText" + data-testid="highlights-mark" + class="gl-px-1 gl-py-0 gl-bg-orange-100 gl-text-transparent gl-white-space-pre-wrap gl-word-break-word" + v-text="line.highlightedText" + > + </mark> + </div> + </div> + </div> + <textarea + ref="textarea" + :placeholder="placeholder" + :value="text" + class="gl-absolute gl-w-full gl-h-full gl-z-index-2 gl-font-monospace p-0 gl-outline-0 gl-bg-transparent gl-border-0" + data-qa-selector="ide_commit_message_field" + dir="auto" + name="commit-message" + @scroll="handleScroll" + @input="onInput" + @focus="updateIsFocused(true)" + @blur="updateIsFocused(false)" + @keydown.ctrl.enter="onCtrlEnter" + @keydown.meta.enter="onCtrlEnter" + > + </textarea> + </div> + </fieldset> +</template> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 4845b667b40..706d98fdb90 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -5,7 +5,7 @@ export const MAX_TITLE_LENGTH = 50; export const MAX_BODY_LENGTH = 72; export const SIDEBAR_INIT_WIDTH = 340; -export const SIDEBAR_MIN_WIDTH = 340; +export const SIDEBAR_MIN_WIDTH = 260; export const SIDEBAR_NAV_WIDTH = 60; // File view modes diff --git a/app/assets/javascripts/import_entities/components/group_dropdown.vue b/app/assets/javascripts/import_entities/components/group_dropdown.vue index 5ba910746ca..25d4037bbe5 100644 --- a/app/assets/javascripts/import_entities/components/group_dropdown.vue +++ b/app/assets/javascripts/import_entities/components/group_dropdown.vue @@ -19,7 +19,7 @@ export default { computed: { filteredNamespaces() { return this.namespaces.filter((ns) => - ns.toLowerCase().includes(this.searchTerm.toLowerCase()), + ns.fullPath.toLowerCase().includes(this.searchTerm.toLowerCase()), ); }, }, diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue index 104c84173fc..e004bc35087 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue @@ -1,7 +1,5 @@ <script> import { GlButton, GlIcon, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; -import { joinPaths } from '~/lib/utils/url_utility'; -import { isFinished, isInvalid, isAvailableForImport } from '../utils'; export default { components: { @@ -12,32 +10,17 @@ export default { GlTooltip, }, props: { - group: { - type: Object, + isFinished: { + type: Boolean, required: true, }, - groupPathRegex: { - type: RegExp, + isAvailableForImport: { + type: Boolean, required: true, }, - }, - computed: { - fullLastImportPath() { - return this.group.last_import_target - ? `${this.group.last_import_target.target_namespace}/${this.group.last_import_target.new_name}` - : null; - }, - absoluteLastImportPath() { - return joinPaths(gon.relative_url_root || '/', this.fullLastImportPath); - }, - isAvailableForImport() { - return isAvailableForImport(this.group); - }, - isFinished() { - return isFinished(this.group); - }, - isInvalid() { - return isInvalid(this.group, this.groupPathRegex); + isInvalid: { + type: Boolean, + required: true, }, }, }; @@ -56,7 +39,7 @@ export default { {{ isFinished ? __('Re-import') : __('Import') }} </gl-button> <gl-icon - v-if="isFinished" + v-if="isAvailableForImport && isFinished" v-gl-tooltip :size="16" name="information-o" diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue index 2de9bd4f868..cad1b983d61 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue @@ -1,7 +1,6 @@ <script> import { GlLink, GlSprintf, GlIcon } from '@gitlab/ui'; import { joinPaths } from '~/lib/utils/url_utility'; -import { isFinished } from '../utils'; export default { components: { @@ -17,16 +16,13 @@ export default { }, computed: { fullLastImportPath() { - return this.group.last_import_target - ? `${this.group.last_import_target.target_namespace}/${this.group.last_import_target.new_name}` + return this.group.lastImportTarget + ? `${this.group.lastImportTarget.targetNamespace}/${this.group.lastImportTarget.newName}` : null; }, absoluteLastImportPath() { return joinPaths(gon.relative_url_root || '/', this.fullLastImportPath); }, - isFinished() { - return isFinished(this.group); - }, }, }; </script> @@ -34,13 +30,13 @@ export default { <template> <div> <gl-link - :href="group.web_url" + :href="group.webUrl" target="_blank" class="gl-display-inline-flex gl-align-items-center gl-h-7" > - {{ group.full_path }} <gl-icon name="external-link" /> + {{ group.fullPath }} <gl-icon name="external-link" /> </gl-link> - <div v-if="isFinished && fullLastImportPath" class="gl-font-sm"> + <div v-if="group.flags.isFinished && fullLastImportPath" class="gl-font-sm"> <gl-sprintf :message="s__('BulkImport|Last imported to %{link}')"> <template #link> <gl-link :href="absoluteLastImportPath" class="gl-font-sm" target="_blank">{{ diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue index 04b037ecc2b..ec6025c84bb 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -12,18 +12,28 @@ import { GlTable, GlFormCheckbox, } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import createFlash from '~/flash'; import { s__, __, n__ } from '~/locale'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; +import { getGroupPathAvailability } from '~/rest_api'; +import axios from '~/lib/utils/axios_utils'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; + +import { STATUSES } from '../../constants'; import ImportStatusCell from '../../components/import_status.vue'; import importGroupsMutation from '../graphql/mutations/import_groups.mutation.graphql'; -import setImportTargetMutation from '../graphql/mutations/set_import_target.mutation.graphql'; +import updateImportStatusMutation from '../graphql/mutations/update_import_status.mutation.graphql'; import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql'; import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql'; -import { isInvalid, isFinished, isAvailableForImport } from '../utils'; +import { NEW_NAME_FIELD, i18n } from '../constants'; +import { StatusPoller } from '../services/status_poller'; +import { isFinished, isAvailableForImport, isNameValid, isSameTarget } from '../utils'; import ImportActionsCell from './import_actions_cell.vue'; import ImportSourceCell from './import_source_cell.vue'; import ImportTargetCell from './import_target_cell.vue'; +const VALIDATION_DEBOUNCE_TIME = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; const PAGE_SIZES = [20, 50, 100]; const DEFAULT_PAGE_SIZE = PAGE_SIZES[0]; const DEFAULT_TH_CLASSES = @@ -59,7 +69,7 @@ export default { type: RegExp, required: true, }, - groupUrlErrorMessage: { + jobsPath: { type: String, required: true, }, @@ -70,7 +80,9 @@ export default { filter: '', page: 1, perPage: DEFAULT_PAGE_SIZE, - selectedGroups: [], + selectedGroupsIds: [], + pendingGroupsIds: [], + importTargets: {}, }; }, @@ -94,14 +106,14 @@ export default { tdClass: `${DEFAULT_TD_CLASSES} gl-pr-3!`, }, { - key: 'web_url', + key: 'webUrl', label: s__('BulkImport|From source group'), thClass: `${DEFAULT_TH_CLASSES} gl-pl-0! import-jobs-from-col`, // eslint-disable-next-line @gitlab/require-i18n-strings tdClass: `${DEFAULT_TD_CLASSES} gl-pl-0!`, }, { - key: 'import_target', + key: 'importTarget', label: s__('BulkImport|To new group'), thClass: `${DEFAULT_TH_CLASSES} import-jobs-to-col`, tdClass: DEFAULT_TD_CLASSES, @@ -126,16 +138,39 @@ export default { return this.bulkImportSourceGroups?.nodes ?? []; }, + groupsTableData() { + return this.groups.map((group) => { + const importTarget = this.getImportTarget(group); + const status = this.getStatus(group); + + const flags = { + isInvalid: importTarget.validationErrors?.length > 0, + isAvailableForImport: isAvailableForImport(group) && status !== STATUSES.SCHEDULING, + isFinished: isFinished(group), + }; + + return { + ...group, + visibleStatus: status, + importTarget, + flags: { + ...flags, + isUnselectable: !flags.isAvailableForImport || flags.isInvalid, + }, + }; + }); + }, + hasSelectedGroups() { - return this.selectedGroups.length > 0; + return this.selectedGroupsIds.length > 0; }, hasAllAvailableGroupsSelected() { - return this.selectedGroups.length === this.availableGroupsForImport.length; + return this.selectedGroupsIds.length === this.availableGroupsForImport.length; }, availableGroupsForImport() { - return this.groups.filter((g) => isAvailableForImport(g) && !this.isInvalid(g)); + return this.groupsTableData.filter((g) => g.flags.isAvailableForImport && g.flags.isInvalid); }, humanizedTotal() { @@ -175,25 +210,43 @@ export default { filter() { this.page = 1; }, - groups() { + + groupsTableData() { const table = this.getTableRef(); - this.groups.forEach((g, idx) => { - if (this.selectedGroups.includes(g)) { + const matches = new Set(); + this.groupsTableData.forEach((g, idx) => { + if (this.selectedGroupsIds.includes(g.id)) { + matches.add(g.id); this.$nextTick(() => { table.selectRow(idx); }); } }); - this.selectedGroups = []; + + this.selectedGroupsIds = this.selectedGroupsIds.filter((id) => matches.has(id)); }, }, - methods: { - isUnselectable(group) { - return !this.isAvailableForImport(group) || this.isInvalid(group); - }, + mounted() { + this.statusPoller = new StatusPoller({ + pollPath: this.jobsPath, + updateImportStatus: (update) => { + this.$apollo.mutate({ + mutation: updateImportStatusMutation, + variables: { id: update.id, status: update.status_name }, + }); + }, + }); - rowClasses(group) { + this.statusPoller.startPolling(); + }, + + beforeDestroy() { + this.statusPoller.stopPolling(); + }, + + methods: { + rowClasses(groupTableItem) { const DEFAULT_CLASSES = [ 'gl-border-gray-200', 'gl-border-0', @@ -201,7 +254,7 @@ export default { 'gl-border-solid', ]; const result = [...DEFAULT_CLASSES]; - if (this.isUnselectable(group)) { + if (groupTableItem.flags.isUnselectable) { result.push('gl-cursor-default!'); } return result; @@ -211,19 +264,13 @@ export default { if (type === 'row') { return { 'data-qa-selector': 'import_item', - 'data-qa-source-group': group.full_path, + 'data-qa-source-group': group.fullPath, }; } return {}; }, - isAvailableForImport, - isFinished, - isInvalid(group) { - return isInvalid(group, this.groupPathRegex); - }, - groupsCount(count) { return n__('%d group', '%d groups', count); }, @@ -232,22 +279,64 @@ export default { this.page = page; }, - updateImportTarget(sourceGroupId, targetNamespace, newName) { - this.$apollo.mutate({ - mutation: setImportTargetMutation, - variables: { sourceGroupId, targetNamespace, newName }, - }); + getStatus(group) { + if (this.pendingGroupsIds.includes(group.id)) { + return STATUSES.SCHEDULING; + } + + return group.progress?.status || STATUSES.NONE; }, - importGroups(sourceGroupIds) { - this.$apollo.mutate({ - mutation: importGroupsMutation, - variables: { sourceGroupIds }, + updateImportTarget(group, changes) { + const newImportTarget = { + ...group.importTarget, + ...changes, + }; + this.$set(this.importTargets, group.id, newImportTarget); + this.validateImportTarget(newImportTarget); + }, + + async importGroups(importRequests) { + const newPendingGroupsIds = importRequests.map((request) => request.sourceGroupId); + newPendingGroupsIds.forEach((id) => { + this.importTargets[id].validationErrors = [ + { field: NEW_NAME_FIELD, message: i18n.ERROR_IMPORT_COMPLETED }, + ]; + + if (!this.pendingGroupsIds.includes(id)) { + this.pendingGroupsIds.push(id); + } }); + + try { + await this.$apollo.mutate({ + mutation: importGroupsMutation, + variables: { importRequests }, + }); + } catch (error) { + const message = error?.networkError?.response?.data?.error ?? i18n.ERROR_IMPORT; + createFlash({ + message, + captureError: true, + error, + }); + } finally { + this.pendingGroupsIds = this.pendingGroupsIds.filter( + (id) => !newPendingGroupsIds.includes(id), + ); + } }, importSelectedGroups() { - this.importGroups(this.selectedGroups.map((g) => g.id)); + const importRequests = this.groupsTableData + .filter((group) => this.selectedGroupsIds.includes(group.id)) + .map((group) => ({ + sourceGroupId: group.id, + targetNamespace: group.importTarget.targetNamespace.fullPath, + newName: group.importTarget.newName, + })); + + this.importGroups(importRequests); }, setPageSize(size) { @@ -263,16 +352,115 @@ export default { preventSelectingAlreadyImportedGroups(updatedSelection) { if (updatedSelection) { - this.selectedGroups = updatedSelection; + this.selectedGroupsIds = updatedSelection.map((g) => g.id); } const table = this.getTableRef(); - this.groups.forEach((group, idx) => { - if (table.isRowSelected(idx) && this.isUnselectable(group)) { + this.groupsTableData.forEach((group, idx) => { + if (table.isRowSelected(idx) && group.flags.isUnselectable) { table.unselectRow(idx); } }); }, + + validateImportTarget: debounce(async function validate(importTarget) { + const newValidationErrors = []; + importTarget.cancellationToken?.cancel(); + if (importTarget.newName === '') { + newValidationErrors.push({ field: NEW_NAME_FIELD, message: i18n.ERROR_REQUIRED }); + } else if (!isNameValid(importTarget, this.groupPathRegex)) { + newValidationErrors.push({ field: NEW_NAME_FIELD, message: i18n.ERROR_INVALID_FORMAT }); + } else if (Object.values(this.importTargets).find(isSameTarget(importTarget))) { + newValidationErrors.push({ + field: NEW_NAME_FIELD, + message: i18n.ERROR_NAME_ALREADY_USED_IN_SUGGESTION, + }); + } else { + try { + // eslint-disable-next-line no-param-reassign + importTarget.cancellationToken = axios.CancelToken.source(); + const { + data: { exists }, + } = await getGroupPathAvailability( + importTarget.newName, + importTarget.targetNamespace.id, + { + cancelToken: importTarget.cancellationToken?.token, + }, + ); + + if (exists) { + newValidationErrors.push({ + field: NEW_NAME_FIELD, + message: i18n.ERROR_NAME_ALREADY_EXISTS, + }); + } + } catch (e) { + if (!axios.isCancel(e)) { + throw e; + } + } + } + + // eslint-disable-next-line no-param-reassign + importTarget.validationErrors = newValidationErrors; + }, VALIDATION_DEBOUNCE_TIME), + + getImportTarget(group) { + if (this.importTargets[group.id]) { + return this.importTargets[group.id]; + } + + const defaultTargetNamespace = this.availableNamespaces[0] ?? { fullPath: '', id: null }; + let importTarget; + if (group.lastImportTarget) { + const targetNamespace = this.availableNamespaces.find( + (ns) => ns.fullPath === group.lastImportTarget.targetNamespace, + ); + + importTarget = { + targetNamespace: targetNamespace ?? defaultTargetNamespace, + newName: group.lastImportTarget.newName, + }; + } else { + importTarget = { + targetNamespace: defaultTargetNamespace, + newName: group.fullPath, + }; + } + + const cancellationToken = axios.CancelToken.source(); + this.$set(this.importTargets, group.id, { + ...importTarget, + cancellationToken, + validationErrors: [], + }); + + getGroupPathAvailability(importTarget.newName, importTarget.targetNamespace.id, { + cancelToken: cancellationToken.token, + }) + .then(({ data: { exists, suggests: suggestions } }) => { + if (!exists) return; + + let currentSuggestion = suggestions[0] ?? importTarget.newName; + const existingTargets = Object.values(this.importTargets) + .filter((t) => t.targetNamespace.id === importTarget.targetNamespace.id) + .map((t) => t.newName.toLowerCase()); + + while (existingTargets.includes(currentSuggestion.toLowerCase())) { + currentSuggestion = `${currentSuggestion}-1`; + } + + Object.assign(this.importTargets[group.id], { + targetNamespace: importTarget.targetNamespace, + newName: currentSuggestion, + }); + }) + .catch(() => { + // empty catch intended + }); + return this.importTargets[group.id]; + }, }, gitlabLogo: window.gon.gitlab_logo, @@ -329,7 +517,7 @@ export default { <gl-empty-state v-else-if="!hasGroups" :title="s__('BulkImport|You have no groups to import')" - :description="s__('Check your source instance permissions.')" + :description="__('Check your source instance permissions.')" /> <template v-else> <div @@ -337,7 +525,7 @@ export default { > <gl-sprintf :message="__('%{count} selected')"> <template #count> - {{ selectedGroups.length }} + {{ selectedGroupsIds.length }} </template> </gl-sprintf> <gl-button @@ -355,7 +543,7 @@ export default { data-qa-selector="import_table" :tbody-tr-class="rowClasses" :tbody-tr-attr="qaRowAttributes" - :items="groups" + :items="groupsTableData" :fields="$options.fields" selectable select-mode="multi" @@ -364,7 +552,7 @@ export default { > <template #head(selected)="{ selectAllRows, clearSelected }"> <gl-form-checkbox - :key="`checkbox-${selectedGroups.length}`" + :key="`checkbox-${selectedGroupsIds.length}`" class="gl-h-7 gl-pt-3" :checked="hasSelectedGroups" :indeterminate="hasSelectedGroups && !hasAllAvailableGroupsSelected" @@ -375,35 +563,39 @@ export default { <gl-form-checkbox class="gl-h-7 gl-pt-3" :checked="rowSelected" - :disabled="!isAvailableForImport(group) || isInvalid(group)" + :disabled="group.flags.isUnselectable" @change="rowSelected ? unselectRow() : selectRow()" /> </template> - <template #cell(web_url)="{ item: group }"> + <template #cell(webUrl)="{ item: group }"> <import-source-cell :group="group" /> </template> - <template #cell(import_target)="{ item: group }"> + <template #cell(importTarget)="{ item: group }"> <import-target-cell :group="group" :available-namespaces="availableNamespaces" :group-path-regex="groupPathRegex" - :group-url-error-message="groupUrlErrorMessage" - @update-target-namespace=" - updateImportTarget(group.id, $event, group.import_target.new_name) - " - @update-new-name=" - updateImportTarget(group.id, group.import_target.target_namespace, $event) - " + @update-target-namespace="updateImportTarget(group, { targetNamespace: $event })" + @update-new-name="updateImportTarget(group, { newName: $event })" /> </template> - <template #cell(progress)="{ value: { status } }"> - <import-status-cell :status="status" class="gl-line-height-32" /> + <template #cell(progress)="{ item: group }"> + <import-status-cell :status="group.visibleStatus" class="gl-line-height-32" /> </template> <template #cell(actions)="{ item: group }"> <import-actions-cell - :group="group" - :group-path-regex="groupPathRegex" - @import-group="importGroups([group.id])" + :is-finished="group.flags.isFinished" + :is-available-for-import="group.flags.isAvailableForImport" + :is-invalid="group.flags.isInvalid" + @import-group=" + importGroups([ + { + sourceGroupId: group.id, + targetNamespace: group.importTarget.targetNamespace.fullPath, + newName: group.importTarget.newName, + }, + ]) + " /> </template> </gl-table> @@ -413,7 +605,7 @@ export default { :page-info="bulkImportSourceGroups.pageInfo" class="gl-m-0" /> - <gl-dropdown category="tertiary" class="gl-ml-auto"> + <gl-dropdown category="tertiary" :aria-label="__('Page size')" class="gl-ml-auto"> <template #button-content> <span class="font-weight-bold"> <gl-sprintf :message="__('%{count} items per page')"> diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue index daced740c94..ca9ae9447d0 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue @@ -7,12 +7,7 @@ import { } from '@gitlab/ui'; import { s__ } from '~/locale'; import ImportGroupDropdown from '../../components/group_dropdown.vue'; -import { - isInvalid, - getInvalidNameValidationMessage, - isNameValid, - isAvailableForImport, -} from '../utils'; +import { getInvalidNameValidationMessage } from '../utils'; export default { components: { @@ -31,44 +26,15 @@ export default { type: Array, required: true, }, - groupPathRegex: { - type: RegExp, - required: true, - }, - groupUrlErrorMessage: { - type: String, - required: true, - }, }, computed: { - availableNamespaceNames() { - return this.availableNamespaces.map((ns) => ns.full_path); - }, - - importTarget() { - return this.group.import_target; + fullPath() { + return this.group.importTarget.targetNamespace.fullPath || s__('BulkImport|No parent'); }, - invalidNameValidationMessage() { - return getInvalidNameValidationMessage(this.group); + return getInvalidNameValidationMessage(this.group.importTarget); }, - - isInvalid() { - return isInvalid(this.group, this.groupPathRegex); - }, - - isNameValid() { - return isNameValid(this.group, this.groupPathRegex); - }, - - isAvailableForImport() { - return isAvailableForImport(this.group); - }, - }, - - i18n: { - NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'), }, }; </script> @@ -77,14 +43,14 @@ export default { <div class="gl-display-flex gl-align-items-stretch"> <import-group-dropdown #default="{ namespaces }" - :text="importTarget.target_namespace" - :disabled="!isAvailableForImport" - :namespaces="availableNamespaceNames" + :text="fullPath" + :disabled="!group.flags.isAvailableForImport" + :namespaces="availableNamespaces" toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" class="gl-h-7 gl-flex-grow-1" data-qa-selector="target_namespace_selector_dropdown" > - <gl-dropdown-item @click="$emit('update-target-namespace', '')">{{ + <gl-dropdown-item @click="$emit('update-target-namespace', { fullPath: '', id: null })">{{ s__('BulkImport|No parent') }}</gl-dropdown-item> <template v-if="namespaces.length"> @@ -94,20 +60,20 @@ export default { </gl-dropdown-section-header> <gl-dropdown-item v-for="ns in namespaces" - :key="ns" + :key="ns.fullPath" data-qa-selector="target_group_dropdown_item" - :data-qa-group-name="ns" + :data-qa-group-name="ns.fullPath" @click="$emit('update-target-namespace', ns)" > - {{ ns }} + {{ ns.fullPath }} </gl-dropdown-item> </template> </import-group-dropdown> <div class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10" :class="{ - 'gl-text-gray-400 gl-border-gray-100': !isAvailableForImport, - 'gl-border-gray-200': isAvailableForImport, + 'gl-text-gray-400 gl-border-gray-100': !group.flags.isAvailableForImport, + 'gl-border-gray-200': group.flags.isAvailableForImport, }" > / @@ -116,21 +82,21 @@ export default { <gl-form-input class="gl-rounded-top-left-none gl-rounded-bottom-left-none" :class="{ - 'gl-inset-border-1-gray-200!': isAvailableForImport, - 'gl-inset-border-1-gray-100!': !isAvailableForImport, - 'is-invalid': isInvalid && isAvailableForImport, + 'gl-inset-border-1-gray-200!': group.flags.isAvailableForImport, + 'gl-inset-border-1-gray-100!': !group.flags.isAvailableForImport, + 'is-invalid': group.flags.isInvalid && group.flags.isAvailableForImport, }" - :disabled="!isAvailableForImport" - :value="importTarget.new_name" + debounce="500" + :disabled="!group.flags.isAvailableForImport" + :value="group.importTarget.newName" + :aria-label="__('New name')" @input="$emit('update-new-name', $event)" /> - <p v-if="isInvalid" class="gl-text-red-500 gl-m-0 gl-mt-2"> - <template v-if="!isNameValid"> - {{ groupUrlErrorMessage }} - </template> - <template v-else-if="invalidNameValidationMessage"> - {{ invalidNameValidationMessage }} - </template> + <p + v-if="group.flags.isAvailableForImport && group.flags.isInvalid" + class="gl-text-red-500 gl-m-0 gl-mt-2" + > + {{ invalidNameValidationMessage }} </p> </div> </div> diff --git a/app/assets/javascripts/import_entities/import_groups/constants.js b/app/assets/javascripts/import_entities/import_groups/constants.js index b2c3d85e280..aa9cf3897e6 100644 --- a/app/assets/javascripts/import_entities/import_groups/constants.js +++ b/app/assets/javascripts/import_entities/import_groups/constants.js @@ -1,7 +1,16 @@ -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; export const i18n = { - NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'), + ERROR_INVALID_FORMAT: s__( + 'GroupSettings|Please choose a group URL with no special characters or spaces.', + ), + ERROR_NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'), + ERROR_REQUIRED: __('This field is required.'), + ERROR_NAME_ALREADY_USED_IN_SUGGESTION: s__( + 'BulkImport|Name already used as a target for another group.', + ), + ERROR_IMPORT: s__('BulkImport|Importing the group failed.'), + ERROR_IMPORT_COMPLETED: s__('BulkImport|Import is finished. Pick another name for re-import'), }; -export const NEW_NAME_FIELD = 'new_name'; +export const NEW_NAME_FIELD = 'newName'; diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js index c08cf909a00..bce6e7bcb1f 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js @@ -1,23 +1,10 @@ -import createFlash from '~/flash'; import createDefaultClient from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; -import { s__ } from '~/locale'; import { STATUSES } from '../../constants'; -import { i18n, NEW_NAME_FIELD } from '../constants'; -import { isAvailableForImport } from '../utils'; import bulkImportSourceGroupItemFragment from './fragments/bulk_import_source_group_item.fragment.graphql'; import bulkImportSourceGroupProgressFragment from './fragments/bulk_import_source_group_progress.fragment.graphql'; -import addValidationErrorMutation from './mutations/add_validation_error.mutation.graphql'; -import removeValidationErrorMutation from './mutations/remove_validation_error.mutation.graphql'; -import setImportProgressMutation from './mutations/set_import_progress.mutation.graphql'; -import setImportTargetMutation from './mutations/set_import_target.mutation.graphql'; -import updateImportStatusMutation from './mutations/update_import_status.mutation.graphql'; -import availableNamespacesQuery from './queries/available_namespaces.query.graphql'; -import bulkImportSourceGroupQuery from './queries/bulk_import_source_group.query.graphql'; -import groupAndProjectQuery from './queries/group_and_project.query.graphql'; -import { SourceGroupsManager } from './services/source_groups_manager'; -import { StatusPoller } from './services/status_poller'; +import { LocalStorageCache } from './services/local_storage_cache'; import typeDefs from './typedefs.graphql'; export const clientTypenames = { @@ -27,221 +14,99 @@ export const clientTypenames = { BulkImportPageInfo: 'ClientBulkImportPageInfo', BulkImportTarget: 'ClientBulkImportTarget', BulkImportProgress: 'ClientBulkImportProgress', - BulkImportValidationError: 'ClientBulkImportValidationError', }; -function makeGroup(data) { - const result = { - __typename: clientTypenames.BulkImportSourceGroup, +function makeLastImportTarget(data) { + return { + __typename: clientTypenames.BulkImportTarget, ...data, }; - const NESTED_OBJECT_FIELDS = { - import_target: clientTypenames.BulkImportTarget, - last_import_target: clientTypenames.BulkImportTarget, - progress: clientTypenames.BulkImportProgress, - }; - - Object.entries(NESTED_OBJECT_FIELDS).forEach(([field, type]) => { - if (!data[field]) { - return; - } - result[field] = { - __typename: type, - ...data[field], - }; - }); - - return result; } -async function checkImportTargetIsValid({ client, newName, targetNamespace, sourceGroupId }) { - const { - data: { existingGroup, existingProject }, - } = await client.query({ - query: groupAndProjectQuery, - fetchPolicy: 'no-cache', - variables: { - fullPath: `${targetNamespace}/${newName}`, - }, - }); - - const variables = { - field: NEW_NAME_FIELD, - sourceGroupId, +function makeProgress(data) { + return { + __typename: clientTypenames.BulkImportProgress, + ...data, }; - - if (!existingGroup && !existingProject) { - client.mutate({ - mutation: removeValidationErrorMutation, - variables, - }); - } else { - client.mutate({ - mutation: addValidationErrorMutation, - variables: { - ...variables, - message: i18n.NAME_ALREADY_EXISTS, - }, - }); - } } -const localProgressId = (id) => `not-started-${id}`; -const nextName = (name) => `${name}-1`; +function makeGroup(data) { + return { + __typename: clientTypenames.BulkImportSourceGroup, + ...data, + progress: data.progress + ? makeProgress({ + id: `LOCAL-PROGRESS-${data.id}`, + ...data.progress, + }) + : null, + lastImportTarget: data.lastImportTarget + ? makeLastImportTarget({ + id: data.id, + ...data.lastImportTarget, + }) + : null, + }; +} -export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) { - const groupsManager = new GroupsManager({ - sourceUrl, +function getGroupFromCache({ client, id, getCacheKey }) { + return client.readFragment({ + fragment: bulkImportSourceGroupItemFragment, + fragmentName: 'BulkImportSourceGroupItem', + id: getCacheKey({ + __typename: clientTypenames.BulkImportSourceGroup, + id, + }), }); +} - let statusPoller; +export function createResolvers({ endpoints }) { + const localStorageCache = new LocalStorageCache(); return { Query: { - async bulkImportSourceGroup(_, { id }, { client, getCacheKey }) { - return client.readFragment({ - fragment: bulkImportSourceGroupItemFragment, - fragmentName: 'BulkImportSourceGroupItem', - id: getCacheKey({ - __typename: clientTypenames.BulkImportSourceGroup, - id, - }), + async bulkImportSourceGroups(_, vars) { + const { headers, data } = await axios.get(endpoints.status, { + params: { + page: vars.page, + per_page: vars.perPage, + filter: vars.filter, + }, }); - }, - async bulkImportSourceGroups(_, vars, { client }) { - if (!statusPoller) { - statusPoller = new StatusPoller({ - updateImportStatus: ({ id, status_name: status }) => - client.mutate({ - mutation: updateImportStatusMutation, - variables: { id, status }, - }), - pollPath: endpoints.jobs, - }); - statusPoller.startPolling(); - } - - return Promise.all([ - axios.get(endpoints.status, { - params: { - page: vars.page, - per_page: vars.perPage, - filter: vars.filter, - }, - }), - client.query({ query: availableNamespacesQuery }), - ]).then( - ([ - { headers, data }, - { - data: { availableNamespaces }, - }, - ]) => { - const pagination = parseIntPagination(normalizeHeaders(headers)); - - const response = { - __typename: clientTypenames.BulkImportSourceGroupConnection, - nodes: data.importable_data.map((group) => { - const { jobId, importState: cachedImportState } = - groupsManager.getImportStateFromStorageByGroupId(group.id) ?? {}; - - const status = cachedImportState?.status ?? STATUSES.NONE; - - const importTarget = - status === STATUSES.FINISHED && cachedImportState.importTarget - ? { - target_namespace: cachedImportState.importTarget.target_namespace, - new_name: nextName(cachedImportState.importTarget.new_name), - } - : cachedImportState?.importTarget ?? { - new_name: group.full_path, - target_namespace: availableNamespaces[0]?.full_path ?? '', - }; - - return makeGroup({ - ...group, - validation_errors: [], - progress: { - id: jobId ?? localProgressId(group.id), - status, - }, - import_target: importTarget, - last_import_target: cachedImportState?.importTarget ?? null, - }); - }), - pageInfo: { - __typename: clientTypenames.BulkImportPageInfo, - ...pagination, - }, - }; - - setTimeout(() => { - response.nodes.forEach((group) => { - if (isAvailableForImport(group)) { - checkImportTargetIsValid({ - client, - newName: group.import_target.new_name, - targetNamespace: group.import_target.target_namespace, - sourceGroupId: group.id, - }); - } - }); + const pagination = parseIntPagination(normalizeHeaders(headers)); + + const response = { + __typename: clientTypenames.BulkImportSourceGroupConnection, + nodes: data.importable_data.map((group) => { + return makeGroup({ + id: group.id, + webUrl: group.web_url, + fullPath: group.full_path, + fullName: group.full_name, + ...group, + ...localStorageCache.get(group.web_url), }); - - return response; + }), + pageInfo: { + __typename: clientTypenames.BulkImportPageInfo, + ...pagination, }, - ); + }; + return response; }, availableNamespaces: () => axios.get(endpoints.availableNamespaces).then(({ data }) => data.map((namespace) => ({ __typename: clientTypenames.AvailableNamespace, - ...namespace, + id: namespace.id, + fullPath: namespace.full_path, })), ), }, Mutation: { - setImportTarget(_, { targetNamespace, newName, sourceGroupId }, { client }) { - checkImportTargetIsValid({ - client, - sourceGroupId, - targetNamespace, - newName, - }); - - return makeGroup({ - id: sourceGroupId, - import_target: { - target_namespace: targetNamespace, - new_name: newName, - id: sourceGroupId, - }, - }); - }, - - async setImportProgress(_, { sourceGroupId, status, jobId, importTarget }) { - if (jobId) { - groupsManager.updateImportProgress(jobId, status); - } - - return makeGroup({ - id: sourceGroupId, - progress: { - id: jobId ?? localProgressId(sourceGroupId), - status, - }, - last_import_target: { - __typename: clientTypenames.BulkImportTarget, - ...importTarget, - }, - }); - }, - async updateImportStatus(_, { id, status: newStatus }, { client, getCacheKey }) { - groupsManager.updateImportProgress(id, newStatus); - const progressItem = client.readFragment({ fragment: bulkImportSourceGroupProgressFragment, fragmentName: 'BulkImportSourceGroupProgress', @@ -251,133 +116,62 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr }), }); - const isInProgress = Boolean(progressItem); - const { status: currentStatus } = progressItem ?? {}; - if (newStatus === STATUSES.FINISHED && isInProgress && currentStatus !== newStatus) { - const groups = groupsManager.getImportedGroupsByJobId(id); + if (!progressItem) return null; - groups.forEach(async ({ id: groupId, importTarget }) => { - client.mutate({ - mutation: setImportTargetMutation, - variables: { - sourceGroupId: groupId, - targetNamespace: importTarget.target_namespace, - newName: nextName(importTarget.new_name), - }, - }); - }); - } + localStorageCache.updateStatusByJobId(id, newStatus); return { __typename: clientTypenames.BulkImportProgress, + ...progressItem, id, status: newStatus, }; }, - async addValidationError(_, { sourceGroupId, field, message }, { client }) { - const { - data: { - bulkImportSourceGroup: { validation_errors: validationErrors, ...group }, - }, - } = await client.query({ - query: bulkImportSourceGroupQuery, - variables: { id: sourceGroupId }, - }); + async importGroups(_, { importRequests }, { client, getCacheKey }) { + const importOperations = importRequests.map((importRequest) => { + const group = getGroupFromCache({ + client, + getCacheKey, + id: importRequest.sourceGroupId, + }); - return { - ...group, - validation_errors: [ - ...validationErrors.filter(({ field: f }) => f !== field), - { - __typename: clientTypenames.BulkImportValidationError, - field, - message, - }, - ], - }; - }, + return { + group, + ...importRequest, + }; + }); - async removeValidationError(_, { sourceGroupId, field }, { client }) { const { - data: { - bulkImportSourceGroup: { validation_errors: validationErrors, ...group }, - }, - } = await client.query({ - query: bulkImportSourceGroupQuery, - variables: { id: sourceGroupId }, + data: { id: jobId }, + } = await axios.post(endpoints.createBulkImport, { + bulk_import: importOperations.map((op) => ({ + source_type: 'group_entity', + source_full_path: op.group.fullPath, + destination_namespace: op.targetNamespace, + destination_name: op.newName, + })), }); - return { - ...group, - validation_errors: validationErrors.filter(({ field: f }) => f !== field), - }; - }, - - async importGroups(_, { sourceGroupIds }, { client }) { - const groups = await Promise.all( - sourceGroupIds.map((id) => - client - .query({ - query: bulkImportSourceGroupQuery, - variables: { id }, - }) - .then(({ data }) => data.bulkImportSourceGroup), - ), - ); + return importOperations.map((op) => { + const lastImportTarget = { + targetNamespace: op.targetNamespace, + newName: op.newName, + }; - const GROUPS_BEING_SCHEDULED = sourceGroupIds.map((sourceGroupId) => - makeGroup({ - id: sourceGroupId, - progress: { - id: localProgressId(sourceGroupId), - status: STATUSES.SCHEDULING, - }, - }), - ); - - const defaultErrorMessage = s__('BulkImport|Importing the group failed'); - axios - .post(endpoints.createBulkImport, { - bulk_import: groups.map((group) => ({ - source_type: 'group_entity', - source_full_path: group.full_path, - destination_namespace: group.import_target.target_namespace, - destination_name: group.import_target.new_name, - })), - }) - .then(({ data: { id: jobId } }) => { - groupsManager.createImportState(jobId, { - status: STATUSES.CREATED, - groups, - }); + const progress = { + id: jobId, + status: STATUSES.CREATED, + }; - return { status: STATUSES.CREATED, jobId }; - }) - .catch((e) => { - const message = e?.response?.data?.error ?? defaultErrorMessage; - createFlash({ message }); - return { status: STATUSES.NONE }; - }) - .then((newStatus) => - sourceGroupIds.forEach((sourceGroupId, idx) => - client.mutate({ - mutation: setImportProgressMutation, - variables: { sourceGroupId, ...newStatus, importTarget: groups[idx].import_target }, - }), - ), - ) - .catch(() => createFlash({ message: defaultErrorMessage })); + localStorageCache.set(op.group.webUrl, { progress, lastImportTarget }); - return GROUPS_BEING_SCHEDULED; + return makeGroup({ ...op.group, progress, lastImportTarget }); + }); }, }, }; } export const createApolloClient = ({ sourceUrl, endpoints }) => - createDefaultClient( - createResolvers({ sourceUrl, endpoints }), - { assumeImmutableResults: true }, - typeDefs, - ); + createDefaultClient(createResolvers({ sourceUrl, endpoints }), { typeDefs }); diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql index 089340b3c48..0d83be7c0e8 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql @@ -2,22 +2,15 @@ fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup { id - web_url - full_path - full_name + webUrl + fullPath + fullName + lastImportTarget { + id + targetNamespace + newName + } progress { ...BulkImportSourceGroupProgress } - import_target { - target_namespace - new_name - } - last_import_target { - target_namespace - new_name - } - validation_errors { - field - message - } } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql deleted file mode 100644 index d95c460c046..00000000000 --- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql +++ /dev/null @@ -1,9 +0,0 @@ -mutation addValidationError($sourceGroupId: String!, $field: String!, $message: String!) { - addValidationError(sourceGroupId: $sourceGroupId, field: $field, message: $message) @client { - id - validation_errors { - field - message - } - } -} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql index d8e46329e38..75215471d0f 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql @@ -1,6 +1,11 @@ -mutation importGroups($sourceGroupIds: [String!]!) { - importGroups(sourceGroupIds: $sourceGroupIds) @client { +mutation importGroups($importRequests: [ImportGroupInput!]!) { + importGroups(importRequests: $importRequests) @client { id + lastImportTarget { + id + targetNamespace + newName + } progress { id status diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql deleted file mode 100644 index 940bf4dfaac..00000000000 --- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql +++ /dev/null @@ -1,9 +0,0 @@ -mutation removeValidationError($sourceGroupId: String!, $field: String!) { - removeValidationError(sourceGroupId: $sourceGroupId, field: $field) @client { - id - validation_errors { - field - message - } - } -} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql deleted file mode 100644 index 43301554de3..00000000000 --- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql +++ /dev/null @@ -1,23 +0,0 @@ -mutation setImportProgress( - $status: String! - $sourceGroupId: String! - $jobId: String - $importTarget: ImportTargetInput! -) { - setImportProgress( - status: $status - sourceGroupId: $sourceGroupId - jobId: $jobId - importTarget: $importTarget - ) @client { - id - progress { - id - status - } - last_import_target { - target_namespace - new_name - } - } -} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql deleted file mode 100644 index 793b60ee378..00000000000 --- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql +++ /dev/null @@ -1,13 +0,0 @@ -mutation setImportTarget($newName: String!, $targetNamespace: String!, $sourceGroupId: String!) { - setImportTarget( - newName: $newName - targetNamespace: $targetNamespace - sourceGroupId: $sourceGroupId - ) @client { - id - import_target { - new_name - target_namespace - } - } -} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql index 5ab9796b50a..b0741dfbe5c 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql @@ -1,6 +1,6 @@ query availableNamespaces { availableNamespaces @client { id - full_path + fullPath } } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql deleted file mode 100644 index 0aff23af96d..00000000000 --- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql +++ /dev/null @@ -1,7 +0,0 @@ -#import "../fragments/bulk_import_source_group_item.fragment.graphql" - -query bulkImportSourceGroup($id: ID!) { - bulkImportSourceGroup(id: $id) @client { - ...BulkImportSourceGroupItem - } -} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/group_and_project.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/group_and_project.query.graphql deleted file mode 100644 index d6124f84025..00000000000 --- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/group_and_project.query.graphql +++ /dev/null @@ -1,9 +0,0 @@ -query groupAndProject($fullPath: ID!) { - existingGroup: group(fullPath: $fullPath) { - id - } - - existingProject: project(fullPath: $fullPath) { - id - } -} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js new file mode 100644 index 00000000000..09bc7b33692 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js @@ -0,0 +1,74 @@ +import { debounce, merge } from 'lodash'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; + +const OLD_KEY = 'gl-bulk-imports-import-state'; +export const KEY = 'gl-bulk-imports-import-state-v2'; +export const DEBOUNCE_INTERVAL = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; + +export class LocalStorageCache { + constructor({ storage = window.localStorage } = {}) { + this.storage = storage; + this.cache = this.loadCacheFromStorage(); + try { + // remove old storage data + this.storage.removeItem(OLD_KEY); + } catch { + // empty catch intended + } + + // cache for searching data by jobid + this.jobsLookupCache = {}; + } + + loadCacheFromStorage() { + try { + return JSON.parse(this.storage.getItem(KEY)) ?? {}; + } catch { + return {}; + } + } + + set(webUrl, data) { + this.cache[webUrl] = data; + this.saveCacheToStorage(); + // There are changes to jobIds, drop cache + this.jobsLookupCache = {}; + } + + get(webUrl) { + return this.cache[webUrl]; + } + + getCacheKeysByJobId(jobId) { + // this is invoked by polling, so we would like to cache results + if (!this.jobsLookupCache[jobId]) { + this.jobsLookupCache[jobId] = Object.keys(this.cache).filter( + (url) => this.cache[url]?.progress.id === jobId, + ); + } + + return this.jobsLookupCache[jobId]; + } + + updateStatusByJobId(jobId, status) { + this.getCacheKeysByJobId(jobId).forEach((webUrl) => + this.set(webUrl, { + ...(this.get(webUrl) ?? {}), + progress: { + id: jobId, + status, + }, + }), + ); + this.saveCacheToStorage(); + } + + saveCacheToStorage = debounce(() => { + try { + // storage might be changed in other tab so fetch first + this.storage.setItem(KEY, JSON.stringify(merge({}, this.loadCacheFromStorage(), this.cache))); + } catch { + // empty catch intentional: storage might be unavailable or full + } + }, DEBOUNCE_INTERVAL); +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js deleted file mode 100644 index 7caa37d9ad4..00000000000 --- a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js +++ /dev/null @@ -1,87 +0,0 @@ -import { debounce, merge } from 'lodash'; - -export const KEY = 'gl-bulk-imports-import-state'; -export const DEBOUNCE_INTERVAL = 200; - -export class SourceGroupsManager { - constructor({ sourceUrl, storage = window.localStorage }) { - this.sourceUrl = sourceUrl; - - this.storage = storage; - this.importStates = this.loadImportStatesFromStorage(); - } - - loadImportStatesFromStorage() { - try { - return Object.fromEntries( - Object.entries(JSON.parse(this.storage.getItem(KEY)) ?? {}).map(([jobId, config]) => { - // new format of storage - if (config.groups) { - return [jobId, config]; - } - - return [ - jobId, - { - status: config.status, - groups: [{ id: config.id, importTarget: config.importTarget }], - }, - ]; - }), - ); - } catch { - return {}; - } - } - - createImportState(importId, jobConfig) { - this.importStates[importId] = { - status: jobConfig.status, - groups: jobConfig.groups.map((g) => ({ - importTarget: { ...g.import_target }, - id: g.id, - })), - }; - this.saveImportStatesToStorage(); - } - - updateImportProgress(importId, status) { - const currentState = this.importStates[importId]; - if (!currentState) { - return; - } - - currentState.status = status; - this.saveImportStatesToStorage(); - } - - getImportedGroupsByJobId(jobId) { - return this.importStates[jobId]?.groups ?? []; - } - - getImportStateFromStorageByGroupId(groupId) { - const [jobId, importState] = - Object.entries(this.importStates) - .reverse() - .find(([, state]) => state.groups.some((g) => g.id === groupId)) ?? []; - - if (!jobId) { - return null; - } - - const group = importState.groups.find((g) => g.id === groupId); - return { jobId, importState: { ...group, status: importState.status } }; - } - - saveImportStatesToStorage = debounce(() => { - try { - // storage might be changed in other tab so fetch first - this.storage.setItem( - KEY, - JSON.stringify(merge({}, this.loadImportStatesFromStorage(), this.importStates)), - ); - } catch { - // empty catch intentional: storage might be unavailable or full - } - }, DEBOUNCE_INTERVAL); -} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js deleted file mode 100644 index 0297b3d3428..00000000000 --- a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js +++ /dev/null @@ -1,35 +0,0 @@ -import Visibility from 'visibilityjs'; -import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import Poll from '~/lib/utils/poll'; -import { s__ } from '~/locale'; - -export class StatusPoller { - constructor({ updateImportStatus, pollPath }) { - this.eTagPoll = new Poll({ - resource: { - fetchJobs: () => axios.get(pollPath), - }, - method: 'fetchJobs', - successCallback: ({ data: statuses }) => { - statuses.forEach((status) => updateImportStatus(status)); - }, - errorCallback: () => - createFlash({ - message: s__('BulkImport|Update of import statuses with realtime changes failed'), - }), - }); - - Visibility.change(() => { - if (!Visibility.hidden()) { - this.eTagPoll.restart(); - } else { - this.eTagPoll.stop(); - } - }); - } - - startPolling() { - this.eTagPoll.makeRequest(); - } -} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql index 6ef4bbafec0..b8dd79a5000 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql @@ -1,11 +1,11 @@ type ClientBulkImportAvailableNamespace { id: ID! - full_path: String! + fullPath: String! } type ClientBulkImportTarget { - target_namespace: String! - new_name: String! + targetNamespace: String! + newName: String! } type ClientBulkImportSourceGroupConnection { @@ -14,7 +14,7 @@ type ClientBulkImportSourceGroupConnection { } type ClientBulkImportProgress { - id: ID + id: ID! status: String! } @@ -25,13 +25,11 @@ type ClientBulkImportValidationError { type ClientBulkImportSourceGroup { id: ID! - web_url: String! - full_path: String! - full_name: String! - progress: ClientBulkImportProgress! - import_target: ClientBulkImportTarget! - last_import_target: ClientBulkImportTarget - validation_errors: [ClientBulkImportValidationError!]! + webUrl: String! + fullPath: String! + fullName: String! + lastImportTarget: ClientBulkImportTarget + progress: ClientBulkImportProgress } type ClientBulkImportPageInfo { @@ -41,8 +39,13 @@ type ClientBulkImportPageInfo { totalPages: Int! } +type ClientBulkImportNamespaceSuggestion { + id: ID! + exists: Boolean! + suggestions: [String!]! +} + extend type Query { - bulkImportSourceGroup(id: ID!): ClientBulkImportSourceGroup bulkImportSourceGroups( page: Int! perPage: Int! @@ -51,26 +54,13 @@ extend type Query { availableNamespaces: [ClientBulkImportAvailableNamespace!]! } -input InputTargetInput { - target_namespace: String! - new_name: String! +input ImportRequestInput { + sourceGroupId: ID! + targetNamespace: String! + newName: String! } extend type Mutation { - setNewName(newName: String, sourceGroupId: ID!): ClientBulkImportSourceGroup! - setTargetNamespace(targetNamespace: String, sourceGroupId: ID!): ClientBulkImportSourceGroup! - importGroups(sourceGroupIds: [ID!]!): [ClientBulkImportSourceGroup!]! - setImportProgress( - id: ID - status: String! - jobId: String - importTarget: ImportTargetInput! - ): ClientBulkImportSourceGroup! - updateImportProgress(id: ID, status: String!): ClientBulkImportProgress - addValidationError( - sourceGroupId: ID! - field: String! - message: String! - ): ClientBulkImportSourceGroup! - removeValidationError(sourceGroupId: ID!, field: String!): ClientBulkImportSourceGroup! + importGroups(importRequests: [ImportRequestInput!]!): [ClientBulkImportSourceGroup!]! + updateImportStatus(id: ID, status: String!): ClientBulkImportProgress } diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js index 07b839c5c82..67a7258d504 100644 --- a/app/assets/javascripts/import_entities/import_groups/index.js +++ b/app/assets/javascripts/import_entities/import_groups/index.js @@ -17,7 +17,6 @@ export function mountImportGroupsApp(mountElement) { jobsPath, sourceUrl, groupPathRegex, - groupUrlErrorMessage, } = mountElement.dataset; const apolloProvider = new VueApollo({ defaultClient: createApolloClient({ @@ -26,7 +25,6 @@ export function mountImportGroupsApp(mountElement) { status: statusPath, availableNamespaces: availableNamespacesPath, createBulkImport: createBulkImportPath, - jobs: jobsPath, }, }), }); @@ -38,8 +36,8 @@ export function mountImportGroupsApp(mountElement) { return createElement(ImportTable, { props: { sourceUrl, + jobsPath, groupPathRegex: new RegExp(`^(${groupPathRegex})$`), - groupUrlErrorMessage, }, }); }, diff --git a/app/assets/javascripts/import_entities/import_groups/services/status_poller.js b/app/assets/javascripts/import_entities/import_groups/services/status_poller.js new file mode 100644 index 00000000000..ba0f2bb947a --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/services/status_poller.js @@ -0,0 +1,39 @@ +import Visibility from 'visibilityjs'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import Poll from '~/lib/utils/poll'; +import { s__ } from '~/locale'; + +export class StatusPoller { + constructor({ updateImportStatus, pollPath }) { + this.eTagPoll = new Poll({ + resource: { + fetchJobs: () => axios.get(pollPath), + }, + method: 'fetchJobs', + successCallback: ({ data: statuses }) => { + statuses.forEach((status) => updateImportStatus(status)); + }, + errorCallback: () => + createFlash({ + message: s__('BulkImport|Update of import statuses with realtime changes failed'), + }), + }); + + Visibility.change(() => { + if (!Visibility.hidden()) { + this.eTagPoll.restart(); + } else { + this.eTagPoll.stop(); + } + }); + } + + startPolling() { + this.eTagPoll.makeRequest(); + } + + stopPolling() { + this.eTagPoll.stop(); + } +} diff --git a/app/assets/javascripts/import_entities/import_groups/utils.js b/app/assets/javascripts/import_entities/import_groups/utils.js index a1baeaf39dd..1d0ab75e1cb 100644 --- a/app/assets/javascripts/import_entities/import_groups/utils.js +++ b/app/assets/javascripts/import_entities/import_groups/utils.js @@ -1,22 +1,25 @@ import { STATUSES } from '../constants'; import { NEW_NAME_FIELD } from './constants'; -export function isNameValid(group, validationRegex) { - return validationRegex.test(group.import_target[NEW_NAME_FIELD]); +export function isNameValid(importTarget, validationRegex) { + return validationRegex.test(importTarget[NEW_NAME_FIELD]); } -export function getInvalidNameValidationMessage(group) { - return group.validation_errors.find(({ field }) => field === NEW_NAME_FIELD)?.message; -} - -export function isInvalid(group, validationRegex) { - return Boolean(!isNameValid(group, validationRegex) || getInvalidNameValidationMessage(group)); +export function getInvalidNameValidationMessage(importTarget) { + return importTarget.validationErrors?.find(({ field }) => field === NEW_NAME_FIELD)?.message; } export function isFinished(group) { - return group.progress.status === STATUSES.FINISHED; + return [STATUSES.FINISHED, STATUSES.FAILED].includes(group.progress?.status); } export function isAvailableForImport(group) { - return [STATUSES.NONE, STATUSES.FINISHED].some((status) => group.progress.status === status); + return !group.progress || isFinished(group); +} + +export function isSameTarget(importTarget) { + return (target) => + target !== importTarget && + target.newName.toLowerCase() === importTarget.newName.toLowerCase() && + target.targetNamespace.id === importTarget.targetNamespace.id; } diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue index 0cd3519bcec..b9f0b5012ac 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue @@ -46,10 +46,6 @@ export default { return `${this.filter}-${this.repositories.length}-${this.pageInfo.page}`; }, - availableNamespaces() { - return this.namespaces.map(({ fullPath }) => fullPath); - }, - importAllButtonText() { if (this.isImportingAnyRepo) { return n__('Importing %d repository', 'Importing %d repositories', this.importingRepoCount); @@ -167,7 +163,7 @@ export default { <provider-repo-table-row :key="repo.importSource.providerLink" :repo="repo" - :available-namespaces="availableNamespaces" + :available-namespaces="namespaces" :user-namespace="defaultTargetNamespace" /> </template> diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue index a97af5367fb..c3d0ca4ed8c 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue @@ -128,17 +128,17 @@ export default { <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header> <gl-dropdown-item v-for="ns in namespaces" - :key="ns" + :key="ns.fullPath" data-qa-selector="target_group_dropdown_item" - :data-qa-group-name="ns" - @click="updateImportTarget({ targetNamespace: ns })" + :data-qa-group-name="ns.fullPath" + @click="updateImportTarget({ targetNamespace: ns.fullPath })" > - {{ ns }} + {{ ns.fullPath }} </gl-dropdown-item> <gl-dropdown-divider /> </template> <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header> - <gl-dropdown-item @click="updateImportTarget({ targetNamespace: ns })">{{ + <gl-dropdown-item @click="updateImportTarget({ targetNamespace: userNamespace })">{{ userNamespace }}</gl-dropdown-item> </import-group-dropdown> diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index 4d34daa43ba..37597da3c8e 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -125,6 +125,7 @@ export default { 'authorUsernameQuery', 'assigneeUsernameQuery', 'slaFeatureAvailable', + 'canCreateIncident', ], apollo: { incidents: { @@ -230,13 +231,16 @@ export default { }, emptyStateData() { const { - emptyState: { title, emptyClosedTabTitle, description }, + emptyState: { title, emptyClosedTabTitle, description, cannotCreateIncidentDescription }, createIncidentBtnLabel, } = this.$options.i18n; if (this.activeClosedTabHasNoIncidents) { return { title: emptyClosedTabTitle }; } + if (!this.canCreateIncident) { + return { title, description: cannotCreateIncidentDescription }; + } return { title, description, @@ -244,6 +248,9 @@ export default { btnText: createIncidentBtnLabel, }; }, + isHeaderButtonVisible() { + return this.canCreateIncident && (!this.isEmpty || this.activeClosedTabHasNoIncidents); + }, }, methods: { hasAssignees(assignees) { @@ -311,7 +318,7 @@ export default { > <template #header-actions> <gl-button - v-if="!isEmpty || activeClosedTabHasNoIncidents" + v-if="isHeaderButtonVisible" class="gl-my-3 gl-mr-5 create-incident-button" data-testid="createIncidentBtn" data-qa-selector="create_incident_button" diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js index b82980b5628..23909ae3b6c 100644 --- a/app/assets/javascripts/incidents/constants.js +++ b/app/assets/javascripts/incidents/constants.js @@ -11,7 +11,10 @@ export const I18N = { title: s__('IncidentManagement|Display your incidents in a dedicated view'), emptyClosedTabTitle: s__('IncidentManagement|There are no closed incidents'), description: s__( - 'IncidentManagement|All alerts promoted to incidents will automatically be displayed within the list. You can also create a new incident using the button below.', + 'IncidentManagement|All alerts promoted to incidents are automatically displayed within the list. You can also create a new incident using the button below.', + ), + cannotCreateIncidentDescription: s__( + 'IncidentManagement|All alerts promoted to incidents are automatically displayed within the list.', ), }, }; diff --git a/app/assets/javascripts/incidents/list.js b/app/assets/javascripts/incidents/list.js index 6e6461cd7a9..1d40f1093a4 100644 --- a/app/assets/javascripts/incidents/list.js +++ b/app/assets/javascripts/incidents/list.js @@ -21,10 +21,11 @@ export default () => { authorUsernameQuery, assigneeUsernameQuery, slaFeatureAvailable, + canCreateIncident, } = domEl.dataset; const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), + defaultClient: createDefaultClient(), }); return new Vue({ @@ -44,6 +45,7 @@ export default () => { authorUsernameQuery, assigneeUsernameQuery, slaFeatureAvailable: parseBoolean(slaFeatureAvailable), + canCreateIncident: parseBoolean(canCreateIncident), }, apolloProvider, render(createElement) { diff --git a/app/assets/javascripts/init_confirm_danger.js b/app/assets/javascripts/init_confirm_danger.js new file mode 100644 index 00000000000..d3d32c8be54 --- /dev/null +++ b/app/assets/javascripts/init_confirm_danger.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import { parseBoolean } from './lib/utils/common_utils'; +import ConfirmDanger from './vue_shared/components/confirm_danger/confirm_danger.vue'; + +export default () => { + const el = document.querySelector('.js-confirm-danger'); + if (!el) return null; + + const { + removeFormId = null, + phrase, + buttonText, + buttonTestid = null, + confirmDangerMessage, + disabled = false, + } = el.dataset; + + return new Vue({ + el, + provide: { + confirmDangerMessage, + }, + render: (createElement) => + createElement(ConfirmDanger, { + props: { + phrase, + buttonText, + buttonTestid, + disabled: parseBoolean(disabled), + }, + on: { + confirm: () => { + if (removeFormId) document.getElementById(removeFormId)?.submit(); + }, + }, + }), + }); +}; diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index 8a8d38b295c..d214ee4ded6 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; export const TEST_INTEGRATION_EVENT = 'testIntegration'; export const SAVE_INTEGRATION_EVENT = 'saveIntegration'; @@ -21,3 +21,9 @@ export const overrideDropdownDescriptions = { 'Integrations|Default settings are inherited from the instance level.', ), }; + +export const I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE = s__( + 'Integrations|Connection failed. Please check your settings.', +); +export const I18N_DEFAULT_ERROR_MESSAGE = __('Something went wrong on our end.'); +export const I18N_SUCCESSFUL_CONNECTION_MESSAGE = s__('Integrations|Connection successful.'); diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue index f30298676df..258cd1bf365 100644 --- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -62,6 +62,14 @@ export default { required: false, default: null, }, + /** + * The label that is displayed inline with the checkbox. + */ + checkboxLabel: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -152,7 +160,7 @@ export default { <template v-if="isCheckbox"> <input :name="fieldName" type="hidden" :value="model || false" /> <gl-form-checkbox :id="fieldId" v-model="model" :disabled="isInheriting"> - {{ humanizedTitle }} + {{ checkboxLabel || humanizedTitle }} </gl-form-checkbox> </template> <template v-else-if="isSelect"> diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue index 0521e1eeea5..7cbfb35aeaa 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -5,6 +5,7 @@ import { VALIDATE_INTEGRATION_FORM_EVENT, GET_JIRA_ISSUE_TYPES_EVENT, } from '~/integrations/constants'; +import { s__, __ } from '~/locale'; import eventHub from '../event_hub'; import JiraUpgradeCta from './jira_upgrade_cta.vue'; @@ -94,33 +95,38 @@ export default { eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT); }, }, + i18n: { + sectionTitle: s__('JiraService|View Jira issues in GitLab'), + sectionDescription: s__( + 'JiraService|Work on Jira issues without leaving GitLab. Adds a Jira menu to access your list of Jira issues and view any issue as read-only.', + ), + enableCheckboxLabel: s__('JiraService|Enable Jira issues'), + enableCheckboxHelp: s__( + 'JiraService|Warning: All GitLab users that have access to this GitLab project are able to view all issues from the Jira project specified below.', + ), + projectKeyLabel: s__('JiraService|Jira project key'), + projectKeyPlaceholder: s__('JiraService|For example, AB'), + requiredFieldFeedback: __('This field is required.'), + issueTrackerConflictWarning: s__( + 'JiraService|Displaying Jira issues while leaving the GitLab issue functionality enabled might be confusing. Consider %{linkStart}disabling GitLab issues%{linkEnd} if they won’t otherwise be used.', + ), + }, }; </script> <template> <div> - <gl-form-group - :label="s__('JiraService|View Jira issues in GitLab')" - label-for="jira-issue-settings" - > + <gl-form-group :label="$options.i18n.sectionTitle" label-for="jira-issue-settings"> <div id="jira-issue-settings"> <p> - {{ - s__( - 'JiraService|Work on Jira issues without leaving GitLab. Adds a Jira menu to access your list of Jira issues and view any issue as read-only.', - ) - }} + {{ $options.i18n.sectionDescription }} </p> <template v-if="showJiraIssuesIntegration"> <input name="service[issues_enabled]" type="hidden" :value="enableJiraIssues || false" /> <gl-form-checkbox v-model="enableJiraIssues" :disabled="isInheriting"> - {{ s__('JiraService|Enable Jira issues') }} + {{ $options.i18n.enableCheckboxLabel }} <template #help> - {{ - s__( - 'JiraService|Warning: All GitLab users that have access to this GitLab project are able to view all issues from the Jira project specified below.', - ) - }} + {{ $options.i18n.enableCheckboxHelp }} </template> </gl-form-checkbox> <template v-if="enableJiraIssues"> @@ -152,30 +158,25 @@ export default { </gl-form-group> <template v-if="showJiraIssuesIntegration"> <gl-form-group - :label="s__('JiraService|Jira project key')" + :label="$options.i18n.projectKeyLabel" label-for="service_project_key" - :invalid-feedback="__('This field is required.')" + :invalid-feedback="$options.i18n.requiredFieldFeedback" :state="validProjectKey" + data-testid="project-key-form-group" > <gl-form-input id="service_project_key" v-model="projectKey" name="service[project_key]" - :placeholder="s__('JiraService|For example, AB')" + :placeholder="$options.i18n.projectKeyPlaceholder" :required="enableJiraIssues" :state="validProjectKey" :disabled="!enableJiraIssues" :readonly="isInheriting" /> </gl-form-group> - <p v-if="gitlabIssuesEnabled"> - <gl-sprintf - :message=" - s__( - 'JiraService|Displaying Jira issues while leaving the GitLab issue functionality enabled might be confusing. Consider %{linkStart}disabling GitLab issues%{linkEnd} if they won’t otherwise be used.', - ) - " - > + <p v-if="gitlabIssuesEnabled" data-testid="conflict-warning-text"> + <gl-sprintf :message="$options.i18n.issueTrackerConflictWarning"> <template #link="{ content }"> <gl-link :href="editProjectPath" target="_blank">{{ content }}</gl-link> </template> diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js index f33364d5545..f519fc87c46 100644 --- a/app/assets/javascripts/integrations/integration_settings_form.js +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -1,5 +1,4 @@ import { delay } from 'lodash'; -import { __, s__ } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; import axios from '../lib/utils/axios_utils'; import initForm from './edit'; @@ -10,6 +9,9 @@ import { GET_JIRA_ISSUE_TYPES_EVENT, TOGGLE_INTEGRATION_EVENT, VALIDATE_INTEGRATION_FORM_EVENT, + I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, + I18N_DEFAULT_ERROR_MESSAGE, + I18N_SUCCESSFUL_CONNECTION_MESSAGE, } from './constants'; export default class IntegrationSettingsForm { @@ -104,11 +106,7 @@ export default class IntegrationSettingsForm { return this.fetchTestSettings(formData) .then( ({ - data: { - issuetypes, - error, - message = s__('Integrations|Connection failed. Please check your settings.'), - }, + data: { issuetypes, error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE }, }) => { if (error || !issuetypes?.length) { eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); @@ -118,7 +116,7 @@ export default class IntegrationSettingsForm { dispatch('receiveJiraIssueTypesSuccess', issuetypes); }, ) - .catch(({ message = __('Something went wrong on our end.') }) => { + .catch(({ message = I18N_DEFAULT_ERROR_MESSAGE }) => { dispatch('receiveJiraIssueTypesError', message); }); } @@ -140,11 +138,11 @@ export default class IntegrationSettingsForm { toast(`${data.message} ${data.service_response}`); } else { this.vue.$store.dispatch('receiveJiraIssueTypesSuccess', data.issuetypes); - toast(s__('Integrations|Connection successful.')); + toast(I18N_SUCCESSFUL_CONNECTION_MESSAGE); } }) .catch(() => { - toast(__('Something went wrong on our end.')); + toast(I18N_DEFAULT_ERROR_MESSAGE); }) .finally(() => { this.vue.$store.dispatch('setIsTesting', false); diff --git a/app/assets/javascripts/invite_members/components/confetti.vue b/app/assets/javascripts/invite_members/components/confetti.vue new file mode 100644 index 00000000000..2e5744afcd4 --- /dev/null +++ b/app/assets/javascripts/invite_members/components/confetti.vue @@ -0,0 +1,33 @@ +<script> +import confetti from 'canvas-confetti'; + +export default { + mounted() { + confetti.create(this.$refs.canvas, { + resize: true, + useWorker: true, + disableForReducedMotion: true, + }); + + this.basicCannon(); + }, + methods: { + basicCannon() { + confetti({ + particleCount: 100, + spread: 70, + origin: { y: 0.2 }, + scalar: 2, + shapes: ['square'], + colors: ['#FC6D26', '#6B4FBB', '#FDB997'], + zIndex: 1045, + gravity: 1.5, + }); + }, + }, +}; +</script> + +<template> + <canvas ref="canvas" width="0" height="0"></canvas> +</template> diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index cd0b413265b..cf4f434a7a8 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -1,5 +1,6 @@ <script> import { + GlAlert, GlFormGroup, GlModal, GlDropdown, @@ -11,29 +12,34 @@ import { GlFormInput, GlFormCheckboxGroup, } from '@gitlab/ui'; -import { partition, isString, unescape } from 'lodash'; +import { partition, isString, unescape, uniqueId } from 'lodash'; import Api from '~/api'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import { sanitize } from '~/lib/dompurify'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; -import { s__, sprintf } from '~/locale'; +import { getParameterValues } from '~/lib/utils/url_utility'; +import { sprintf } from '~/locale'; import { INVITE_MEMBERS_IN_COMMENT, GROUP_FILTERS, USERS_FILTER_ALL, MEMBER_AREAS_OF_FOCUS, + INVITE_MEMBERS_FOR_TASK, + MODAL_LABELS, } from '../constants'; import eventHub from '../event_hub'; import { responseMessageFromError, responseMessageFromSuccess, } from '../utils/response_message_parser'; +import ModalConfetti from './confetti.vue'; import GroupSelect from './group_select.vue'; import MembersTokenSelect from './members_token_select.vue'; export default { name: 'InviteMembersModal', components: { + GlAlert, GlFormGroup, GlDatepicker, GlLink, @@ -46,7 +52,9 @@ export default { GlFormCheckboxGroup, MembersTokenSelect, GroupSelect, + ModalConfetti, }, + inject: ['newProjectPath'], props: { id: { type: String, @@ -100,36 +108,54 @@ export default { type: Array, required: true, }, + tasksToBeDoneOptions: { + type: Array, + required: true, + }, + projects: { + type: Array, + required: true, + }, }, data() { return { visible: true, - modalId: 'invite-members-modal', + modalId: uniqueId('invite-members-modal-'), selectedAccessLevel: this.defaultAccessLevel, inviteeType: 'members', newUsersToInvite: [], selectedDate: undefined, selectedAreasOfFocus: [], + selectedTasksToBeDone: [], + selectedTaskProject: this.projects[0], groupToBeSharedWith: {}, source: 'unknown', invalidFeedbackMessage: '', isLoading: false, + mode: 'default', }; }, computed: { + isCelebration() { + return this.mode === 'celebrate'; + }, validationState() { return this.invalidFeedbackMessage === '' ? null : false; }, isInviteGroup() { return this.inviteeType === 'group'; }, + modalTitle() { + return this.$options.labels[this.inviteeType].modal[this.mode].title; + }, introText() { - const inviteTo = this.isProject ? 'toProject' : 'toGroup'; - - return sprintf(this.$options.labels[this.inviteeType][inviteTo].introText, { + return sprintf(this.$options.labels[this.inviteeType][this.inviteTo][this.mode].introText, { name: this.name, }); }, + inviteTo() { + return this.isProject ? 'toProject' : 'toGroup'; + }, toastOptions() { return { onComplete: () => { @@ -156,7 +182,7 @@ export default { ); }, areasOfFocusEnabled() { - return this.areasOfFocusOptions.length !== 0; + return !this.tasksToBeDoneEnabled && this.areasOfFocusOptions.length !== 0; }, areasOfFocusForPost() { if (this.selectedAreasOfFocus.length === 0 && this.areasOfFocusEnabled) { @@ -172,12 +198,40 @@ export default { return this.$options.labels[this.inviteeType].placeHolder; }, + tasksToBeDoneEnabled() { + return ( + getParameterValues('open_modal')[0] === 'invite_members_for_task' && + this.tasksToBeDoneOptions.length + ); + }, + showTasksToBeDone() { + return ( + this.tasksToBeDoneEnabled && + this.selectedAccessLevel >= INVITE_MEMBERS_FOR_TASK.minimum_access_level + ); + }, + showTaskProjects() { + return !this.isProject && this.selectedTasksToBeDone.length; + }, + tasksToBeDoneForPost() { + return this.showTasksToBeDone ? this.selectedTasksToBeDone : []; + }, + tasksProjectForPost() { + return this.showTasksToBeDone && this.selectedTasksToBeDone.length + ? this.selectedTaskProject.id + : ''; + }, }, mounted() { eventHub.$on('openModal', (options) => { this.openModal(options); this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.view); }); + + if (this.tasksToBeDoneEnabled) { + this.openModal({ inviteeType: 'members', source: 'in_product_marketing_email' }); + this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, INVITE_MEMBERS_FOR_TASK.view); + } }, methods: { partitionNewUsersToInvite() { @@ -191,7 +245,8 @@ export default { usersToAddById.map((user) => user.id).join(','), ]; }, - openModal({ inviteeType, source }) { + openModal({ mode = 'default', inviteeType, source }) { + this.mode = mode; this.inviteeType = inviteeType; this.source = source; @@ -219,6 +274,12 @@ export default { this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.submit); }, + trackinviteMembersForTask() { + const label = 'selected_tasks_to_be_done'; + const property = this.selectedTasksToBeDone.join(','); + const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property }); + tracking.event(INVITE_MEMBERS_FOR_TASK.submit); + }, resetFields() { this.isLoading = false; this.selectedAccessLevel = this.defaultAccessLevel; @@ -227,10 +288,15 @@ export default { this.groupToBeSharedWith = {}; this.invalidFeedbackMessage = ''; this.selectedAreasOfFocus = []; + this.selectedTasksToBeDone = []; + [this.selectedTaskProject] = this.projects; }, changeSelectedItem(item) { this.selectedAccessLevel = item; }, + changeSelectedTaskProject(project) { + this.selectedTaskProject = project; + }, submitShareWithGroup() { const apiShareWithGroup = this.isProject ? Api.projectShareWithGroup.bind(Api) @@ -263,6 +329,7 @@ export default { promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById))); } this.trackInvite(); + this.trackinviteMembersForTask(); Promise.all(promises) .then(this.conditionallyShowToastSuccess) @@ -275,6 +342,8 @@ export default { access_level: this.selectedAccessLevel, invite_source: this.source, areas_of_focus: this.areasOfFocusForPost, + tasks_to_be_done: this.tasksToBeDoneForPost, + tasks_project_id: this.tasksProjectForPost, }; }, addByUserIdPostData(usersToAddById) { @@ -284,6 +353,8 @@ export default { access_level: this.selectedAccessLevel, invite_source: this.source, areas_of_focus: this.areasOfFocusForPost, + tasks_to_be_done: this.tasksToBeDoneForPost, + tasks_project_id: this.tasksProjectForPost, }; }, shareWithGroupPostData(groupToBeSharedWith) { @@ -322,49 +393,7 @@ export default { return unescape(sanitize(message, { ALLOWED_TAGS: [] })); }, }, - labels: { - members: { - modalTitle: s__('InviteMembersModal|Invite members'), - searchField: s__('InviteMembersModal|GitLab member or email address'), - placeHolder: s__('InviteMembersModal|Select members or type email addresses'), - toGroup: { - introText: s__( - "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} group.", - ), - }, - toProject: { - introText: s__( - "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} project.", - ), - }, - }, - group: { - modalTitle: s__('InviteMembersModal|Invite a group'), - searchField: s__('InviteMembersModal|Select a group to invite'), - placeHolder: s__('InviteMembersModal|Search for a group to invite'), - toGroup: { - introText: s__( - "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} group.", - ), - }, - toProject: { - introText: s__( - "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} project.", - ), - }, - }, - accessLevel: s__('InviteMembersModal|Select a role'), - accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'), - toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'), - invalidFeedbackMessageDefault: s__('InviteMembersModal|Something went wrong'), - readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`), - inviteButtonText: s__('InviteMembersModal|Invite'), - cancelButtonText: s__('InviteMembersModal|Cancel'), - headerCloseLabel: s__('InviteMembersModal|Close invite team members'), - areasOfFocusLabel: s__( - 'InviteMembersModal|What would you like new member(s) to focus on? (optional)', - ), - }, + labels: MODAL_LABELS, membersTokenSelectLabelId: 'invite-members-input', }; </script> @@ -374,20 +403,29 @@ export default { :modal-id="modalId" size="sm" data-qa-selector="invite_members_modal_content" - :title="$options.labels[inviteeType].modalTitle" + data-testid="invite-members-modal" + :title="modalTitle" :header-close-label="$options.labels.headerCloseLabel" @hidden="resetFields" @close="resetFields" @hide="resetFields" > <div> - <p ref="introText"> - <gl-sprintf :message="introText"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </p> + <div class="gl-display-flex"> + <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div> + <div> + <p ref="introText"> + <gl-sprintf :message="introText"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + <br /> + <span v-if="isCelebration">{{ $options.labels.members.modal.celebrate.intro }} </span> + <modal-confetti v-if="isCelebration" /> + </p> + </div> + </div> <gl-form-group :invalid-feedback="invalidFeedbackMessage" @@ -476,24 +514,70 @@ export default { data-testid="area-of-focus-checks" /> </div> + <div v-if="showTasksToBeDone" data-testid="invite-members-modal-tasks-to-be-done"> + <label class="gl-mt-5"> + {{ $options.labels.members.tasksToBeDone.title }} + </label> + <template v-if="projects.length"> + <gl-form-checkbox-group + v-model="selectedTasksToBeDone" + :options="tasksToBeDoneOptions" + data-testid="invite-members-modal-tasks" + /> + <template v-if="showTaskProjects"> + <label class="gl-mt-5 gl-display-block"> + {{ $options.labels.members.tasksProject.title }} + </label> + <gl-dropdown + class="gl-w-half gl-xs-w-full" + :text="selectedTaskProject.title" + data-testid="invite-members-modal-project-select" + > + <template v-for="project in projects"> + <gl-dropdown-item + :key="project.id" + active-class="is-active" + is-check-item + :is-checked="project.id === selectedTaskProject.id" + @click="changeSelectedTaskProject(project)" + > + {{ project.title }} + </gl-dropdown-item> + </template> + </gl-dropdown> + </template> + </template> + <gl-alert + v-else-if="tasksToBeDoneEnabled" + variant="tip" + :dismissible="false" + data-testid="invite-members-modal-no-projects-alert" + > + <gl-sprintf :message="$options.labels.members.tasksToBeDone.noProjects"> + <template #link="{ content }"> + <gl-link :href="newProjectPath" target="_blank" class="gl-label-link"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> + </div> </div> <template #modal-footer> - <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0"> - <gl-button data-testid="cancel-button" @click="closeModal"> - {{ $options.labels.cancelButtonText }} - </gl-button> - <div class="gl-mr-3"></div> - <gl-button - :disabled="inviteDisabled" - :loading="isLoading" - variant="success" - data-qa-selector="invite_button" - data-testid="invite-button" - @click="sendInvite" - >{{ $options.labels.inviteButtonText }}</gl-button - > - </div> + <gl-button data-testid="cancel-button" @click="closeModal"> + {{ $options.labels.cancelButtonText }} + </gl-button> + <gl-button + :disabled="inviteDisabled" + :loading="isLoading" + variant="success" + data-qa-selector="invite_button" + data-testid="invite-button" + @click="sendInvite" + > + {{ $options.labels.inviteButtonText }} + </gl-button> </template> </gl-modal> </template> diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue index 05be427742c..bf3250f63a5 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -1,11 +1,12 @@ <script> -import { GlButton, GlLink } from '@gitlab/ui'; +import { GlButton, GlLink, GlIcon } from '@gitlab/ui'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; +import { TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV } from '../constants'; export default { - components: { GlButton, GlLink }, + components: { GlButton, GlLink, GlIcon }, props: { displayText: { type: String, @@ -53,13 +54,11 @@ export default { }, }, computed: { - isButton() { - return this.triggerElement === 'button'; - }, componentAttributes() { const baseAttributes = { class: this.classes, 'data-qa-selector': 'invite_members_button', + 'data-test-id': 'invite-members-button', }; if (this.event && this.label) { @@ -77,6 +76,9 @@ export default { this.trackExperimentOnShow(); }, methods: { + checkTrigger(targetTriggerElement) { + return this.triggerElement === targetTriggerElement; + }, openModal() { eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource }); }, @@ -87,12 +89,14 @@ export default { } }, }, + TRIGGER_ELEMENT_BUTTON, + TRIGGER_ELEMENT_SIDE_NAV, }; </script> <template> <gl-button - v-if="isButton" + v-if="checkTrigger($options.TRIGGER_ELEMENT_BUTTON)" v-bind="componentAttributes" :variant="variant" :icon="icon" @@ -100,6 +104,17 @@ export default { > {{ displayText }} </gl-button> + <gl-link + v-else-if="checkTrigger($options.TRIGGER_ELEMENT_SIDE_NAV)" + v-bind="componentAttributes" + data-is-link="true" + @click="openModal" + > + <span class="nav-icon-container"> + <gl-icon :name="icon" /> + </span> + <span class="nav-item-name"> {{ displayText }} </span> + </gl-link> <gl-link v-else v-bind="componentAttributes" data-is-link="true" @click="openModal"> {{ displayText }} </gl-link> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index d7daf83e26b..59d4c2f3077 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -1,4 +1,4 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; export const SEARCH_DELAY = 200; @@ -8,6 +8,12 @@ export const MEMBER_AREAS_OF_FOCUS = { view: 'view', submit: 'submit', }; +export const INVITE_MEMBERS_FOR_TASK = { + minimum_access_level: 30, + name: 'invite_members_for_task', + view: 'modal_opened_from_email', + submit: 'submit', +}; export const GROUP_FILTERS = { ALL: 'all', @@ -19,3 +25,122 @@ export const API_MESSAGES = { }; export const USERS_FILTER_ALL = 'all'; export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id'; +export const TRIGGER_ELEMENT_BUTTON = 'button'; +export const TRIGGER_ELEMENT_SIDE_NAV = 'side-nav'; +export const MEMBERS_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite members'); +export const MEMBERS_MODAL_CELEBRATE_TITLE = s__( + 'InviteMembersModal|GitLab is better with colleagues!', +); +export const MEMBERS_MODAL_CELEBRATE_INTRO = s__( + 'InviteMembersModal|How about inviting a colleague or two to join you?', +); +export const MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT = s__( + "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} group.", +); + +export const MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT = s__( + "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} project.", +); +export const MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT = s__( + "InviteMembersModal|Congratulations on creating your project, you're almost there!", +); +export const MEMBERS_SEARCH_FIELD = s__('InviteMembersModal|GitLab member or email address'); +export const MEMBERS_PLACEHOLDER = s__('InviteMembersModal|Select members or type email addresses'); +export const MEMBERS_TASKS_TO_BE_DONE_TITLE = s__( + 'InviteMembersModal|Create issues for your new team member to work on (optional)', +); +export const MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS = s__( + 'InviteMembersModal|To assign issues to a new team member, you need a project for the issues. %{linkStart}Create a project to get started.%{linkEnd}', +); +export const MEMBERS_TASKS_PROJECTS_TITLE = s__( + 'InviteMembersModal|Choose a project for the issues', +); + +export const GROUP_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite a group'); +export const GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT = s__( + "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} group.", +); +export const GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT = s__( + "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} project.", +); + +export const GROUP_SEARCH_FIELD = s__('InviteMembersModal|Select a group to invite'); +export const GROUP_PLACEHOLDER = s__('InviteMembersModal|Search for a group to invite'); + +export const ACCESS_LEVEL = s__('InviteMembersModal|Select a role'); +export const ACCESS_EXPIRE_DATE = s__('InviteMembersModal|Access expiration date (optional)'); +export const TOAST_MESSAGE_SUCCESSFUL = s__('InviteMembersModal|Members were successfully added'); +export const INVALID_FEEDBACK_MESSAGE_DEFAULT = s__('InviteMembersModal|Something went wrong'); +export const READ_MORE_TEXT = s__( + `InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`, +); +export const INVITE_BUTTON_TEXT = s__('InviteMembersModal|Invite'); +export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel'); +export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members'); +export const AREAS_OF_FOCUS_LABEL = s__( + 'InviteMembersModal|What would you like new member(s) to focus on? (optional)', +); + +export const MODAL_LABELS = { + members: { + modal: { + default: { + title: MEMBERS_MODAL_DEFAULT_TITLE, + }, + celebrate: { + title: MEMBERS_MODAL_CELEBRATE_TITLE, + intro: MEMBERS_MODAL_CELEBRATE_INTRO, + }, + }, + toGroup: { + default: { + introText: MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT, + }, + }, + toProject: { + default: { + introText: MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT, + }, + celebrate: { + introText: MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT, + }, + }, + searchField: MEMBERS_SEARCH_FIELD, + placeHolder: MEMBERS_PLACEHOLDER, + tasksToBeDone: { + title: MEMBERS_TASKS_TO_BE_DONE_TITLE, + noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS, + }, + tasksProject: { + title: MEMBERS_TASKS_PROJECTS_TITLE, + }, + }, + group: { + modal: { + default: { + title: GROUP_MODAL_DEFAULT_TITLE, + }, + }, + toGroup: { + default: { + introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT, + }, + }, + toProject: { + default: { + introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT, + }, + }, + searchField: GROUP_SEARCH_FIELD, + placeHolder: GROUP_PLACEHOLDER, + }, + accessLevel: ACCESS_LEVEL, + accessExpireDate: ACCESS_EXPIRE_DATE, + toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL, + invalidFeedbackMessageDefault: INVALID_FEEDBACK_MESSAGE_DEFAULT, + readMoreText: READ_MORE_TEXT, + inviteButtonText: INVITE_BUTTON_TEXT, + cancelButtonText: CANCEL_BUTTON_TEXT, + headerCloseLabel: HEADER_CLOSE_LABEL, + areasOfFocusLabel: AREAS_OF_FOCUS_LABEL, +}; diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index c1dfaa25dc7..fc657a064dd 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -5,15 +5,32 @@ import { parseBoolean } from '~/lib/utils/common_utils'; Vue.use(GlToast); +let initedInviteMembersModal; + export default function initInviteMembersModal() { + if (initedInviteMembersModal) { + // if we already loaded this in another part of the dom, we don't want to do it again + // else we will stack the modals + return false; + } + + // https://gitlab.com/gitlab-org/gitlab/-/issues/344955 + // bug lying in wait here for someone to put group and project invite in same screen + // once that happens we'll need to mount these differently, perhaps split + // group/project to each mount one, with many ways to open it. const el = document.querySelector('.js-invite-members-modal'); if (!el) { return false; } + initedInviteMembersModal = true; + return new Vue({ el, + provide: { + newProjectPath: el.dataset.newProjectPath, + }, render: (createElement) => createElement(InviteMembersModal, { props: { @@ -24,6 +41,8 @@ export default function initInviteMembersModal() { groupSelectFilter: el.dataset.groupsFilter, groupSelectParentId: parseInt(el.dataset.parentId, 10), areasOfFocusOptions: JSON.parse(el.dataset.areasOfFocusOptions), + tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'), + projects: JSON.parse(el.dataset.projects || '[]'), noSelectionAreasOfFocus: JSON.parse(el.dataset.noSelectionAreasOfFocus), usersFilter: el.dataset.usersFilter, filterId: parseInt(el.dataset.filterId, 10), diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue index 6e300831e00..799d2bdc9e2 100644 --- a/app/assets/javascripts/issuable/components/issuable_by_email.vue +++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue @@ -166,7 +166,7 @@ export default { </gl-sprintf> </p> <template #modal-footer> - <gl-button category="secondary" @click="cancelHandler">{{ s__('Cancel') }}</gl-button> + <gl-button category="secondary" @click="cancelHandler">{{ __('Cancel') }}</gl-button> </template> </gl-modal> </div> diff --git a/app/assets/javascripts/issuable_suggestions/index.js b/app/assets/javascripts/issuable_suggestions/index.js index 22a99a17741..8f7f317d6b4 100644 --- a/app/assets/javascripts/issuable_suggestions/index.js +++ b/app/assets/javascripts/issuable_suggestions/index.js @@ -10,12 +10,7 @@ export default function initIssuableSuggestions() { const issueTitle = document.getElementById('issue_title'); const { projectPath } = el.dataset; const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - assumeImmutableResults: true, - }, - ), + defaultClient: createDefaultClient(), }); return new Vue({ diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 2c9a512acdb..d3b58ed3012 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -4,7 +4,7 @@ import Visibility from 'visibilityjs'; import createFlash from '~/flash'; import Poll from '~/lib/utils/poll'; import { visitUrl } from '~/lib/utils/url_utility'; -import { __, s__, sprintf } from '~/locale'; +import { __, sprintf } from '~/locale'; import { IssuableStatus, IssuableStatusText, @@ -12,6 +12,7 @@ import { IssueTypePath, IncidentTypePath, IncidentType, + POLLING_DELAY, } from '../constants'; import eventHub from '../event_hub'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; @@ -249,7 +250,7 @@ export default { return false; }, defaultErrorMessage() { - return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType }); + return sprintf(__('Error updating %{issuableType}'), { issuableType: this.issuableType }); }, isClosed() { return this.issuableStatus === IssuableStatus.Closed; @@ -282,7 +283,7 @@ export default { }); if (!Visibility.hidden()) { - this.poll.makeDelayedRequest(2000); + this.poll.makeDelayedRequest(POLLING_DELAY); } Visibility.change(() => { @@ -436,7 +437,7 @@ export default { }) .catch(() => { createFlash({ - message: sprintf(s__('Error deleting %{issuableType}'), { + message: sprintf(__('Error deleting %{issuableType}'), { issuableType: this.issuableType, }), }); @@ -457,6 +458,22 @@ export default { this.flashContainer = null; } }, + + taskListUpdateStarted() { + this.poll.stop(); + }, + + taskListUpdateSucceeded() { + this.poll.enable(); + this.poll.makeDelayedRequest(POLLING_DELAY); + }, + + taskListUpdateFailed() { + this.poll.enable(); + this.poll.makeDelayedRequest(POLLING_DELAY); + + this.updateStoreState(); + }, }, }; </script> @@ -552,7 +569,9 @@ export default { :issuable-type="issuableType" :update-url="updateEndpoint" :lock-version="state.lock_version" - @taskListUpdateFailed="updateStoreState" + @taskListUpdateStarted="taskListUpdateStarted" + @taskListUpdateSucceeded="taskListUpdateSucceeded" + @taskListUpdateFailed="taskListUpdateFailed" /> <edited-component diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 4c6a1478e95..9dc122d426c 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -2,7 +2,7 @@ import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import $ from 'jquery'; import createFlash from '~/flash'; -import { s__, sprintf } from '~/locale'; +import { __, sprintf } from '~/locale'; import TaskList from '../../task_list'; import animateMixin from '../mixins/animate'; @@ -86,15 +86,25 @@ export default { fieldName: 'description', lockVersion: this.lockVersion, selector: '.detail-page-description', + onUpdate: this.taskListUpdateStarted.bind(this), + onSuccess: this.taskListUpdateSuccess.bind(this), onError: this.taskListUpdateError.bind(this), }); } }, + taskListUpdateStarted() { + this.$emit('taskListUpdateStarted'); + }, + + taskListUpdateSuccess() { + this.$emit('taskListUpdateSucceeded'); + }, + taskListUpdateError() { createFlash({ message: sprintf( - s__( + __( 'Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again.', ), { diff --git a/app/assets/javascripts/issue_show/components/fields/type.vue b/app/assets/javascripts/issue_show/components/fields/type.vue index 3eac448c637..9110a6924b4 100644 --- a/app/assets/javascripts/issue_show/components/fields/type.vue +++ b/app/assets/javascripts/issue_show/components/fields/type.vue @@ -2,7 +2,7 @@ import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; import { capitalize } from 'lodash'; import { __ } from '~/locale'; -import { IssuableTypes } from '../../constants'; +import { IssuableTypes, IncidentType } from '../../constants'; import getIssueStateQuery from '../../queries/get_issue_state.query.graphql'; import updateIssueStateMutation from '../../queries/update_issue_state.mutation.graphql'; @@ -19,6 +19,14 @@ export default { GlDropdown, GlDropdownItem, }, + inject: { + canCreateIncident: { + default: false, + }, + issueType: { + default: 'issue', + }, + }, data() { return { issueState: {}, @@ -36,6 +44,9 @@ export default { } = this; return capitalize(issueType); }, + shouldShowIncident() { + return this.issueType === IncidentType || this.canCreateIncident; + }, }, methods: { updateIssueType(issueType) { @@ -47,6 +58,9 @@ export default { }, }); }, + isShown(type) { + return type.value !== IncidentType || this.shouldShowIncident; + }, }, }; </script> @@ -68,6 +82,7 @@ export default { > <gl-dropdown-item v-for="type in $options.IssuableTypes" + v-show="isShown(type)" :key="type.value" :is-checked="issueState.issueType === type.value" is-check-item diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issue_show/components/header_actions.vue index 2bddbe4faa0..2c314ce1c3f 100644 --- a/app/assets/javascripts/issue_show/components/header_actions.vue +++ b/app/assets/javascripts/issue_show/components/header_actions.vue @@ -192,9 +192,14 @@ export default { class="gl-sm-display-none! w-100" block :text="dropdownText" + data-qa-selector="issue_actions_dropdown" :loading="isToggleStateButtonLoading" > - <gl-dropdown-item v-if="showToggleIssueStateButton" @click="toggleIssueState"> + <gl-dropdown-item + v-if="showToggleIssueStateButton" + :data-qa-selector="`mobile_${qaSelector}`" + @click="toggleIssueState" + > {{ buttonText }} </gl-dropdown-item> <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath"> diff --git a/app/assets/javascripts/issue_show/constants.js b/app/assets/javascripts/issue_show/constants.js index 64d39a79821..ef9699deb42 100644 --- a/app/assets/javascripts/issue_show/constants.js +++ b/app/assets/javascripts/issue_show/constants.js @@ -37,3 +37,10 @@ export const IncidentTypePath = 'issues/incident'; export const IncidentType = 'incident'; export const issueState = { issueType: undefined, isDirty: false }; + +export const POLLING_DELAY = 2000; + +export const WorkspaceType = { + project: 'project', + group: 'group', +}; diff --git a/app/assets/javascripts/issue_show/incident.js b/app/assets/javascripts/issue_show/incident.js index df986195656..3aff2d9c54a 100644 --- a/app/assets/javascripts/issue_show/incident.js +++ b/app/assets/javascripts/issue_show/incident.js @@ -2,25 +2,31 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import issuableApp from './components/app.vue'; import incidentTabs from './components/incidents/incident_tabs.vue'; -import { issueState } from './constants'; +import { issueState, IncidentType } from './constants'; import apolloProvider from './graphql'; import getIssueStateQuery from './queries/get_issue_state.query.graphql'; +import HeaderActions from './components/header_actions.vue'; -export default function initIssuableApp(issuableData = {}) { +const bootstrapApollo = (state = {}) => { + return apolloProvider.clients.defaultClient.cache.writeQuery({ + query: getIssueStateQuery, + data: { + issueState: state, + }, + }); +}; + +export function initIncidentApp(issuableData = {}) { const el = document.getElementById('js-issuable-app'); if (!el) { return undefined; } - apolloProvider.clients.defaultClient.cache.writeQuery({ - query: getIssueStateQuery, - data: { - issueState: { ...issueState, issueType: el.dataset.issueType }, - }, - }); + bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); const { + canCreateIncident, canUpdate, iid, projectNamespace, @@ -39,6 +45,8 @@ export default function initIssuableApp(issuableData = {}) { issuableApp, }, provide: { + issueType: IncidentType, + canCreateIncident, canUpdate, fullPath, iid, @@ -57,3 +65,35 @@ export default function initIssuableApp(issuableData = {}) { }, }); } + +export function initIncidentHeaderActions(store) { + const el = document.querySelector('.js-issue-header-actions'); + + if (!el) { + return undefined; + } + + bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); + + return new Vue({ + el, + apolloProvider, + store, + provide: { + canCreateIssue: parseBoolean(el.dataset.canCreateIncident), + canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic), + canReopenIssue: parseBoolean(el.dataset.canReopenIssue), + canReportSpam: parseBoolean(el.dataset.canReportSpam), + canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue), + iid: el.dataset.iid, + isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor), + issueType: el.dataset.issueType, + newIssuePath: el.dataset.newIssuePath, + projectPath: el.dataset.projectPath, + projectId: el.dataset.projectId, + reportAbusePath: el.dataset.reportAbusePath, + submitAsSpamPath: el.dataset.submitAsSpamPath, + }, + render: (createElement) => createElement(HeaderActions), + }); +} diff --git a/app/assets/javascripts/issue_show/issue.js b/app/assets/javascripts/issue_show/issue.js index 4374dba6eb7..25cc51478ff 100644 --- a/app/assets/javascripts/issue_show/issue.js +++ b/app/assets/javascripts/issue_show/issue.js @@ -25,17 +25,22 @@ export function initIssuableApp(issuableData, store) { bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); + const { canCreateIncident, ...issuableProps } = issuableData; + return new Vue({ el, apolloProvider, store, + provide: { + canCreateIncident, + }, computed: { ...mapGetters(['getNoteableData']), }, render(createElement) { return createElement(IssuableApp, { props: { - ...issuableData, + ...issuableProps, isConfidential: this.getNoteableData?.confidential, isLocked: this.getNoteableData?.discussion_locked, issuableStatus: this.getNoteableData?.state, diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue index 7b51f6ee46a..7f2082e5b90 100644 --- a/app/assets/javascripts/issues_list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue @@ -36,6 +36,7 @@ import { TOKEN_TYPE_LABEL, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, TOKEN_TYPE_WEIGHT, UPDATED_DESC, @@ -65,6 +66,7 @@ import { TOKEN_TITLE_LABEL, TOKEN_TITLE_MILESTONE, TOKEN_TITLE_MY_REACTION, + TOKEN_TITLE_RELEASE, TOKEN_TITLE_TYPE, TOKEN_TITLE_WEIGHT, } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -88,6 +90,8 @@ const LabelToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'); const MilestoneToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'); +const ReleaseToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'); const WeightToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'); @@ -165,6 +169,9 @@ export default { newIssuePath: { default: '', }, + releasesPath: { + default: '', + }, rssPath: { default: '', }, @@ -288,6 +295,7 @@ export default { avatar_url: gon.current_user_avatar_url, }); } + const tokens = [ { type: TOKEN_TYPE_AUTHOR, @@ -297,7 +305,6 @@ export default { dataType: 'user', unique: true, defaultAuthors: [], - operators: OPERATOR_IS_ONLY, fetchAuthors: this.fetchUsers, preloadedAuthors, }, @@ -317,7 +324,6 @@ export default { title: TOKEN_TITLE_MILESTONE, icon: 'clock', token: MilestoneToken, - unique: true, fetchMilestones: this.fetchMilestones, }, { @@ -333,7 +339,6 @@ export default { title: TOKEN_TITLE_TYPE, icon: 'issues', token: GlFilteredSearchToken, - operators: OPERATOR_IS_ONLY, options: [ { icon: 'issue-type-issue', title: 'issue', value: 'issue' }, { icon: 'issue-type-incident', title: 'incident', value: 'incident' }, @@ -342,6 +347,16 @@ export default { }, ]; + if (this.isProject) { + tokens.push({ + type: TOKEN_TYPE_RELEASE, + title: TOKEN_TITLE_RELEASE, + icon: 'rocket', + token: ReleaseToken, + fetchReleases: this.fetchReleases, + }); + } + if (this.isSignedIn) { tokens.push({ type: TOKEN_TYPE_MY_REACTION, @@ -349,7 +364,6 @@ export default { icon: 'thumb-up', token: EmojiToken, unique: true, - operators: OPERATOR_IS_ONLY, fetchEmojis: this.fetchEmojis, }); @@ -373,7 +387,6 @@ export default { title: TOKEN_TITLE_ITERATION, icon: 'iteration', token: IterationToken, - unique: true, fetchIterations: this.fetchIterations, }); } @@ -459,6 +472,9 @@ export default { fetchEmojis(search) { return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search); }, + fetchReleases(search) { + return this.fetchWithCache(this.releasesPath, 'releases', 'tag', search); + }, fetchLabels(search) { return this.$apollo .query({ diff --git a/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue index 037fd9be542..e749579af80 100644 --- a/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue +++ b/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue @@ -71,8 +71,11 @@ export default { hasSelectedProject() { return this.selectedProject.id; }, + projectsWithIssuesEnabled() { + return this.projects.filter((project) => project.issuesEnabled); + }, showNoSearchResultsText() { - return !this.projects.length && this.search; + return !this.projectsWithIssuesEnabled.length && this.search; }, }, methods: { @@ -110,7 +113,7 @@ export default { <gl-loading-icon v-if="$apollo.queries.projects.loading" /> <template v-else> <gl-dropdown-item - v-for="project of projects" + v-for="project of projectsWithIssuesEnabled" :key="project.id" @click="selectProject(project)" > diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js index 5bdc1bd9f90..da9b96d0e22 100644 --- a/app/assets/javascripts/issues_list/constants.js +++ b/app/assets/javascripts/issues_list/constants.js @@ -166,6 +166,7 @@ const LABEL_PRIORITY_ASC_SORT = 'label_priority_asc'; const POPULARITY_ASC_SORT = 'popularity_asc'; const WEIGHT_DESC_SORT = 'weight_desc'; const BLOCKING_ISSUES_DESC_SORT = 'blocking_issues_desc'; +const TITLE_ASC_SORT = 'title_asc'; const TITLE_DESC_SORT = 'title_desc'; export const urlSortParams = { @@ -187,7 +188,7 @@ export const urlSortParams = { [WEIGHT_ASC]: WEIGHT, [WEIGHT_DESC]: WEIGHT_DESC_SORT, [BLOCKING_ISSUES_DESC]: BLOCKING_ISSUES_DESC_SORT, - [TITLE_ASC]: TITLE, + [TITLE_ASC]: TITLE_ASC_SORT, [TITLE_DESC]: TITLE_DESC_SORT, }; @@ -211,6 +212,7 @@ export const TOKEN_TYPE_ASSIGNEE = 'assignee_username'; export const TOKEN_TYPE_MILESTONE = 'milestone'; export const TOKEN_TYPE_LABEL = 'labels'; export const TOKEN_TYPE_TYPE = 'type'; +export const TOKEN_TYPE_RELEASE = 'release'; export const TOKEN_TYPE_MY_REACTION = 'my_reaction_emoji'; export const TOKEN_TYPE_CONFIDENTIAL = 'confidential'; export const TOKEN_TYPE_ITERATION = 'iteration'; @@ -271,6 +273,7 @@ export const filters = { [OPERATOR_IS]: { [NORMAL_FILTER]: 'label_name[]', [SPECIAL_FILTER]: 'label_name[]', + [ALTERNATIVE_FILTER]: 'label_name', }, [OPERATOR_IS_NOT]: { [NORMAL_FILTER]: 'not[label_name][]', @@ -280,12 +283,28 @@ export const filters = { [TOKEN_TYPE_TYPE]: { [API_PARAM]: { [NORMAL_FILTER]: 'types', - [SPECIAL_FILTER]: 'types', }, [URL_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'type[]', - [SPECIAL_FILTER]: 'type[]', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[type][]', + }, + }, + }, + [TOKEN_TYPE_RELEASE]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'releaseTag', + [SPECIAL_FILTER]: 'releaseTagWildcardId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'release_tag', + [SPECIAL_FILTER]: 'release_tag', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[release_tag]', }, }, }, @@ -299,6 +318,9 @@ export const filters = { [NORMAL_FILTER]: 'my_reaction_emoji', [SPECIAL_FILTER]: 'my_reaction_emoji', }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[my_reaction_emoji]', + }, }, }, [TOKEN_TYPE_CONFIDENTIAL]: { diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index 47af20f5271..59034964afb 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -24,7 +24,7 @@ export function mountJiraIssuesListApp() { } Vue.use(VueApollo); - const defaultClient = createDefaultClient({}, { assumeImmutableResults: true }); + const defaultClient = createDefaultClient(); const apolloProvider = new VueApollo({ defaultClient, }); @@ -103,7 +103,7 @@ export function mountIssuesListApp() { }, }; - const defaultClient = createDefaultClient(resolvers, { assumeImmutableResults: true }); + const defaultClient = createDefaultClient(resolvers); const apolloProvider = new VueApollo({ defaultClient, }); @@ -137,6 +137,7 @@ export function mountIssuesListApp() { newIssuePath, projectImportJiraPath, quickActionsHelpPath, + releasesPath, resetPath, rssPath, showNewIssueLink, @@ -164,6 +165,7 @@ export function mountIssuesListApp() { isSignedIn: parseBoolean(isSignedIn), jiraIntegrationPath, newIssuePath, + releasesPath, rssPath, showNewIssueLink: parseBoolean(showNewIssueLink), signInPath, diff --git a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql index 6df72cf6596..9866efbcecc 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql @@ -11,9 +11,13 @@ query getIssues( $assigneeId: String $assigneeUsernames: [String!] $authorUsername: String + $confidential: Boolean $labelName: [String] $milestoneTitle: [String] $milestoneWildcardId: MilestoneWildcardId + $myReactionEmoji: String + $releaseTag: [String!] + $releaseTagWildcardId: ReleaseTagWildcardId $types: [IssueType!] $not: NegatedIssueFilterInput $beforeCursor: String @@ -30,9 +34,11 @@ query getIssues( assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername + confidential: $confidential labelName: $labelName milestoneTitle: $milestoneTitle milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji types: $types not: $not before: $beforeCursor @@ -57,9 +63,13 @@ query getIssues( assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername + confidential: $confidential labelName: $labelName milestoneTitle: $milestoneTitle milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId types: $types not: $not before: $beforeCursor diff --git a/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql index 7bcdbbb28fc..5e755ec5870 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql +++ b/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql @@ -5,9 +5,13 @@ query getIssuesCount( $assigneeId: String $assigneeUsernames: [String!] $authorUsername: String + $confidential: Boolean $labelName: [String] $milestoneTitle: [String] $milestoneWildcardId: MilestoneWildcardId + $myReactionEmoji: String + $releaseTag: [String!] + $releaseTagWildcardId: ReleaseTagWildcardId $types: [IssueType!] $not: NegatedIssueFilterInput ) { @@ -19,9 +23,11 @@ query getIssuesCount( assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername + confidential: $confidential labelName: $labelName milestoneTitle: $milestoneTitle milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji types: $types not: $not ) { @@ -34,9 +40,11 @@ query getIssuesCount( assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername + confidential: $confidential labelName: $labelName milestoneTitle: $milestoneTitle milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji types: $types not: $not ) { @@ -49,9 +57,11 @@ query getIssuesCount( assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername + confidential: $confidential labelName: $labelName milestoneTitle: $milestoneTitle milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji types: $types not: $not ) { @@ -65,9 +75,13 @@ query getIssuesCount( assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername + confidential: $confidential labelName: $labelName milestoneTitle: $milestoneTitle milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId types: $types not: $not ) { @@ -79,9 +93,13 @@ query getIssuesCount( assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername + confidential: $confidential labelName: $labelName milestoneTitle: $milestoneTitle milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId types: $types not: $not ) { @@ -93,9 +111,13 @@ query getIssuesCount( assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername + confidential: $confidential labelName: $labelName milestoneTitle: $milestoneTitle milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId types: $types not: $not ) { diff --git a/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql index 8f9b888d19b..8c95e6114d3 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql +++ b/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql @@ -1,4 +1,4 @@ -query($fullPath: ID!) { +query getIssuesListDetails($fullPath: ID!) { project(fullPath: $fullPath) { issues { nodes { diff --git a/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql b/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql index 78a368089a8..4f7217be7f7 100644 --- a/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql +++ b/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql @@ -1,4 +1,10 @@ fragment Iteration on Iteration { id title + startDate + dueDate + iterationCadence { + id + title + } } diff --git a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql b/app/assets/javascripts/issues_list/queries/search_projects.query.graphql index df1f330139a..75463f643a2 100644 --- a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql +++ b/app/assets/javascripts/issues_list/queries/search_projects.query.graphql @@ -3,6 +3,7 @@ query searchProjects($fullPath: ID!, $search: String) { projects(search: $search, includeSubgroups: true) { nodes { id + issuesEnabled name nameWithNamespace webUrl diff --git a/app/assets/javascripts/issues_list/service_desk_helper.js b/app/assets/javascripts/issues_list/service_desk_helper.js index f96567ef53b..815f338f1a0 100644 --- a/app/assets/javascripts/issues_list/service_desk_helper.js +++ b/app/assets/javascripts/issues_list/service_desk_helper.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; /** * Generates empty state messages for Service Desk issues list. @@ -20,12 +20,12 @@ export function generateMessages(emptyStateMeta) { ); const serviceDeskSupportedMessage = s__( - 'ServiceDesk|Issues created from Service Desk emails appear here. Each comment becomes part of the email conversation.', + 'ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation.', ); const commonDescription = ` <span>${serviceDeskSupportedMessage}</span> - <a href="${serviceDeskHelpPage}">${s__('Learn more.')}</a>`; + <a href="${serviceDeskHelpPage}">${__('Learn more.')}</a>`; return { serviceDeskEnabledAndCanEditProjectSettings: { @@ -60,7 +60,7 @@ export function generateMessages(emptyStateMeta) { 'ServiceDesk|To enable Service Desk on this instance, an instance administrator must first set up incoming email.', ), primaryLink: incomingEmailHelpPage, - primaryText: s__('Learn more.'), + primaryText: __('Learn more.'), }, serviceDeskIsNotEnabled: { title: s__('ServiceDesk|Service Desk is not enabled'), diff --git a/app/assets/javascripts/issues_list/utils.js b/app/assets/javascripts/issues_list/utils.js index 1d3d07475af..0e57e2bff83 100644 --- a/app/assets/javascripts/issues_list/utils.js +++ b/app/assets/javascripts/issues_list/utils.js @@ -21,9 +21,13 @@ import { RELATIVE_POSITION_ASC, SPECIAL_FILTER, SPECIAL_FILTER_VALUES, + TITLE_ASC, + TITLE_DESC, TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_CONFIDENTIAL, TOKEN_TYPE_ITERATION, TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, UPDATED_ASC, UPDATED_DESC, @@ -113,11 +117,19 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) descending: RELATIVE_POSITION_ASC, }, }, + { + id: 9, + title: __('Title'), + sortDirection: { + ascending: TITLE_ASC, + descending: TITLE_DESC, + }, + }, ]; if (hasIssueWeightsFeature) { sortOptions.push({ - id: 9, + id: sortOptions.length + 1, title: __('Weight'), sortDirection: { ascending: WEIGHT_ASC, @@ -128,7 +140,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) if (hasBlockedIssuesFeature) { sortOptions.push({ - id: 10, + id: sortOptions.length + 1, title: __('Blocking'), sortDirection: { ascending: BLOCKING_ISSUES_DESC, @@ -193,17 +205,23 @@ const getFilterType = (data, tokenType = '') => ? SPECIAL_FILTER : NORMAL_FILTER; +const wildcardTokens = [TOKEN_TYPE_ITERATION, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_RELEASE]; + const isWildcardValue = (tokenType, value) => - (tokenType === TOKEN_TYPE_ITERATION || tokenType === TOKEN_TYPE_MILESTONE) && - SPECIAL_FILTER_VALUES.includes(value); + wildcardTokens.includes(tokenType) && SPECIAL_FILTER_VALUES.includes(value); const requiresUpperCaseValue = (tokenType, value) => tokenType === TOKEN_TYPE_TYPE || isWildcardValue(tokenType, value); -const formatData = (token) => - requiresUpperCaseValue(token.type, token.value.data) - ? token.value.data.toUpperCase() - : token.value.data; +const formatData = (token) => { + if (requiresUpperCaseValue(token.type, token.value.data)) { + return token.value.data.toUpperCase(); + } + if (token.type === TOKEN_TYPE_CONFIDENTIAL) { + return token.value.data === 'yes'; + } + return token.value.data; +}; export const convertToApiParams = (filterTokens) => { const params = {}; diff --git a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql index e768154e210..32fbc1113bc 100644 --- a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql +++ b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql @@ -1,6 +1,6 @@ #import "~/graphql_shared/fragments/pageInfo.fragment.graphql" -query getProjects( +query jiraGetProjects( $search: String! $after: String = "" $first: Int! diff --git a/app/assets/javascripts/jira_connect/branches/index.js b/app/assets/javascripts/jira_connect/branches/index.js index 95bd4f5c675..04510fcff4b 100644 --- a/app/assets/javascripts/jira_connect/branches/index.js +++ b/app/assets/javascripts/jira_connect/branches/index.js @@ -14,12 +14,7 @@ export default async function initJiraConnectBranches() { const { initialBranchName, successStateSvgPath } = el.dataset; const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - assumeImmutableResults: true, - }, - ), + defaultClient: createDefaultClient(), }); return new Vue({ diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue new file mode 100644 index 00000000000..0b286bc903f --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue @@ -0,0 +1,24 @@ +<script> +import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { ADD_NAMESPACE_MODAL_ID } from '../constants'; +import AddNamespaceModal from './add_namespace_modal/add_namespace_modal.vue'; + +export default { + components: { + GlButton, + AddNamespaceModal, + }, + directives: { + GlModal: GlModalDirective, + }, + ADD_NAMESPACE_MODAL_ID, +}; +</script> +<template> + <div> + <gl-button v-gl-modal="$options.ADD_NAMESPACE_MODAL_ID" category="primary" variant="info"> + {{ s__('Integrations|Add namespace') }} + </gl-button> + <add-namespace-modal /> + </div> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue new file mode 100644 index 00000000000..0e209a09b16 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue @@ -0,0 +1,26 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { ADD_NAMESPACE_MODAL_ID } from '../../constants'; +import GroupsList from './groups_list.vue'; + +export default { + components: { GlModal, GroupsList }, + modal: { + id: ADD_NAMESPACE_MODAL_ID, + title: s__('Integrations|Link namespaces'), + cancelProps: { + text: __('Cancel'), + }, + }, +}; +</script> +<template> + <gl-modal + :modal-id="$options.modal.id" + :title="$options.modal.title" + :action-cancel="$options.modal.cancelProps" + > + <groups-list /> + </gl-modal> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/groups_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue index 5a49d7c1a90..5a49d7c1a90 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/groups_list.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue new file mode 100644 index 00000000000..005c3bcd0e3 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue @@ -0,0 +1,85 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { addSubscription } from '~/jira_connect/subscriptions/api'; +import { persistAlert, reloadPage } from '~/jira_connect/subscriptions/utils'; +import { s__ } from '~/locale'; +import GroupItemName from '../group_item_name.vue'; + +export default { + components: { + GlButton, + GroupItemName, + }, + inject: { + subscriptionsPath: { + default: '', + }, + }, + props: { + group: { + type: Object, + required: true, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isLoading: false, + }; + }, + methods: { + onClick() { + this.isLoading = true; + + addSubscription(this.subscriptionsPath, this.group.full_path) + .then(() => { + persistAlert({ + title: s__('Integrations|Namespace successfully linked'), + message: s__( + 'Integrations|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', + ), + linkUrl: helpPagePath('integration/jira_development_panel.html', { anchor: 'usage' }), + variant: 'success', + }); + + reloadPage(); + }) + .catch((error) => { + this.$emit( + 'error', + error?.response?.data?.error || + s__('Integrations|Failed to link namespace. Please try again.'), + ); + this.isLoading = false; + }); + }, + }, +}; +</script> + +<template> + <li class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"> + <div class="gl-display-flex gl-align-items-center gl-py-3"> + <div class="gl-min-w-0 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1 gl-align-items-center"> + <div class="gl-min-w-0 gl-flex-grow-1 flex-shrink-1"> + <group-item-name :group="group" /> + </div> + + <gl-button + category="secondary" + variant="confirm" + :loading="isLoading" + :disabled="disabled" + @click.prevent="onClick" + > + {{ __('Link') }} + </gl-button> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue index 413424be28d..c0504cbb645 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue @@ -1,65 +1,51 @@ <script> -import { GlAlert, GlButton, GlLink, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlLink, GlSprintf, GlEmptyState } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; import { mapState, mapMutations } from 'vuex'; -import { retrieveAlert, getLocation } from '~/jira_connect/subscriptions/utils'; -import { __ } from '~/locale'; +import { retrieveAlert } from '~/jira_connect/subscriptions/utils'; import { SET_ALERT } from '../store/mutation_types'; -import GroupsList from './groups_list.vue'; import SubscriptionsList from './subscriptions_list.vue'; +import AddNamespaceButton from './add_namespace_button.vue'; +import SignInButton from './sign_in_button.vue'; export default { name: 'JiraConnectApp', components: { GlAlert, - GlButton, GlLink, - GlModal, GlSprintf, - GroupsList, + GlEmptyState, SubscriptionsList, - }, - directives: { - GlModalDirective, + AddNamespaceButton, + SignInButton, }, inject: { usersPath: { default: '', }, - }, - data() { - return { - location: '', - }; + subscriptions: { + default: [], + }, }, computed: { ...mapState(['alert']), - usersPathWithReturnTo() { - if (this.location) { - return `${this.usersPath}?return_to=${this.location}`; - } - - return this.usersPath; - }, shouldShowAlert() { return Boolean(this.alert?.message); }, - }, - modal: { - cancelProps: { - text: __('Cancel'), + hasSubscriptions() { + return !isEmpty(this.subscriptions); + }, + userSignedIn() { + return Boolean(!this.usersPath); }, }, created() { this.setInitialAlert(); - this.setLocation(); }, methods: { ...mapMutations({ setAlert: SET_ALERT, }), - async setLocation() { - this.location = await getLocation(); - }, setInitialAlert() { const { linkUrl, title, message, variant } = retrieveAlert() || {}; this.setAlert({ linkUrl, title, message, variant }); @@ -88,38 +74,44 @@ export default { </template> </gl-alert> - <h2 class="gl-text-center">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> + <h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> + <div class="jira-connect-app-body gl-mx-auto gl-px-5 gl-mb-7"> + <template v-if="hasSubscriptions"> + <div class="gl-display-flex gl-justify-content-end"> + <sign-in-button v-if="!userSignedIn" :users-path="usersPath" /> + <add-namespace-button v-else /> + </div> - <div class="jira-connect-app-body gl-my-7 gl-px-5 gl-pb-4"> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button - v-if="usersPath" - category="primary" - variant="info" - class="gl-align-self-center" - :href="usersPathWithReturnTo" - target="_blank" - >{{ s__('Integrations|Sign in to add namespaces') }}</gl-button + <subscriptions-list /> + </template> + <template v-else> + <div v-if="!userSignedIn" class="gl-text-center"> + <p class="gl-mb-7">{{ s__('JiraService|Sign in to GitLab.com to get started.') }}</p> + <sign-in-button class="gl-mb-7" :users-path="usersPath"> + {{ __('Sign in to GitLab') }} + </sign-in-button> + <p> + {{ + s__( + 'Integrations|Note: this integration only works with accounts on GitLab.com (SaaS).', + ) + }} + </p> + </div> + <gl-empty-state + v-else + :title="s__('Integrations|No linked namespaces')" + :description=" + s__( + 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.', + ) + " > - <template v-else> - <gl-button - v-gl-modal-directive="'add-namespace-modal'" - category="primary" - variant="info" - class="gl-align-self-center" - >{{ s__('Integrations|Add namespace') }}</gl-button - > - <gl-modal - modal-id="add-namespace-modal" - :title="s__('Integrations|Link namespaces')" - :action-cancel="$options.modal.cancelProps" - > - <groups-list /> - </gl-modal> - </template> - </div> - - <subscriptions-list /> + <template #actions> + <add-namespace-button /> + </template> + </gl-empty-state> + </template> </div> </div> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/groups_list_item.vue b/app/assets/javascripts/jira_connect/subscriptions/components/groups_list_item.vue deleted file mode 100644 index ed7585e8a88..00000000000 --- a/app/assets/javascripts/jira_connect/subscriptions/components/groups_list_item.vue +++ /dev/null @@ -1,85 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import { addSubscription } from '~/jira_connect/subscriptions/api'; -import { persistAlert, reloadPage } from '~/jira_connect/subscriptions/utils'; -import { s__ } from '~/locale'; -import GroupItemName from './group_item_name.vue'; - -export default { - components: { - GlButton, - GroupItemName, - }, - inject: { - subscriptionsPath: { - default: '', - }, - }, - props: { - group: { - type: Object, - required: true, - }, - disabled: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - isLoading: false, - }; - }, - methods: { - onClick() { - this.isLoading = true; - - addSubscription(this.subscriptionsPath, this.group.full_path) - .then(() => { - persistAlert({ - title: s__('Integrations|Namespace successfully linked'), - message: s__( - 'Integrations|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', - ), - linkUrl: helpPagePath('integration/jira_development_panel.html', { anchor: 'usage' }), - variant: 'success', - }); - - reloadPage(); - }) - .catch((error) => { - this.$emit( - 'error', - error?.response?.data?.error || - s__('Integrations|Failed to link namespace. Please try again.'), - ); - this.isLoading = false; - }); - }, - }, -}; -</script> - -<template> - <li class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"> - <div class="gl-display-flex gl-align-items-center gl-py-3"> - <div class="gl-min-w-0 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1 gl-align-items-center"> - <div class="gl-min-w-0 gl-flex-grow-1 flex-shrink-1"> - <group-item-name :group="group" /> - </div> - - <gl-button - category="secondary" - variant="confirm" - :loading="isLoading" - :disabled="disabled" - @click.prevent="onClick" - > - {{ __('Link') }} - </gl-button> - </div> - </div> - </li> -</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue new file mode 100644 index 00000000000..dc0a77e99c2 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue @@ -0,0 +1,36 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils'; + +export default { + components: { + GlButton, + }, + props: { + usersPath: { + type: String, + required: true, + }, + }, + data() { + return { + signInURL: '', + }; + }, + created() { + this.setSignInURL(); + }, + methods: { + async setSignInURL() { + this.signInURL = await getGitlabSignInURL(this.usersPath); + }, + }, +}; +</script> +<template> + <gl-button category="primary" variant="info" :href="signInURL" target="_blank"> + <slot> + {{ s__('Integrations|Sign in to add namespaces') }} + </slot> + </gl-button> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue index 7062fb370ed..33126040c16 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; +import { GlButton, GlTable } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapMutations } from 'vuex'; import { removeSubscription } from '~/jira_connect/subscriptions/api'; @@ -12,7 +12,6 @@ import GroupItemName from './group_item_name.vue'; export default { components: { GlButton, - GlEmptyState, GlTable, GroupItemName, TimeagoTooltip, @@ -44,17 +43,15 @@ export default { }, ], i18n: { - emptyTitle: s__('Integrations|No linked namespaces'), - emptyDescription: s__( - 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.', - ), unlinkError: s__('Integrations|Failed to unlink namespace. Please try again.'), }, methods: { ...mapMutations({ setAlert: SET_ALERT, }), - isEmpty, + isUnlinkButtonDisabled(item) { + return !isEmpty(item); + }, isLoadingItem(item) { return this.loadingItem === item; }, @@ -81,29 +78,22 @@ export default { </script> <template> - <div> - <gl-empty-state - v-if="isEmpty(subscriptions)" - :title="$options.i18n.emptyTitle" - :description="$options.i18n.emptyDescription" - /> - <gl-table v-else :items="subscriptions" :fields="$options.fields"> - <template #cell(name)="{ item }"> - <group-item-name :group="item.group" /> - </template> - <template #cell(created_at)="{ item }"> - <timeago-tooltip :time="item.created_at" /> - </template> - <template #cell(actions)="{ item }"> - <gl-button - :class="unlinkBtnClass(item)" - category="secondary" - :loading="isLoadingItem(item)" - :disabled="!isEmpty(loadingItem)" - @click.prevent="onClick(item)" - >{{ __('Unlink') }}</gl-button - > - </template> - </gl-table> - </div> + <gl-table :items="subscriptions" :fields="$options.fields"> + <template #cell(name)="{ item }"> + <group-item-name :group="item.group" /> + </template> + <template #cell(created_at)="{ item }"> + <timeago-tooltip :time="item.created_at" /> + </template> + <template #cell(actions)="{ item }"> + <gl-button + :class="unlinkBtnClass(item)" + category="secondary" + :loading="isLoadingItem(item)" + :disabled="isUnlinkButtonDisabled(loadingItem)" + @click.prevent="onClick(item)" + >{{ __('Unlink') }}</gl-button + > + </template> + </gl-table> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js index 8dff83eabb5..2a65b7bc1fa 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/constants.js +++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js @@ -1,3 +1,5 @@ export const DEFAULT_GROUPS_PER_PAGE = 10; export const ALERT_LOCALSTORAGE_KEY = 'gitlab_alert'; export const MINIMUM_SEARCH_TERM_LENGTH = 3; + +export const ADD_NAMESPACE_MODAL_ID = 'add-namespace-modal'; diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js index f1262be0174..8a7a80d885d 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/index.js +++ b/app/assets/javascripts/jira_connect/subscriptions/index.js @@ -7,16 +7,20 @@ import Translate from '~/vue_shared/translate'; import JiraConnectApp from './components/app.vue'; import createStore from './store'; -import { getLocation, sizeToParent } from './utils'; +import { getGitlabSignInURL, sizeToParent } from './utils'; const store = createStore(); +/** + * Add `return_to` query param to all HAML-defined GitLab sign in links. + */ const updateSignInLinks = async () => { - const location = await getLocation(); - Array.from(document.querySelectorAll('.js-jira-connect-sign-in')).forEach((el) => { - const updatedLink = `${el.getAttribute('href')}?return_to=${location}`; - el.setAttribute('href', updatedLink); - }); + await Promise.all( + Array.from(document.querySelectorAll('.js-jira-connect-sign-in')).map(async (el) => { + const updatedLink = await getGitlabSignInURL(el.getAttribute('href')); + el.setAttribute('href', updatedLink); + }), + ); }; export async function initJiraConnect() { diff --git a/app/assets/javascripts/jira_connect/subscriptions/utils.js b/app/assets/javascripts/jira_connect/subscriptions/utils.js index ed7a9484a81..b2d03a1fbba 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/utils.js +++ b/app/assets/javascripts/jira_connect/subscriptions/utils.js @@ -1,4 +1,5 @@ import AccessorUtilities from '~/lib/utils/accessor'; +import { objectToQuery } from '~/lib/utils/url_utility'; import { ALERT_LOCALSTORAGE_KEY } from './constants'; const isFunction = (fn) => typeof fn === 'function'; @@ -71,3 +72,17 @@ export const sizeToParent = () => { AP.sizeToParent(); } }; + +export const getGitlabSignInURL = async (signInURL) => { + const location = await getLocation(); + + if (location) { + const queryParams = { + return_to: location, + }; + + return `${signInURL}?${objectToQuery(queryParams)}`; + } + + return signInURL; +}; diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js index 003f3c7107e..695a237bf50 100644 --- a/app/assets/javascripts/jira_import/index.js +++ b/app/assets/javascripts/jira_import/index.js @@ -6,7 +6,7 @@ import App from './components/jira_import_app.vue'; Vue.use(VueApollo); -const defaultClient = createDefaultClient({}, { assumeImmutableResults: true }); +const defaultClient = createDefaultClient(); const apolloProvider = new VueApollo({ defaultClient, diff --git a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql index 2aacc5cf668..6fec07cc6f8 100644 --- a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql +++ b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql @@ -1,6 +1,6 @@ #import "./jira_import.fragment.graphql" -query($fullPath: ID!) { +query getJiraImportDetails($fullPath: ID!) { project(fullPath: $fullPath) { jiraImportStatus jiraImports { diff --git a/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql b/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql index cca33af342c..7af30ffb869 100644 --- a/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql +++ b/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql @@ -1,4 +1,4 @@ -mutation($input: JiraImportUsersInput!) { +mutation getJiraUserMapping($input: JiraImportUsersInput!) { jiraImportUsers(input: $input) { jiraUsers { jiraAccountId diff --git a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql index 807374bf06c..29f8428fbcf 100644 --- a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql +++ b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql @@ -1,6 +1,6 @@ #import "./jira_import.fragment.graphql" -mutation($input: JiraImportStartInput!) { +mutation initiateJiraImport($input: JiraImportStartInput!) { jiraImportStart(input: $input) { jiraImport { ...JiraImport diff --git a/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql b/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql index 06f119e75ed..6ea8963e6a6 100644 --- a/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql +++ b/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql @@ -1,4 +1,4 @@ -query searchProjectMembers($fullPath: ID!, $search: String) { +query jiraSearchProjectMembers($fullPath: ID!, $search: String) { project(fullPath: $fullPath) { projectMembers(search: $search) { nodes { diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue index 269551ff9aa..7a52a1b0d6b 100644 --- a/app/assets/javascripts/jobs/components/manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue @@ -1,5 +1,12 @@ <script> -import { GlButton, GlLink, GlSprintf } from '@gitlab/ui'; +import { + GlFormInputGroup, + GlInputGroupText, + GlFormInput, + GlButton, + GlLink, + GlSprintf, +} from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { mapActions } from 'vuex'; import { helpPagePath } from '~/helpers/help_page_helper'; @@ -8,6 +15,9 @@ import { s__ } from '~/locale'; export default { name: 'ManualVariablesForm', components: { + GlFormInputGroup, + GlInputGroupText, + GlFormInput, GlButton, GlLink, GlSprintf, @@ -32,6 +42,9 @@ export default { value: 'value', }, i18n: { + header: s__('CiVariables|Variables'), + keyLabel: s__('CiVariables|Key'), + valueLabel: s__('CiVariables|Value'), keyPlaceholder: s__('CiVariables|Input variable key'), valuePlaceholder: s__('CiVariables|Input variable value'), formHelpText: s__( @@ -40,9 +53,13 @@ export default { }, data() { return { - variables: [], - key: '', - secretValue: '', + variables: [ + { + key: '', + secretValue: '', + id: uniqueId(), + }, + ], triggerBtnDisabled: false, }; }, @@ -50,40 +67,32 @@ export default { variableSettings() { return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' }); }, - }, - watch: { - key(newVal) { - this.handleValueChange(newVal, this.$options.inputTypes.key); - }, - secretValue(newVal) { - this.handleValueChange(newVal, this.$options.inputTypes.value); + preparedVariables() { + // we need to ensure no empty variables are passed to the API + // and secretValue should be snake_case when passed to the API + return this.variables + .filter((variable) => variable.key !== '') + .map(({ key, secretValue }) => ({ key, secret_value: secretValue })); }, }, methods: { ...mapActions(['triggerManualJob']), - handleValueChange(newValue, type) { - if (newValue !== '') { - this.createNewVariable(type); - this.resetForm(); - } + canRemove(index) { + return index < this.variables.length - 1; }, - createNewVariable(type) { - const newVariable = { - key: this.key, - secret_value: this.secretValue, - id: uniqueId(), - }; + addEmptyVariable() { + const lastVar = this.variables[this.variables.length - 1]; - this.variables.push(newVariable); + if (lastVar.key === '') { + return; + } - return this.$nextTick().then(() => { - this.$refs[`${this.$options.inputTypes[type]}-${newVariable.id}`][0].focus(); + this.variables.push({ + key: '', + secret_value: '', + id: uniqueId(), }); }, - resetForm() { - this.key = ''; - this.secretValue = ''; - }, deleteVariable(id) { this.variables.splice( this.variables.findIndex((el) => el.id === id), @@ -93,112 +102,92 @@ export default { trigger() { this.triggerBtnDisabled = true; - this.triggerManualJob(this.variables); + this.triggerManualJob(this.preparedVariables); }, }, }; </script> <template> - <div class="col-12" data-testid="manual-vars-form"> - <label>{{ s__('CiVariables|Variables') }}</label> - - <div class="ci-table"> - <div class="gl-responsive-table-row table-row-header pb-0 pt-0 border-0" role="row"> - <div class="table-section section-50" role="rowheader">{{ s__('CiVariables|Key') }}</div> - <div class="table-section section-50" role="rowheader">{{ s__('CiVariables|Value') }}</div> - </div> + <div class="row gl-justify-content-center"> + <div class="col-10" data-testid="manual-vars-form"> + <label>{{ $options.i18n.header }}</label> <div - v-for="variable in variables" + v-for="(variable, index) in variables" :key="variable.id" - class="gl-responsive-table-row" + class="gl-display-flex gl-align-items-center gl-mb-4" data-testid="ci-variable-row" > - <div class="table-section section-50"> - <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div> - <div class="table-mobile-content gl-mr-3"> - <input - :ref="`${$options.inputTypes.key}-${variable.id}`" - v-model="variable.key" - :placeholder="$options.i18n.keyPlaceholder" - class="ci-variable-body-item form-control" - data-testid="ci-variable-key" - /> - </div> - </div> + <gl-form-input-group class="gl-mr-4 gl-flex-grow-1"> + <template #prepend> + <gl-input-group-text> + {{ $options.i18n.keyLabel }} + </gl-input-group-text> + </template> + <gl-form-input + :ref="`${$options.inputTypes.key}-${variable.id}`" + v-model="variable.key" + :placeholder="$options.i18n.keyPlaceholder" + data-testid="ci-variable-key" + @change="addEmptyVariable" + /> + </gl-form-input-group> - <div class="table-section section-50"> - <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Value') }}</div> - <div class="table-mobile-content gl-mr-3"> - <input - :ref="`${$options.inputTypes.value}-${variable.id}`" - v-model="variable.secret_value" - :placeholder="$options.i18n.valuePlaceholder" - class="ci-variable-body-item form-control" - data-testid="ci-variable-value" - /> - </div> - </div> + <gl-form-input-group class="gl-flex-grow-2"> + <template #prepend> + <gl-input-group-text> + {{ $options.i18n.valueLabel }} + </gl-input-group-text> + </template> + <gl-form-input + :ref="`${$options.inputTypes.value}-${variable.id}`" + v-model="variable.secretValue" + :placeholder="$options.i18n.valuePlaceholder" + data-testid="ci-variable-value" + /> + </gl-form-input-group> - <div class="table-section section-10"> - <div class="table-mobile-header" role="rowheader"></div> - <div class="table-mobile-content justify-content-end"> - <gl-button - category="tertiary" - icon="clear" - :aria-label="__('Delete variable')" - data-testid="delete-variable-btn" - @click="deleteVariable(variable.id)" - /> - </div> - </div> + <!-- delete variable button placeholder to not break flex layout --> + <div + v-if="!canRemove(index)" + class="gl-w-7 gl-mr-3" + data-testid="delete-variable-btn-placeholder" + ></div> + + <gl-button + v-if="canRemove(index)" + class="gl-flex-grow-0 gl-flex-basis-0" + category="tertiary" + variant="danger" + icon="clear" + :aria-label="__('Delete variable')" + data-testid="delete-variable-btn" + @click="deleteVariable(variable.id)" + /> </div> - <div class="gl-responsive-table-row"> - <div class="table-section section-50"> - <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div> - <div class="table-mobile-content gl-mr-3"> - <input - ref="inputKey" - v-model="key" - class="js-input-key form-control" - :placeholder="$options.i18n.keyPlaceholder" - /> - </div> - </div> - <div class="table-section section-50"> - <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Value') }}</div> - <div class="table-mobile-content gl-mr-3"> - <input - ref="inputSecretValue" - v-model="secretValue" - class="ci-variable-body-item form-control" - :placeholder="$options.i18n.valuePlaceholder" - /> - </div> - </div> + <div class="gl-text-center gl-mt-5"> + <gl-sprintf :message="$options.i18n.formHelpText"> + <template #link="{ content }"> + <gl-link :href="variableSettings" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> + <div class="gl-display-flex gl-justify-content-center gl-mt-5"> + <gl-button + class="gl-mt-5" + variant="info" + category="primary" + :aria-label="__('Trigger manual job')" + :disabled="triggerBtnDisabled" + data-testid="trigger-manual-job-btn" + @click="trigger" + > + {{ action.button_title }} + </gl-button> </div> - </div> - <div class="gl-text-center gl-mt-3"> - <gl-sprintf :message="$options.i18n.formHelpText"> - <template #link="{ content }"> - <gl-link :href="variableSettings" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </div> - <div class="d-flex justify-content-center"> - <gl-button - variant="info" - category="primary" - :aria-label="__('Trigger manual job')" - :disabled="triggerBtnDisabled" - data-testid="trigger-manual-job-btn" - @click="trigger" - > - {{ action.button_title }} - </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue index fef5b37015c..b1ddede8fe8 100644 --- a/app/assets/javascripts/jobs/components/trigger_block.vue +++ b/app/assets/javascripts/jobs/components/trigger_block.vue @@ -84,9 +84,13 @@ export default { > </p> - <gl-table :items="trigger.variables" :fields="$options.fields" small bordered> + <gl-table :items="trigger.variables" :fields="$options.fields" small bordered fixed> + <template #cell(key)="{ item }"> + <span class="gl-overflow-break-word">{{ item.key }}</span> + </template> + <template #cell(value)="data"> - {{ getDisplayValue(data.value) }} + <span class="gl-overflow-break-word">{{ getDisplayValue(data.value) }}</span> </template> </gl-table> </template> diff --git a/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js index ad92bd4de42..9b7901685b6 100644 --- a/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js +++ b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js @@ -9,10 +9,6 @@ import { isNavigatingAway } from '~/lib/utils/is_navigating_away'; * @returns {ApolloLink|null} */ export const getSuppressNetworkErrorsDuringNavigationLink = () => { - if (!gon.features?.suppressApolloErrorsDuringNavigation) { - return null; - } - return onError(({ networkError }) => { if (networkError && isNavigatingAway()) { // Return an observable that will never notify any subscribers with any diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 39bf804b54e..df2e85afe24 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -48,7 +48,6 @@ export const stripWhitespaceFromQuery = (url, path) => { export default (resolvers = {}, config = {}) => { const { - assumeImmutableResults, baseUrl, batchMax = 10, cacheConfig, @@ -161,10 +160,10 @@ export default (resolvers = {}, config = {}) => { link: appLink, cache: new InMemoryCache({ ...cacheConfig, - freezeResults: assumeImmutableResults, + freezeResults: true, }), resolvers, - assumeImmutableResults, + assumeImmutableResults: true, defaultOptions: { query: { fetchPolicy, diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 813fd3dbb1e..a82dad7e2c9 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -220,16 +220,16 @@ export const scrollToElement = (element, options = {}) => { // In the previous implementation, jQuery naturally deferred this scrolling. // Unfortunately, we're quite coupled to this implementation detail now. defer(() => { - const { duration = 200, offset = 0 } = options; + const { duration = 200, offset = 0, behavior = duration ? 'smooth' : 'auto' } = options; const y = el.getBoundingClientRect().top + window.pageYOffset + offset - contentTop(); - window.scrollTo({ top: y, behavior: duration ? 'smooth' : 'auto' }); + window.scrollTo({ top: y, behavior }); }); } }; -export const scrollToElementWithContext = (element) => { +export const scrollToElementWithContext = (element, options) => { const offsetMultiplier = -0.1; - return scrollToElement(element, { offset: window.innerHeight * offsetMultiplier }); + return scrollToElement(element, { ...options, offset: window.innerHeight * offsetMultiplier }); }; /** @@ -688,17 +688,20 @@ export const searchBy = (query = '', searchSpace = {}) => { */ export const isScopedLabel = ({ title = '' } = {}) => title.includes(SCOPED_LABEL_DELIMITER); +const scopedLabelRegex = new RegExp(`(.*)${SCOPED_LABEL_DELIMITER}.*`); + /** - * Returns the base value of the scoped label - * - * Expected Label to be an Object with `title` as a key: - * { title: 'LabelTitle', ...otherProperties }; + * Returns the key of a scoped label. + * For example: + * - returns `scoped` if the label is `scoped::value`. + * - returns `scoped::label` if the label is `scoped::label::value`. * - * @param {Object} label - * @returns String + * @param {Object} label object containing `title` property + * @returns String scoped label key, or full label if it is not a scoped label */ -export const scopedLabelKey = ({ title = '' }) => - isScopedLabel({ title }) && title.split(SCOPED_LABEL_DELIMITER)[0]; +export const scopedLabelKey = ({ title = '' }) => { + return title.replace(scopedLabelRegex, '$1'); +}; // Methods to set and get Cookie export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 }); diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue new file mode 100644 index 00000000000..733d0f69f5d --- /dev/null +++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue @@ -0,0 +1,47 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + cancelAction: { text: __('Cancel') }, + components: { + GlModal, + }, + props: { + primaryText: { + type: String, + required: false, + default: __('OK'), + }, + primaryVariant: { + type: String, + required: false, + default: 'confirm', + }, + }, + computed: { + primaryAction() { + return { text: this.primaryText, attributes: { variant: this.primaryVariant } }; + }, + }, + mounted() { + this.$refs.modal.show(); + }, +}; +</script> + +<template> + <gl-modal + ref="modal" + size="sm" + modal-id="confirmationModal" + body-class="gl-display-flex" + :action-primary="primaryAction" + :action-cancel="$options.cancelAction" + hide-header + @primary="$emit('confirmed')" + @hidden="$emit('closed')" + > + <div class="gl-align-self-center"><slot></slot></div> + </gl-modal> +</template> diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js new file mode 100644 index 00000000000..fdd0e045d07 --- /dev/null +++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js @@ -0,0 +1,55 @@ +import Vue from 'vue'; + +export function confirmAction(message, { primaryBtnVariant, primaryBtnText } = {}) { + return new Promise((resolve) => { + let confirmed = false; + + const component = new Vue({ + components: { + ConfirmModal: () => import('./confirm_modal.vue'), + }, + render(h) { + return h( + 'confirm-modal', + { + props: { + primaryVariant: primaryBtnVariant, + primaryText: primaryBtnText, + }, + on: { + confirmed() { + confirmed = true; + }, + closed() { + component.$destroy(); + resolve(confirmed); + }, + }, + }, + [message], + ); + }, + }).$mount(); + }); +} + +export function confirmViaGlModal(message, element) { + const primaryBtnConfig = {}; + + const confirmBtnVariant = element.getAttribute('data-confirm-btn-variant'); + + if (confirmBtnVariant) { + primaryBtnConfig.primaryBtnVariant = confirmBtnVariant; + } + + const screenReaderText = + element.querySelector('.gl-sr-only')?.textContent || + element.querySelector('.sr-only')?.textContent || + element.getAttribute('aria-label'); + + if (screenReaderText) { + primaryBtnConfig.primaryBtnText = screenReaderText; + } + + return confirmAction(message, primaryBtnConfig); +} diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 0e5a23a5cbb..36c6545164e 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -24,3 +24,5 @@ export const DEFAULT_TH_CLASSES = // We set the drawer's z-index to 252 to clear flash messages that might // be displayed in the page and that have a z-index of 251. export const DRAWER_Z_INDEX = 252; + +export const MIN_USERNAME_LENGTH = 2; diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js index 3c446c21865..7bff2bf3e47 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js @@ -14,33 +14,33 @@ import { s__, n__, __, sprintf } from '../../../locale'; export const getMonthNames = (abbreviated) => { if (abbreviated) { return [ - s__('Jan'), - s__('Feb'), - s__('Mar'), - s__('Apr'), - s__('May'), - s__('Jun'), - s__('Jul'), - s__('Aug'), - s__('Sep'), - s__('Oct'), - s__('Nov'), - s__('Dec'), + __('Jan'), + __('Feb'), + __('Mar'), + __('Apr'), + __('May'), + __('Jun'), + __('Jul'), + __('Aug'), + __('Sep'), + __('Oct'), + __('Nov'), + __('Dec'), ]; } return [ - s__('January'), - s__('February'), - s__('March'), - s__('April'), - s__('May'), - s__('June'), - s__('July'), - s__('August'), - s__('September'), - s__('October'), - s__('November'), - s__('December'), + __('January'), + __('February'), + __('March'), + __('April'), + __('May'), + __('June'), + __('July'), + __('August'), + __('September'), + __('October'), + __('November'), + __('December'), ]; }; diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js index b8b63bf58d4..f99a4927338 100644 --- a/app/assets/javascripts/lib/utils/file_upload.js +++ b/app/assets/javascripts/lib/utils/file_upload.js @@ -15,13 +15,17 @@ export default (buttonSelector, fileSelector) => { }); }; -export const getFilename = ({ clipboardData }) => { - let value; - if (window.clipboardData && window.clipboardData.getData) { - value = window.clipboardData.getData('Text'); - } else if (clipboardData && clipboardData.getData) { - value = clipboardData.getData('text/plain'); +export const getFilename = (file) => { + let fileName; + if (file) { + fileName = file.name; } - value = value.split('\r'); - return value[0]; + + return fileName; +}; + +export const validateImageName = (file) => { + const fileName = file.name ? file.name : 'image.png'; + const legalImageRegex = /^[\w.\-+]+\.(png|jpg|jpeg|gif|bmp|tiff|ico|webp)$/; + return legalImageRegex.test(fileName) ? fileName : 'image.png'; }; diff --git a/app/assets/javascripts/lib/utils/rails_ujs.js b/app/assets/javascripts/lib/utils/rails_ujs.js index 8b40cc7bd11..6b1985a23ba 100644 --- a/app/assets/javascripts/lib/utils/rails_ujs.js +++ b/app/assets/javascripts/lib/utils/rails_ujs.js @@ -1,4 +1,42 @@ import Rails from '@rails/ujs'; +import { confirmViaGlModal } from './confirm_via_gl_modal/confirm_via_gl_modal'; + +function monkeyPatchConfirmModal() { + /** + * This function is used to replace the `Rails.confirm` which uses `window.confirm` + * + * This function opens a confirmation modal which will resolve in a promise. + * Because the `Rails.confirm` API is synchronous, we go with a little hack here: + * + * 1. User clicks on something with `data-confirm` + * 2. We open the modal and return `false`, ending the "Rails" event chain + * 3. If the modal is closed and the user "confirmed" the action + * 1. replace the `Rails.confirm` with a function that always returns `true` + * 2. click the same element programmatically + * + * @param message {String} Message to be shown in the modal + * @param element {HTMLElement} Element that was clicked on + * @returns {boolean} + */ + function confirmViaModal(message, element) { + confirmViaGlModal(message, element) + .then((confirmed) => { + if (confirmed) { + Rails.confirm = () => true; + element.click(); + Rails.confirm = confirmViaModal; + } + }) + .catch(() => {}); + return false; + } + + Rails.confirm = confirmViaModal; +} + +if (gon?.features?.bootstrapConfirmationModals) { + monkeyPatchConfirmModal(); +} export const initRails = () => { // eslint-disable-next-line no-underscore-dangle diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 0804d792631..40dd29bea76 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -233,7 +233,7 @@ export function insertMarkdownText({ } } else if (tag.indexOf(textPlaceholder) > -1) { textToInsert = tag.replace(textPlaceholder, () => - selected.replace(/\\n/g, '\n').replace('%br', '\\n'), + selected.replace(/\\n/g, '\n').replace(/%br/g, '\\n'), ); } else { textToInsert = String(startChar) + tag + selected + (wrap ? tag : ''); diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index c70d23d06ec..e53a39cde06 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -5,6 +5,12 @@ const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`); const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`); const SHA_REGEX = /[\da-f]{40}/gi; +// About GitLab default host (overwrite in jh) +export const PROMO_HOST = 'about.gitlab.com'; + +// About Gitlab default url (overwrite in jh) +export const PROMO_URL = `https://${PROMO_HOST}`; + // Reset the cursor in a Regex so that multiple uses before a recompile don't fail function resetRegExp(regex) { regex.lastIndex = 0; /* eslint-disable-line no-param-reassign */ diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue index 2a60825a427..c9e7b034950 100644 --- a/app/assets/javascripts/logs/components/environment_logs.vue +++ b/app/assets/javascripts/logs/components/environment_logs.vue @@ -130,7 +130,7 @@ export default { }} <a :href="clusterApplicationsDocumentationPath"> <strong> - {{ s__('View Documentation') }} + {{ __('View Documentation') }} </strong> </a> </gl-alert> diff --git a/app/assets/javascripts/members/components/app.vue b/app/assets/javascripts/members/components/app.vue index 0ec39f58930..c5083bc4826 100644 --- a/app/assets/javascripts/members/components/app.vue +++ b/app/assets/javascripts/members/components/app.vue @@ -11,7 +11,10 @@ export default { components: { MembersTable, FilterSortContainer, GlAlert }, provide() { return { - namespace: this.namespace, + // We can't use this.namespace due to bug in vue-apollo when + // provide is called in beforeCreate + // See https://github.com/vuejs/vue-apollo/pull/1153 for details + namespace: this.$options.propsData.namespace, }; }, props: { diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue deleted file mode 100644 index e5d7e2ea2eb..00000000000 --- a/app/assets/javascripts/monitoring/components/alert_widget.vue +++ /dev/null @@ -1,285 +0,0 @@ -<script> -import { GlBadge, GlLoadingIcon, GlModalDirective, GlIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; -import { values, get } from 'lodash'; -import createFlash from '~/flash'; -import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; -import { s__ } from '~/locale'; -import { OPERATORS } from '../constants'; -import AlertsService from '../services/alerts_service'; -import { alertsValidator, queriesValidator } from '../validators'; -import AlertWidgetForm from './alert_widget_form.vue'; - -export default { - components: { - AlertWidgetForm, - GlBadge, - GlLoadingIcon, - GlIcon, - GlTooltip, - GlSprintf, - }, - directives: { - GlModal: GlModalDirective, - }, - props: { - alertsEndpoint: { - type: String, - required: true, - }, - showLoadingState: { - type: Boolean, - required: false, - default: true, - }, - // { [alertPath]: { alert_attributes } }. Populated from subsequent API calls. - // Includes only the metrics/alerts to be managed by this widget. - alertsToManage: { - type: Object, - required: false, - default: () => ({}), - validator: alertsValidator, - }, - // [{ metric+query_attributes }]. Represents queries (and alerts) we know about - // on intial fetch. Essentially used for reference. - relevantQueries: { - type: Array, - required: true, - validator: queriesValidator, - }, - modalId: { - type: String, - required: true, - }, - }, - data() { - return { - service: null, - errorMessage: null, - isLoading: false, - apiAction: 'create', - }; - }, - i18n: { - alertsCountMsg: s__('PrometheusAlerts|%{count} alerts applied'), - singleFiringMsg: s__('PrometheusAlerts|Firing: %{alert}'), - multipleFiringMsg: s__('PrometheusAlerts|%{firingCount} firing'), - firingAlertsTooltip: s__('PrometheusAlerts|Firing: %{alerts}'), - }, - computed: { - singleAlertSummary() { - return { - message: this.isFiring ? this.$options.i18n.singleFiringMsg : this.thresholds[0], - alert: this.thresholds[0], - }; - }, - multipleAlertsSummary() { - return { - message: this.isFiring - ? `${this.$options.i18n.alertsCountMsg}, ${this.$options.i18n.multipleFiringMsg}` - : this.$options.i18n.alertsCountMsg, - count: this.thresholds.length, - firingCount: this.firingAlerts.length, - }; - }, - shouldShowLoadingIcon() { - return this.showLoadingState && this.isLoading; - }, - thresholds() { - const alertsToManage = Object.keys(this.alertsToManage); - return alertsToManage.map(this.formatAlertSummary); - }, - hasAlerts() { - return Boolean(Object.keys(this.alertsToManage).length); - }, - hasMultipleAlerts() { - return this.thresholds.length > 1; - }, - isFiring() { - return Boolean(this.firingAlerts.length); - }, - firingAlerts() { - return values(this.alertsToManage).filter((alert) => - this.passedAlertThreshold(this.getQueryData(alert), alert), - ); - }, - formattedFiringAlerts() { - return this.firingAlerts.map((alert) => this.formatAlertSummary(alert.alert_path)); - }, - configuredAlert() { - return this.hasAlerts ? values(this.alertsToManage)[0].metricId : ''; - }, - }, - created() { - this.service = new AlertsService({ alertsEndpoint: this.alertsEndpoint }); - this.fetchAlertData(); - }, - methods: { - fetchAlertData() { - this.isLoading = true; - - const queriesWithAlerts = this.relevantQueries.filter((query) => query.alert_path); - - return Promise.all( - queriesWithAlerts.map((query) => - this.service - .readAlert(query.alert_path) - .then((alertAttributes) => this.setAlert(alertAttributes, query.metricId)), - ), - ) - .then(() => { - this.isLoading = false; - }) - .catch(() => { - createFlash({ - message: s__('PrometheusAlerts|Error fetching alert'), - }); - this.isLoading = false; - }); - }, - setAlert(alertAttributes, metricId) { - this.$emit('setAlerts', alertAttributes.alert_path, { ...alertAttributes, metricId }); - }, - removeAlert(alertPath) { - this.$emit('setAlerts', alertPath, null); - }, - formatAlertSummary(alertPath) { - const alert = this.alertsToManage[alertPath]; - const alertQuery = this.relevantQueries.find((query) => query.metricId === alert.metricId); - - return `${alertQuery.label} ${alert.operator} ${alert.threshold}`; - }, - passedAlertThreshold(data, alert) { - const { threshold, operator } = alert; - - switch (operator) { - case OPERATORS.greaterThan: - return data.some((value) => value > threshold); - case OPERATORS.lessThan: - return data.some((value) => value < threshold); - case OPERATORS.equalTo: - return data.some((value) => value === threshold); - default: - return false; - } - }, - getQueryData(alert) { - const alertQuery = this.relevantQueries.find((query) => query.metricId === alert.metricId); - - return get(alertQuery, 'result[0].values', []).map((value) => get(value, '[1]', null)); - }, - showModal() { - this.$root.$emit(BV_SHOW_MODAL, this.modalId); - }, - hideModal() { - this.errorMessage = null; - this.$root.$emit(BV_HIDE_MODAL, this.modalId); - }, - handleSetApiAction(apiAction) { - this.apiAction = apiAction; - }, - handleCreate({ operator, threshold, prometheus_metric_id, runbookUrl }) { - const newAlert = { operator, threshold, prometheus_metric_id, runbookUrl }; - this.isLoading = true; - this.service - .createAlert(newAlert) - .then((alertAttributes) => { - this.setAlert(alertAttributes, prometheus_metric_id); - this.isLoading = false; - this.hideModal(); - }) - .catch(() => { - this.errorMessage = s__('PrometheusAlerts|Error creating alert'); - this.isLoading = false; - }); - }, - handleUpdate({ alert, operator, threshold, runbookUrl }) { - const updatedAlert = { operator, threshold, runbookUrl }; - this.isLoading = true; - this.service - .updateAlert(alert, updatedAlert) - .then((alertAttributes) => { - this.setAlert(alertAttributes, this.alertsToManage[alert].metricId); - this.isLoading = false; - this.hideModal(); - }) - .catch(() => { - this.errorMessage = s__('PrometheusAlerts|Error saving alert'); - this.isLoading = false; - }); - }, - handleDelete({ alert }) { - this.isLoading = true; - this.service - .deleteAlert(alert) - .then(() => { - this.removeAlert(alert); - this.isLoading = false; - this.hideModal(); - }) - .catch(() => { - this.errorMessage = s__('PrometheusAlerts|Error deleting alert'); - this.isLoading = false; - }); - }, - }, -}; -</script> - -<template> - <div class="prometheus-alert-widget dropdown flex-grow-2 overflow-hidden"> - <gl-loading-icon v-if="shouldShowLoadingIcon" :inline="true" size="sm" /> - <span v-else-if="errorMessage" ref="alertErrorMessage" class="alert-error-message">{{ - errorMessage - }}</span> - <span - v-else-if="hasAlerts" - ref="alertCurrentSetting" - class="alert-current-setting cursor-pointer d-flex" - @click="showModal" - > - <gl-badge :variant="isFiring ? 'danger' : 'neutral'" class="d-flex-center text-truncate"> - <gl-icon name="warning" :size="16" class="flex-shrink-0" /> - <span class="text-truncate gl-pl-2"> - <gl-sprintf - :message=" - hasMultipleAlerts ? multipleAlertsSummary.message : singleAlertSummary.message - " - > - <template #alert> - {{ singleAlertSummary.alert }} - </template> - <template #count> - {{ multipleAlertsSummary.count }} - </template> - <template #firingCount> - {{ multipleAlertsSummary.firingCount }} - </template> - </gl-sprintf> - </span> - </gl-badge> - <gl-tooltip v-if="hasMultipleAlerts && isFiring" :target="() => $refs.alertCurrentSetting"> - <gl-sprintf :message="$options.i18n.firingAlertsTooltip"> - <template #alerts> - <div v-for="alert in formattedFiringAlerts" :key="alert.alert_path"> - {{ alert }} - </div> - </template> - </gl-sprintf> - </gl-tooltip> - </span> - <alert-widget-form - ref="widgetForm" - :disabled="isLoading" - :alerts-to-manage="alertsToManage" - :relevant-queries="relevantQueries" - :error-message="errorMessage" - :configured-alert="configuredAlert" - :modal-id="modalId" - @create="handleCreate" - @update="handleUpdate" - @delete="handleDelete" - @cancel="hideModal" - @setAction="handleSetApiAction" - /> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue deleted file mode 100644 index 68fd3e256ec..00000000000 --- a/app/assets/javascripts/monitoring/components/alert_widget_form.vue +++ /dev/null @@ -1,324 +0,0 @@ -<script> -import { - GlLink, - GlButton, - GlButtonGroup, - GlFormGroup, - GlFormInput, - GlDropdown, - GlDropdownItem, - GlModal, - GlTooltipDirective, - GlIcon, -} from '@gitlab/ui'; -import { isEmpty, findKey } from 'lodash'; -import Vue from 'vue'; -import { __, s__ } from '~/locale'; -import TrackEventDirective from '~/vue_shared/directives/track_event'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import Translate from '~/vue_shared/translate'; -import { OPERATORS } from '../constants'; -import { alertsValidator, queriesValidator } from '../validators'; - -Vue.use(Translate); - -const SUBMIT_ACTION_TEXT = { - create: __('Add'), - update: __('Save'), - delete: __('Delete'), -}; - -const SUBMIT_BUTTON_CLASS = { - create: 'btn-success', - update: 'btn-success', - delete: 'btn-danger', -}; - -export default { - components: { - GlButton, - GlButtonGroup, - GlFormGroup, - GlFormInput, - GlDropdown, - GlDropdownItem, - GlModal, - GlLink, - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - TrackEvent: TrackEventDirective, - }, - mixins: [glFeatureFlagsMixin()], - props: { - disabled: { - type: Boolean, - required: true, - }, - errorMessage: { - type: String, - required: false, - default: '', - }, - configuredAlert: { - type: String, - required: false, - default: '', - }, - alertsToManage: { - type: Object, - required: false, - default: () => ({}), - validator: alertsValidator, - }, - relevantQueries: { - type: Array, - required: true, - validator: queriesValidator, - }, - modalId: { - type: String, - required: true, - }, - }, - data() { - return { - operators: OPERATORS, - operator: null, - threshold: null, - prometheusMetricId: null, - runbookUrl: null, - selectedAlert: {}, - alertQuery: '', - }; - }, - computed: { - isValidQuery() { - // TODO: Add query validation check (most likely via http request) - return this.alertQuery.length ? true : null; - }, - currentQuery() { - return this.relevantQueries.find((query) => query.metricId === this.prometheusMetricId) || {}; - }, - formDisabled() { - // We need a prometheusMetricId to determine whether we're - // creating/updating/deleting - return this.disabled || !(this.prometheusMetricId || this.isValidQuery); - }, - supportsComputedAlerts() { - return this.glFeatures.prometheusComputedAlerts; - }, - queryDropdownLabel() { - return this.currentQuery.label || s__('PrometheusAlerts|Select query'); - }, - haveValuesChanged() { - return ( - this.operator && - this.threshold === Number(this.threshold) && - (this.operator !== this.selectedAlert.operator || - this.threshold !== this.selectedAlert.threshold || - this.runbookUrl !== this.selectedAlert.runbookUrl) - ); - }, - submitAction() { - if (isEmpty(this.selectedAlert)) return 'create'; - if (this.haveValuesChanged) return 'update'; - return 'delete'; - }, - submitActionText() { - return SUBMIT_ACTION_TEXT[this.submitAction]; - }, - submitButtonClass() { - return SUBMIT_BUTTON_CLASS[this.submitAction]; - }, - isSubmitDisabled() { - return this.disabled || (this.submitAction === 'create' && !this.haveValuesChanged); - }, - dropdownTitle() { - return this.submitAction === 'create' - ? s__('PrometheusAlerts|Add alert') - : s__('PrometheusAlerts|Edit alert'); - }, - }, - watch: { - alertsToManage() { - this.resetAlertData(); - }, - submitAction() { - this.$emit('setAction', this.submitAction); - }, - }, - methods: { - selectQuery(queryId) { - const existingAlertPath = findKey(this.alertsToManage, (alert) => alert.metricId === queryId); - const existingAlert = this.alertsToManage[existingAlertPath]; - - if (existingAlert) { - const { operator, threshold, runbookUrl } = existingAlert; - - this.selectedAlert = existingAlert; - this.operator = operator; - this.threshold = threshold; - this.runbookUrl = runbookUrl; - } else { - this.selectedAlert = {}; - this.operator = this.operators.greaterThan; - this.threshold = null; - this.runbookUrl = null; - } - - this.prometheusMetricId = queryId; - }, - handleHidden() { - this.resetAlertData(); - this.$emit('cancel'); - }, - handleSubmit() { - this.$emit(this.submitAction, { - alert: this.selectedAlert.alert_path, - operator: this.operator, - threshold: this.threshold, - prometheus_metric_id: this.prometheusMetricId, - runbookUrl: this.runbookUrl, - }); - }, - handleShown() { - if (this.configuredAlert) { - this.selectQuery(this.configuredAlert); - } else if (this.relevantQueries.length === 1) { - this.selectQuery(this.relevantQueries[0].metricId); - } - }, - resetAlertData() { - this.operator = null; - this.threshold = null; - this.prometheusMetricId = null; - this.selectedAlert = {}; - this.runbookUrl = null; - }, - getAlertFormActionTrackingOption() { - const label = `${this.submitAction}_alert`; - return { - category: document.body.dataset.page, - action: 'click_button', - label, - }; - }, - }, - alertQueryText: { - label: __('Query'), - validFeedback: __('Query is valid'), - invalidFeedback: __('Invalid query'), - descriptionTooltip: __( - 'Example: Usage = single query. (Requested) / (Capacity) = multiple queries combined into a formula.', - ), - }, -}; -</script> - -<template> - <gl-modal - ref="alertModal" - :title="dropdownTitle" - :modal-id="modalId" - :ok-variant="submitAction === 'delete' ? 'danger' : 'success'" - :ok-disabled="formDisabled" - @ok.prevent="handleSubmit" - @hidden="handleHidden" - @shown="handleShown" - > - <div v-if="errorMessage" class="alert-modal-message danger_message">{{ errorMessage }}</div> - <div class="alert-form"> - <gl-form-group - v-if="supportsComputedAlerts" - :label="$options.alertQueryText.label" - label-for="alert-query-input" - :valid-feedback="$options.alertQueryText.validFeedback" - :invalid-feedback="$options.alertQueryText.invalidFeedback" - :state="isValidQuery" - > - <gl-form-input id="alert-query-input" v-model.trim="alertQuery" :state="isValidQuery" /> - <template #description> - <div class="d-flex align-items-center"> - {{ __('Single or combined queries') }} - <gl-icon - v-gl-tooltip="$options.alertQueryText.descriptionTooltip" - name="question" - class="gl-ml-2" - /> - </div> - </template> - </gl-form-group> - <gl-form-group v-else label-for="alert-query-dropdown" :label="$options.alertQueryText.label"> - <gl-dropdown - id="alert-query-dropdown" - :text="queryDropdownLabel" - toggle-class="dropdown-menu-toggle gl-border-1! qa-alert-query-dropdown" - > - <gl-dropdown-item - v-for="query in relevantQueries" - :key="query.metricId" - data-qa-selector="alert_query_option" - @click="selectQuery(query.metricId)" - > - {{ query.label }} - </gl-dropdown-item> - </gl-dropdown> - </gl-form-group> - <gl-button-group class="mb-3" :label="s__('PrometheusAlerts|Operator')"> - <gl-button - :class="{ active: operator === operators.greaterThan }" - :disabled="formDisabled" - @click="operator = operators.greaterThan" - > - {{ operators.greaterThan }} - </gl-button> - <gl-button - :class="{ active: operator === operators.equalTo }" - :disabled="formDisabled" - @click="operator = operators.equalTo" - > - {{ operators.equalTo }} - </gl-button> - <gl-button - :class="{ active: operator === operators.lessThan }" - :disabled="formDisabled" - @click="operator = operators.lessThan" - > - {{ operators.lessThan }} - </gl-button> - </gl-button-group> - <gl-form-group :label="s__('PrometheusAlerts|Threshold')" label-for="alerts-threshold"> - <gl-form-input - id="alerts-threshold" - v-model.number="threshold" - :disabled="formDisabled" - type="number" - data-qa-selector="alert_threshold_field" - /> - </gl-form-group> - <gl-form-group - :label="s__('PrometheusAlerts|Runbook URL (optional)')" - label-for="alert-runbook" - > - <gl-form-input - id="alert-runbook" - v-model="runbookUrl" - :disabled="formDisabled" - data-testid="alertRunbookField" - type="text" - :placeholder="s__('PrometheusAlerts|https://gitlab.com/gitlab-com/runbooks')" - /> - </gl-form-group> - </div> - <template #modal-ok> - <gl-link - v-track-event="getAlertFormActionTrackingOption()" - class="text-reset text-decoration-none" - > - {{ submitActionText }} - </gl-link> - </template> - </gl-modal> -</template> diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue index 4b54cffe231..ae079da0b0b 100644 --- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue +++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue @@ -1,8 +1,12 @@ <script> import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-empty-state.svg'; +import { GlSafeHtmlDirective } from '@gitlab/ui'; import { chartHeight } from '../../constants'; export default { + directives: { + SafeHtml: GlSafeHtmlDirective, + }, data() { return { height: chartHeight, @@ -18,14 +22,15 @@ export default { created() { this.chartEmptyStateIllustration = chartEmptyStateIllustration; }, + safeHtmlConfig: { ADD_TAGS: ['use'] }, }; </script> <template> <div class="d-flex flex-column justify-content-center"> <div + v-safe-html:[$options.safeHtmlConfig]="chartEmptyStateIllustration" class="gl-mt-3 svg-w-100 d-flex align-items-center" :style="svgContainerStyle" - v-html="chartEmptyStateIllustration /* eslint-disable-line vue/no-v-html */" ></div> <h5 class="text-center gl-mt-3">{{ __('No data to display') }}</h5> </div> diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 12f5e7efc96..5529a94874b 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -73,11 +73,6 @@ export default { required: false, default: chartHeight, }, - thresholds: { - type: Array, - required: false, - default: () => [], - }, legendLayout: { type: String, required: false, @@ -391,7 +386,6 @@ export default { :option="chartOptions" :format-tooltip-text="formatTooltipText" :format-annotations-tooltip-text="formatAnnotationsTooltipText" - :thresholds="thresholds" :width="width" :height="height" :legend-layout="legendLayout" diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index be9f104b81e..c9767330b73 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -8,10 +8,8 @@ import invalidUrl from '~/lib/utils/invalid_url'; import { ESC_KEY } from '~/lib/utils/keys'; import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; -import AlertsDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue'; import { defaultTimeRange } from '~/vue_shared/constants'; import TrackEventDirective from '~/vue_shared/directives/track_event'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { metricStates, keyboardShortcutKeys } from '../constants'; import { timeRangeFromUrl, @@ -30,7 +28,6 @@ import VariablesSection from './variables_section.vue'; export default { components: { - AlertsDeprecationWarning, VueDraggable, DashboardHeader, DashboardPanel, @@ -47,7 +44,6 @@ export default { GlTooltip: GlTooltipDirective, TrackEvent: TrackEventDirective, }, - mixins: [glFeatureFlagMixin()], props: { hasMetrics: { type: Boolean, @@ -399,8 +395,6 @@ export default { <template> <div class="prometheus-graphs" data-qa-selector="prometheus_graphs"> - <alerts-deprecation-warning v-if="!glFeatures.managedAlertsDeprecation" /> - <dashboard-header v-if="showHeader" ref="prometheusGraphsHeader" diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 446c6b52602..78e3b15913a 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -13,20 +13,16 @@ import { GlTooltip, GlTooltipDirective, } from '@gitlab/ui'; -import { mapValues, pickBy } from 'lodash'; import { mapState } from 'vuex'; -import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; import invalidUrl from '~/lib/utils/invalid_url'; import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility'; import { __, n__ } from '~/locale'; import TrackEventDirective from '~/vue_shared/directives/track_event'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { panelTypes } from '../constants'; import { graphDataToCsv } from '../csv_export'; import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; -import AlertWidget from './alert_widget.vue'; import MonitorAnomalyChart from './charts/anomaly.vue'; import MonitorBarChart from './charts/bar.vue'; import MonitorColumnChart from './charts/column.vue'; @@ -45,7 +41,6 @@ const events = { export default { components: { MonitorEmptyChart, - AlertWidget, GlIcon, GlLink, GlLoadingIcon, @@ -62,7 +57,6 @@ export default { GlTooltip: GlTooltipDirective, TrackEvent: TrackEventDirective, }, - mixins: [glFeatureFlagMixin()], props: { clipboardText: { type: String, @@ -84,16 +78,6 @@ export default { required: false, default: 'monitoringDashboard', }, - alertsEndpoint: { - type: String, - required: false, - default: null, - }, - prometheusAlertsAvailable: { - type: Boolean, - required: false, - default: false, - }, settingsPath: { type: String, required: false, @@ -104,7 +88,6 @@ export default { return { showTitleTooltip: false, zoomedTimeRange: null, - allAlerts: {}, expandBtnAvailable: Boolean(this.$listeners[events.expand]), }; }, @@ -211,7 +194,7 @@ export default { /** * In monitoring, Time Series charts typically support * a larger feature set like "annotations", "deployment - * data", alert "thresholds" and "datazoom". + * data" and "datazoom". * * This is intentional as Time Series are more frequently * used. @@ -252,34 +235,11 @@ export default { const { metrics = [] } = this.graphData; return metrics.some(({ metricId }) => this.metricsSavedToDb.includes(metricId)); }, - alertWidgetAvailable() { - const supportsAlerts = - this.isPanelType(panelTypes.AREA_CHART) || this.isPanelType(panelTypes.LINE_CHART); - return ( - supportsAlerts && - this.prometheusAlertsAvailable && - this.alertsEndpoint && - this.graphData && - this.hasMetricsInDb && - !this.glFeatures.managedAlertsDeprecation - ); - }, - alertModalId() { - return `alert-modal-${this.graphData.id}`; - }, }, mounted() { this.refreshTitleTooltip(); }, methods: { - getGraphAlerts(queries) { - if (!this.allAlerts) return {}; - const metricIdsForChart = queries.map((q) => q.metricId); - return pickBy(this.allAlerts, (alert) => metricIdsForChart.includes(alert.metricId)); - }, - getGraphAlertValues(queries) { - return Object.values(this.getGraphAlerts(queries)); - }, isPanelType(type) { return this.graphData?.type === type; }, @@ -310,24 +270,9 @@ export default { this.onExpand(); } }, - setAlerts(alertPath, alertAttributes) { - if (alertAttributes) { - this.$set(this.allAlerts, alertPath, alertAttributes); - } else { - this.$delete(this.allAlerts, alertPath); - } - }, safeUrl(url) { return isSafeURL(url) ? url : '#'; }, - showAlertModal() { - this.$root.$emit(BV_SHOW_MODAL, this.alertModalId); - }, - showAlertModalFromKeyboardShortcut() { - if (this.isContextualMenuShown) { - this.showAlertModal(); - } - }, visitLogsPage() { if (this.logsPathWithTimeRange) { visitUrl(relativePathToAbsolute(this.logsPathWithTimeRange, getBaseURL())); @@ -348,19 +293,6 @@ export default { this.$refs.copyChartLink.$el.firstChild.click(); } }, - getAlertRunbooks(queries) { - const hasRunbook = (alert) => Boolean(alert.runbookUrl); - const graphAlertsWithRunbooks = pickBy(this.getGraphAlerts(queries), hasRunbook); - const alertToRunbookTransform = (alert) => { - const alertQuery = queries.find((query) => query.metricId === alert.metricId); - return { - key: alert.metricId, - href: alert.runbookUrl, - label: alertQuery.label, - }; - }; - return mapValues(graphAlertsWithRunbooks, alertToRunbookTransform); - }, }, panelTypes, }; @@ -378,15 +310,6 @@ export default { <gl-tooltip :target="() => $refs.graphTitle" :disabled="!showTitleTooltip"> {{ title }} </gl-tooltip> - <alert-widget - v-if="isContextualMenuShown && alertWidgetAvailable" - class="mx-1" - :modal-id="alertModalId" - :alerts-endpoint="alertsEndpoint" - :relevant-queries="graphData.metrics" - :alerts-to-manage="getGraphAlerts(graphData.metrics)" - @setAlerts="setAlerts" - /> <div class="flex-grow-1"></div> <div v-if="graphDataIsLoading" class="mx-1 mt-1"> <gl-loading-icon size="sm" /> @@ -450,32 +373,6 @@ export default { > {{ __('Copy link to chart') }} </gl-dropdown-item> - <gl-dropdown-item - v-if="alertWidgetAvailable" - v-gl-modal="alertModalId" - data-qa-selector="alert_widget_menu_item" - > - {{ __('Alerts') }} - </gl-dropdown-item> - <gl-dropdown-item - v-for="runbook in getAlertRunbooks(graphData.metrics)" - :key="runbook.key" - :href="safeUrl(runbook.href)" - data-testid="runbookLink" - target="_blank" - rel="noopener noreferrer" - > - <span class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> - <span> - <gl-sprintf :message="s__('Metrics|View runbook - %{label}')"> - <template #label> - {{ runbook.label }} - </template> - </gl-sprintf> - </span> - <gl-icon name="external-link" /> - </span> - </gl-dropdown-item> <template v-if="graphData.links && graphData.links.length"> <gl-dropdown-divider /> @@ -515,7 +412,6 @@ export default { :deployment-data="deploymentData" :annotations="annotations" :project-path="projectPath" - :thresholds="getGraphAlertValues(graphData.metrics)" :group-id="groupId" :timezone="dashboardTimezone" :time-range="fixedCurrentTimeRange" diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue index 1765a2f3d5d..a63008aa382 100644 --- a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue +++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue @@ -63,7 +63,7 @@ export default { return !(this.form.fileName && !this.form.fileName.endsWith('.yml')); }, fileNameFeedback() { - return !this.fileNameState ? s__('The file name should have a .yml extension') : ''; + return !this.fileNameState ? __('The file name should have a .yml extension') : ''; }, }, mounted() { diff --git a/app/assets/javascripts/monitoring/monitoring_app.js b/app/assets/javascripts/monitoring/monitoring_app.js index cf79e71b9e0..ee67e5dd827 100644 --- a/app/assets/javascripts/monitoring/monitoring_app.js +++ b/app/assets/javascripts/monitoring/monitoring_app.js @@ -12,10 +12,7 @@ export default (props = {}) => { if (el && el.dataset) { const { metricsDashboardBasePath, ...dataset } = el.dataset; - const { - initState, - dataProps: { hasManagedPrometheus, ...dataProps }, - } = stateAndPropsFromDataset(dataset); + const { initState, dataProps } = stateAndPropsFromDataset(dataset); const store = createStore(initState); const router = createRouter(metricsDashboardBasePath); @@ -24,7 +21,6 @@ export default (props = {}) => { el, store, router, - provide: { hasManagedPrometheus }, data() { return { dashboardProps: { ...dataProps, ...props }, diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 74b777d7b44..336b613b620 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -41,7 +41,6 @@ export const stateAndPropsFromDataset = (dataset = {}) => { dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics); dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable); dataProps.prometheusAlertsAvailable = parseBoolean(dataProps.prometheusAlertsAvailable); - dataProps.hasManagedPrometheus = parseBoolean(dataProps.hasManagedPrometheus); return { initState: { diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index ea3e4e5604c..a1377415efe 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -23,8 +23,8 @@ export default function initMrNotes() { initNotesApp(); document.addEventListener('merged:UpdateActions', () => { - initRevertCommitModal(); - initCherryPickCommitModal(); + initRevertCommitModal('i_code_review_post_merge_submit_revert_modal'); + initCherryPickCommitModal('i_code_review_post_merge_submit_cherry_pick_modal'); }); requestIdleCallback(() => { diff --git a/app/assets/javascripts/mr_popover/index.js b/app/assets/javascripts/mr_popover/index.js index 6e46c5d3c1f..714cf67e0bd 100644 --- a/app/assets/javascripts/mr_popover/index.js +++ b/app/assets/javascripts/mr_popover/index.js @@ -48,12 +48,7 @@ export default (elements) => { Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - assumeImmutableResults: true, - }, - ), + defaultClient: createDefaultClient(), }); const listenerAddedAttr = 'data-mr-listener-added'; diff --git a/app/assets/javascripts/nav/components/responsive_home.vue b/app/assets/javascripts/nav/components/responsive_home.vue index c8f2f0bfb10..a80fda96363 100644 --- a/app/assets/javascripts/nav/components/responsive_home.vue +++ b/app/assets/javascripts/nav/components/responsive_home.vue @@ -55,6 +55,7 @@ export default { v-gl-tooltip="{ title: newDropdownViewModel.title }" :view-model="newDropdownViewModel" class="gl-ml-3" + data-qa-selector="mobile_new_dropdown" /> </header> <top-nav-menu-sections class="gl-h-full" :sections="menuSections" v-on="$listeners" /> diff --git a/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue b/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue index 154bed81854..bfcdcfc7292 100644 --- a/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue +++ b/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue @@ -46,6 +46,7 @@ export default { link-class="top-nav-menu-item" :href="menuItem.href" data-testid="item" + :data-qa-selector="`${menuItem.title.toLowerCase().replace(' ', '_')}_mobile_button`" > {{ menuItem.title }} </gl-dropdown-item> diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js index 6ee5d85a09f..54fe9d19002 100644 --- a/app/assets/javascripts/network/branch_graph.js +++ b/app/assets/javascripts/network/branch_graph.js @@ -272,6 +272,8 @@ export default class BranchGraph { return r .text(this.offsetX + this.unitSpace * this.mspace + 35, y, commit.message.split('\n')[0]) .attr({ + fill: 'currentColor', + class: 'gl-text-body', 'text-anchor': 'start', font: '14px Monaco, monospace', }); diff --git a/app/assets/javascripts/notebook/cells/output/latex.vue b/app/assets/javascripts/notebook/cells/output/latex.vue index db9e61dce82..d0ed963b55d 100644 --- a/app/assets/javascripts/notebook/cells/output/latex.vue +++ b/app/assets/javascripts/notebook/cells/output/latex.vue @@ -1,4 +1,5 @@ <script> +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import 'mathjax/es5/tex-svg'; import Prompt from '../prompt.vue'; @@ -7,6 +8,9 @@ export default { components: { Prompt, }, + directives: { + SafeHtml, + }, props: { count: { type: Number, @@ -33,13 +37,16 @@ export default { return svg.outerHTML; }, }, + safeHtmlConfig: { + // to support SVGs and custom tags for mathjax + ADD_TAGS: ['use', 'mjx-container', 'mjx-tool', 'mjx-status', 'mjx-tip'], + }, }; </script> <template> <div class="output"> <prompt type="Out" :count="count" :show-output="index === 0" /> - <!-- eslint-disable --> - <div ref="maths" v-html="code"></div> + <div ref="maths" v-safe-html:[$options.safeHtmlConfig]="code"></div> </div> </template> diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index 831e6dd8f92..33819c78c0f 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -78,8 +78,8 @@ export default { v-if="resolveAllDiscussionsIssuePath && !allResolved" v-gl-tooltip :href="resolveAllDiscussionsIssuePath" - :title="s__('Create issue to resolve all threads')" - :aria-label="s__('Create issue to resolve all threads')" + :title="__('Create issue to resolve all threads')" + :aria-label="__('Create issue to resolve all threads')" class="new-issue-for-discussion discussion-create-issue-btn" icon="issue-new" /> diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 6fcfa66ea49..d1df4eb848b 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -1,5 +1,6 @@ <script> import { mapGetters, mapActions } from 'vuex'; +import { GlIntersectionObserver } from '@gitlab/ui'; import { __ } from '~/locale'; import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; @@ -16,7 +17,9 @@ export default { ToggleRepliesWidget, NoteEditedText, DiscussionNotesRepliesWrapper, + GlIntersectionObserver, }, + inject: ['discussionObserverHandler'], props: { discussion: { type: Object, @@ -54,7 +57,11 @@ export default { }, }, computed: { - ...mapGetters(['userCanReply']), + ...mapGetters([ + 'userCanReply', + 'previousUnresolvedDiscussionId', + 'firstUnresolvedDiscussionId', + ]), hasReplies() { return Boolean(this.replies.length); }, @@ -77,9 +84,20 @@ export default { url: this.discussion.discussion_path, }; }, + isFirstUnresolved() { + return this.firstUnresolvedDiscussionId === this.discussion.id; + }, + }, + observerOptions: { + threshold: 0, + rootMargin: '0px 0px -50% 0px', }, methods: { - ...mapActions(['toggleDiscussion', 'setSelectedCommentPositionHover']), + ...mapActions([ + 'toggleDiscussion', + 'setSelectedCommentPositionHover', + 'setCurrentDiscussionId', + ]), componentName(note) { if (note.isPlaceholderNote) { if (note.placeholderType === SYSTEM_NOTE) { @@ -110,6 +128,18 @@ export default { this.setSelectedCommentPositionHover(); } }, + observerTriggered(entry) { + this.discussionObserverHandler({ + entry, + isFirstUnresolved: this.isFirstUnresolved, + currentDiscussion: { ...this.discussion }, + isDiffsPage: !this.isOverviewTab, + functions: { + setCurrentDiscussionId: this.setCurrentDiscussionId, + getPreviousUnresolvedDiscussionId: this.previousUnresolvedDiscussionId, + }, + }); + }, }, }; </script> @@ -122,33 +152,35 @@ export default { @mouseleave="handleMouseLeave(discussion)" > <template v-if="shouldGroupReplies"> - <component - :is="componentName(firstNote)" - :note="componentData(firstNote)" - :line="line || diffLine" - :discussion-file="discussion.diff_file" - :commit="commit" - :help-page-path="helpPagePath" - :show-reply-button="userCanReply" - :discussion-root="true" - :discussion-resolve-path="discussion.resolve_path" - :is-overview-tab="isOverviewTab" - @handleDeleteNote="$emit('deleteNote')" - @startReplying="$emit('startReplying')" - > - <template #discussion-resolved-text> - <note-edited-text - v-if="discussion.resolved" - :edited-at="discussion.resolved_at" - :edited-by="discussion.resolved_by" - :action-text="resolvedText" - class-name="discussion-headline-light js-discussion-headline discussion-resolved-text" - /> - </template> - <template #avatar-badge> - <slot name="avatar-badge"></slot> - </template> - </component> + <gl-intersection-observer :options="$options.observerOptions" @update="observerTriggered"> + <component + :is="componentName(firstNote)" + :note="componentData(firstNote)" + :line="line || diffLine" + :discussion-file="discussion.diff_file" + :commit="commit" + :help-page-path="helpPagePath" + :show-reply-button="userCanReply" + :discussion-root="true" + :discussion-resolve-path="discussion.resolve_path" + :is-overview-tab="isOverviewTab" + @handleDeleteNote="$emit('deleteNote')" + @startReplying="$emit('startReplying')" + > + <template #discussion-resolved-text> + <note-edited-text + v-if="discussion.resolved" + :edited-at="discussion.resolved_at" + :edited-by="discussion.resolved_by" + :action-text="resolvedText" + class-name="discussion-headline-light js-discussion-headline discussion-resolved-text" + /> + </template> + <template #avatar-badge> + <slot name="avatar-badge"></slot> + </template> + </component> + </gl-intersection-observer> <discussion-notes-replies-wrapper :is-diff-discussion="discussion.diff_discussion"> <toggle-replies-widget v-if="hasReplies" diff --git a/app/assets/javascripts/notes/components/multiline_comment_form.vue b/app/assets/javascripts/notes/components/multiline_comment_form.vue index 6ad565567be..1633b79c3be 100644 --- a/app/assets/javascripts/notes/components/multiline_comment_form.vue +++ b/app/assets/javascripts/notes/components/multiline_comment_form.vue @@ -1,6 +1,6 @@ <script> import { GlFormSelect, GlSprintf } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; +import { mapActions } from 'vuex'; import { getSymbol, getLineClasses } from './multiline_comment_utils'; export default { @@ -27,13 +27,12 @@ export default { }; }, computed: { - ...mapState({ selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition }), lineNumber() { return this.commentLineOptions[this.commentLineOptions.length - 1].text; }, }, created() { - const line = this.selectedCommentPosition?.start || this.lineRange?.start || this.line; + const line = this.lineRange?.start || this.line; this.commentLineStart = { line_code: line.line_code, @@ -42,7 +41,6 @@ export default { new_line: line.new_line, }; - if (this.selectedCommentPosition) return; this.highlightSelection(); }, destroyed() { diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 1ce1696e332..c09582d6287 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -1,5 +1,6 @@ <script> import $ from 'jquery'; +import { GlSafeHtmlDirective } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; @@ -19,6 +20,9 @@ export default { noteForm, Suggestions, }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, mixins: [autosave], props: { note: { @@ -144,6 +148,9 @@ export default { this.removeSuggestionInfoFromBatch(suggestionId); }, }, + safeHtmlConfig: { + ADD_TAGS: ['use', 'gl-emoji'], + }, }; </script> @@ -163,11 +170,7 @@ export default { @addToBatch="addSuggestionToBatch" @removeFromBatch="removeSuggestionFromBatch" /> - <div - v-else - class="note-text md" - v-html="note.note_html /* eslint-disable-line vue/no-v-html */" - ></div> + <div v-else v-safe-html:[$options.safeHtmlConfig]="note.note_html" class="note-text md"></div> <note-form v-if="isEditing" ref="noteForm" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index b05643e5e13..d6b65ed0e8b 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,9 +1,9 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlSprintf, GlLink } from '@gitlab/ui'; import { mapGetters, mapActions, mapState } from 'vuex'; import { getDraft, updateDraft } from '~/lib/utils/autosave'; import { mergeUrlParams } from '~/lib/utils/url_utility'; -import { __, sprintf } from '~/locale'; +import { __ } from '~/locale'; import markdownField from '~/vue_shared/components/markdown/field.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../event_hub'; @@ -17,6 +17,8 @@ export default { markdownField, CommentFieldLayout, GlButton, + GlSprintf, + GlLink, }, mixins: [glFeatureFlagsMixin(), issuableStateMixin, resolvable], props: { @@ -203,16 +205,12 @@ export default { ); }, changedCommentText() { - return sprintf( - __( + return { + text: __( 'This comment changed after you started editing it. Review the %{startTag}updated comment%{endTag} to ensure information is not lost.', ), - { - startTag: `<a href="${this.noteHash}" target="_blank" rel="noopener noreferrer">`, - endTag: '</a>', - }, - false, - ); + placeholder: { link: ['startTag', 'endTag'] }, + }; }, }, watch: { @@ -318,11 +316,13 @@ export default { <template> <div ref="editNoteForm" class="note-edit-form current-note-edit-form js-discussion-note-form"> - <div - v-if="conflictWhileEditing" - class="js-conflict-edit-warning alert alert-danger" - v-html="changedCommentText /* eslint-disable-line vue/no-v-html */" - ></div> + <div v-if="conflictWhileEditing" class="js-conflict-edit-warning alert alert-danger"> + <gl-sprintf :message="changedCommentText.text" :placeholders="changedCommentText.placeholder"> + <template #link="{ content }"> + <gl-link :href="noteHash" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> <div class="flash-container timeline-content"></div> <form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form"> <comment-field-layout @@ -334,13 +334,13 @@ export default { :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" :line="line" + :lines="lines" :note="discussionNote" :can-suggest="canSuggest" :add-spacing-classes="false" :help-page-path="helpPagePath" :show-suggest-popover="showSuggestPopover" :textarea-value="updatedNoteBody" - :lines="lines" @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" > <template #textarea> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 58570e76795..3ab3e7a20d4 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -8,6 +8,7 @@ import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import draftNote from '../../batch_comments/components/draft_note.vue'; +import { discussionIntersectionObserverHandlerFactory } from '../../diffs/utils/discussions'; import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; @@ -38,6 +39,9 @@ export default { TimelineEntryItem, }, mixins: [glFeatureFlagsMixin()], + provide: { + discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), + }, props: { noteableData: { type: Object, @@ -94,15 +98,17 @@ export default { return this.noteableData.noteableType; }, allDiscussions() { + let skeletonNotes = []; + if (this.renderSkeleton || this.isLoading) { const prerenderedNotesCount = parseInt(this.notesData.prerenderedNotesCount, 10) || 0; - return new Array(prerenderedNotesCount).fill({ + skeletonNotes = new Array(prerenderedNotesCount).fill({ isSkeletonNote: true, }); } - return this.discussions; + return this.discussions.concat(skeletonNotes); }, canReply() { return this.userCanReply && !this.commentsDisabled && !this.timelineEnabled; @@ -258,7 +264,13 @@ export default { getFetchDiscussionsConfig() { const defaultConfig = { path: this.getNotesDataByProp('discussionsPath') }; - if (doesHashExistInUrl(constants.NOTE_UNDERSCORE)) { + const currentFilter = + this.getNotesDataByProp('notesFilter') || constants.DISCUSSION_FILTERS_DEFAULT_VALUE; + + if ( + doesHashExistInUrl(constants.NOTE_UNDERSCORE) && + currentFilter !== constants.DISCUSSION_FILTERS_DEFAULT_VALUE + ) { return { ...defaultConfig, filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE, diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 96974c4fa2d..ad529eb99b6 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -1,7 +1,10 @@ import { mapGetters, mapActions, mapState } from 'vuex'; import { scrollToElementWithContext, scrollToElement } from '~/lib/utils/common_utils'; +import { updateHistory } from '../../lib/utils/url_utility'; import eventHub from '../event_hub'; +const isDiffsVirtualScrollingEnabled = () => window.gon?.features?.diffsVirtualScrolling; + /** * @param {string} selector * @returns {boolean} @@ -11,20 +14,52 @@ function scrollTo(selector, { withoutContext = false } = {}) { const scrollFunction = withoutContext ? scrollToElement : scrollToElementWithContext; if (el) { - scrollFunction(el); + scrollFunction(el, { + behavior: isDiffsVirtualScrollingEnabled() ? 'auto' : 'smooth', + }); return true; } return false; } +function updateUrlWithNoteId(noteId) { + const newHistoryEntry = { + state: null, + title: window.title, + url: `#note_${noteId}`, + replace: true, + }; + + if (noteId && isDiffsVirtualScrollingEnabled()) { + // Temporarily mask the ID to avoid the browser default + // scrolling taking over which is broken with virtual + // scrolling enabled. + const note = document.querySelector(`#note_${noteId}`); + note?.setAttribute('id', `masked::${note.id}`); + + // Update the hash now that the ID "doesn't exist" in the page + updateHistory(newHistoryEntry); + + // Unmask the note's ID + note?.setAttribute('id', `note_${noteId}`); + } else if (noteId) { + updateHistory(newHistoryEntry); + } +} + /** * @param {object} self Component instance with mixin applied * @param {string} id Discussion id we are jumping to */ -function diffsJump({ expandDiscussion }, id) { +function diffsJump({ expandDiscussion }, id, firstNoteId) { const selector = `ul.notes[data-discussion-id="${id}"]`; - eventHub.$once('scrollToDiscussion', () => scrollTo(selector)); + + eventHub.$once('scrollToDiscussion', () => { + scrollTo(selector); + // Wait for the discussion scroll before updating to the more specific ID + setTimeout(() => updateUrlWithNoteId(firstNoteId), 0); + }); expandDiscussion({ discussionId: id }); } @@ -56,12 +91,13 @@ function switchToDiscussionsTabAndJumpTo(self, id) { * @param {object} discussion Discussion we are jumping to */ function jumpToDiscussion(self, discussion) { - const { id, diff_discussion: isDiffDiscussion } = discussion; + const { id, diff_discussion: isDiffDiscussion, notes } = discussion; + const firstNoteId = notes?.[0]?.id; if (id) { const activeTab = window.mrTabs.currentAction; if (activeTab === 'diffs' && isDiffDiscussion) { - diffsJump(self, id); + diffsJump(self, id, firstNoteId); } else if (activeTab === 'show') { discussionJump(self, id); } else { @@ -79,10 +115,18 @@ function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId) const isDiffView = window.mrTabs.currentAction === 'diffs'; const targetId = fn(discussionId, isDiffView); const discussion = self.getDiscussion(targetId); + const setHash = !isDiffView && !isDiffsVirtualScrollingEnabled(); const discussionFilePath = discussion?.diff_file?.file_path; + if (isDiffsVirtualScrollingEnabled()) { + window.location.hash = ''; + } + if (discussionFilePath) { - self.scrollToFile(discussionFilePath); + self.scrollToFile({ + path: discussionFilePath, + setHash, + }); } self.$nextTick(() => { diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 7eb10f647a0..c862a29ad9c 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -1,4 +1,3 @@ -/* eslint-disable @gitlab/require-string-literal-i18n-helpers */ import $ from 'jquery'; import Visibility from 'visibilityjs'; import Vue from 'vue'; @@ -71,7 +70,7 @@ export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, dat export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data); export const setInitialNotes = ({ commit }, discussions) => - commit(types.SET_INITIAL_DISCUSSIONS, discussions); + commit(types.ADD_OR_UPDATE_DISCUSSIONS, discussions); export const setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data); @@ -90,14 +89,51 @@ export const fetchDiscussions = ({ commit, dispatch }, { path, filter, persistFi ? { params: { notes_filter: filter, persist_filter: persistFilter } } : null; + if (window.gon?.features?.paginatedIssueDiscussions) { + return dispatch('fetchDiscussionsBatch', { path, config, perPage: 20 }); + } + return axios.get(path, config).then(({ data }) => { - commit(types.SET_INITIAL_DISCUSSIONS, data); + commit(types.ADD_OR_UPDATE_DISCUSSIONS, data); commit(types.SET_FETCHING_DISCUSSIONS, false); dispatch('updateResolvableDiscussionsCounts'); }); }; +export const fetchDiscussionsBatch = ({ commit, dispatch }, { path, config, cursor, perPage }) => { + const params = { ...config?.params, per_page: perPage }; + + if (cursor) { + params.cursor = cursor; + } + + return axios.get(path, { params }).then(({ data, headers }) => { + commit(types.ADD_OR_UPDATE_DISCUSSIONS, data); + + if (headers['x-next-page-cursor']) { + const nextConfig = { ...config }; + + if (config?.params?.persist_filter) { + delete nextConfig.params.notes_filter; + delete nextConfig.params.persist_filter; + } + + return dispatch('fetchDiscussionsBatch', { + path, + config: nextConfig, + cursor: headers['x-next-page-cursor'], + perPage: Math.min(Math.round(perPage * 1.5), 100), + }); + } + + commit(types.SET_FETCHING_DISCUSSIONS, false); + dispatch('updateResolvableDiscussionsCounts'); + + return undefined; + }); +}; + export const updateDiscussion = ({ commit, state }, discussion) => { commit(types.UPDATE_DISCUSSION, discussion); @@ -621,7 +657,7 @@ export const submitSuggestion = ( const flashMessage = errorMessage || defaultMessage; createFlash({ - message: __(flashMessage), + message: flashMessage, parent: flashContainer, }); }) @@ -657,7 +693,7 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { message, fl const flashMessage = errorMessage || defaultMessage; createFlash({ - message: __(flashMessage), + message: flashMessage, parent: flashContainer, }); }) diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 2e8b728e013..fcd2846ff0d 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -1,11 +1,11 @@ export const ADD_NEW_NOTE = 'ADD_NEW_NOTE'; export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION'; +export const ADD_OR_UPDATE_DISCUSSIONS = 'ADD_OR_UPDATE_DISCUSSIONS'; export const DELETE_NOTE = 'DELETE_NOTE'; export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES'; export const SET_NOTES_DATA = 'SET_NOTES_DATA'; export const SET_NOTEABLE_DATA = 'SET_NOTEABLE_DATA'; export const SET_USER_DATA = 'SET_USER_DATA'; -export const SET_INITIAL_DISCUSSIONS = 'SET_INITIAL_DISCUSSIONS'; export const SET_LAST_FETCHED_AT = 'SET_LAST_FETCHED_AT'; export const SET_TARGET_NOTE_HASH = 'SET_TARGET_NOTE_HASH'; export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index c5fa34dfedd..1a99750ddb3 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -129,8 +129,8 @@ export default { Object.assign(state, { userData: data }); }, - [types.SET_INITIAL_DISCUSSIONS](state, discussionsData) { - const discussions = discussionsData.reduce((acc, d) => { + [types.ADD_OR_UPDATE_DISCUSSIONS](state, discussionsData) { + discussionsData.forEach((d) => { const discussion = { ...d }; const diffData = {}; @@ -145,27 +145,38 @@ export default { // To support legacy notes, should be very rare case. if (discussion.individual_note && discussion.notes.length > 1) { discussion.notes.forEach((n) => { - acc.push({ + const newDiscussion = { ...discussion, ...diffData, notes: [n], // override notes array to only have one item to mimick individual_note - }); + }; + const oldDiscussion = state.discussions.find( + (existingDiscussion) => + existingDiscussion.id === discussion.id && existingDiscussion.notes[0].id === n.id, + ); + + if (oldDiscussion) { + state.discussions.splice(state.discussions.indexOf(oldDiscussion), 1, newDiscussion); + } else { + state.discussions.push(newDiscussion); + } }); } else { - const oldNote = utils.findNoteObjectById(state.discussions, discussion.id); + const oldDiscussion = utils.findNoteObjectById(state.discussions, discussion.id); - acc.push({ - ...discussion, - ...diffData, - expanded: oldNote ? oldNote.expanded : discussion.expanded, - }); + if (oldDiscussion) { + state.discussions.splice(state.discussions.indexOf(oldDiscussion), 1, { + ...discussion, + ...diffData, + expanded: oldDiscussion.expanded, + }); + } else { + state.discussions.push({ ...discussion, ...diffData }); + } } - - return acc; - }, []); - - Object.assign(state, { discussions }); + }); }, + [types.SET_LAST_FETCHED_AT](state, fetchedAt) { Object.assign(state, { lastFetchedAt: fetchedAt }); }, diff --git a/app/assets/javascripts/packages/list/components/package_search.vue b/app/assets/javascripts/packages/list/components/package_search.vue deleted file mode 100644 index 869a2c2f641..00000000000 --- a/app/assets/javascripts/packages/list/components/package_search.vue +++ /dev/null @@ -1,57 +0,0 @@ -<script> -import { mapState, mapActions } from 'vuex'; -import { s__ } from '~/locale'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; -import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; -import UrlSync from '~/vue_shared/components/url_sync.vue'; -import { sortableFields } from '../utils'; -import PackageTypeToken from './tokens/package_type_token.vue'; - -export default { - tokens: [ - { - type: 'type', - icon: 'package', - title: s__('PackageRegistry|Type'), - unique: true, - token: PackageTypeToken, - operators: OPERATOR_IS_ONLY, - }, - ], - components: { RegistrySearch, UrlSync }, - computed: { - ...mapState({ - isGroupPage: (state) => state.config.isGroupPage, - sorting: (state) => state.sorting, - filter: (state) => state.filter, - }), - sortableFields() { - return sortableFields(this.isGroupPage); - }, - }, - methods: { - ...mapActions(['setSorting', 'setFilter']), - updateSorting(newValue) { - this.setSorting(newValue); - this.$emit('update'); - }, - }, -}; -</script> - -<template> - <url-sync> - <template #default="{ updateQuery }"> - <registry-search - :filter="filter" - :sorting="sorting" - :tokens="$options.tokens" - :sortable-fields="sortableFields" - @sorting:changed="updateSorting" - @filter:changed="setFilter" - @filter:submit="$emit('update')" - @query:changed="updateQuery" - /> - </template> - </url-sync> -</template> diff --git a/app/assets/javascripts/packages/list/components/package_title.vue b/app/assets/javascripts/packages/list/components/package_title.vue deleted file mode 100644 index 426ad150ea9..00000000000 --- a/app/assets/javascripts/packages/list/components/package_title.vue +++ /dev/null @@ -1,47 +0,0 @@ -<script> -import { n__ } from '~/locale'; -import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; -import TitleArea from '~/vue_shared/components/registry/title_area.vue'; -import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '../constants'; - -export default { - name: 'PackageTitle', - components: { - TitleArea, - MetadataItem, - }, - props: { - count: { - type: Number, - required: false, - default: null, - }, - helpUrl: { - type: String, - required: true, - }, - }, - computed: { - showPackageCount() { - return Number.isInteger(this.count); - }, - packageAmountText() { - return n__(`%d Package`, `%d Packages`, this.count); - }, - infoMessages() { - return [{ text: LIST_INTRO_TEXT, link: this.helpUrl }]; - }, - }, - i18n: { - LIST_TITLE_TEXT, - }, -}; -</script> - -<template> - <title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages"> - <template #metadata-amount> - <metadata-item v-if="showPackageCount" icon="package" :text="packageAmountText" /> - </template> - </title-area> -</template> diff --git a/app/assets/javascripts/packages/list/components/packages_list_app.vue b/app/assets/javascripts/packages/list/components/packages_list_app.vue index 4c5fb0ee7c9..31d90fa4dee 100644 --- a/app/assets/javascripts/packages/list/components/packages_list_app.vue +++ b/app/assets/javascripts/packages/list/components/packages_list_app.vue @@ -7,6 +7,8 @@ import { s__ } from '~/locale'; import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; +import InfrastructureTitle from '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue'; +import InfrastructureSearch from '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue'; import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants'; import PackageList from './packages_list.vue'; @@ -16,28 +18,10 @@ export default { GlLink, GlSprintf, PackageList, - PackageTitle: () => - import(/* webpackChunkName: 'package_registry_components' */ './package_title.vue'), - PackageSearch: () => - import(/* webpackChunkName: 'package_registry_components' */ './package_search.vue'), - InfrastructureTitle: () => - import( - /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue' - ), - InfrastructureSearch: () => - import( - /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue' - ), + InfrastructureTitle, + InfrastructureSearch, }, inject: { - titleComponent: { - from: 'titleComponent', - default: 'PackageTitle', - }, - searchComponent: { - from: 'searchComponent', - default: 'PackageSearch', - }, emptyPageTitle: { from: 'emptyPageTitle', default: s__('PackageRegistry|There are no packages yet'), @@ -111,8 +95,8 @@ export default { <template> <div> - <component :is="titleComponent" :help-url="packageHelpUrl" :count="packagesCount" /> - <component :is="searchComponent" @update="requestPackagesList" /> + <infrastructure-title :help-url="packageHelpUrl" :count="packagesCount" /> + <infrastructure-search @update="requestPackagesList" /> <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest"> <template #empty-state> diff --git a/app/assets/javascripts/packages/list/components/tokens/package_type_token.vue b/app/assets/javascripts/packages/list/components/tokens/package_type_token.vue deleted file mode 100644 index 74b6774712e..00000000000 --- a/app/assets/javascripts/packages/list/components/tokens/package_type_token.vue +++ /dev/null @@ -1,26 +0,0 @@ -<script> -import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; -import { PACKAGE_TYPES } from '../../constants'; - -export default { - components: { - GlFilteredSearchToken, - GlFilteredSearchSuggestion, - }, - PACKAGE_TYPES, -}; -</script> - -<template> - <gl-filtered-search-token v-bind="{ ...$attrs }" v-on="$listeners"> - <template #suggestions> - <gl-filtered-search-suggestion - v-for="(type, index) in $options.PACKAGE_TYPES" - :key="index" - :value="type.type" - > - {{ type.title }} - </gl-filtered-search-suggestion> - </template> - </gl-filtered-search-token> -</template> diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js index 2c6fd94024e..4f5071e784b 100644 --- a/app/assets/javascripts/packages/list/constants.js +++ b/app/assets/javascripts/packages/list/constants.js @@ -96,10 +96,4 @@ export const PACKAGE_TYPES = [ }, ]; -export const LIST_TITLE_TEXT = s__('PackageRegistry|Package Registry'); - -export const LIST_INTRO_TEXT = s__( - 'PackageRegistry|Publish and share packages for a variety of common package managers. %{docLinkStart}More information%{docLinkEnd}', -); - export const TERRAFORM_SEARCH_TYPE = Object.freeze({ value: { data: 'terraform_module' } }); diff --git a/app/assets/javascripts/registry/explorer/components/delete_button.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue index e4a1a1a8266..e4a1a1a8266 100644 --- a/app/assets/javascripts/registry/explorer/components/delete_button.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue diff --git a/app/assets/javascripts/registry/explorer/components/delete_image.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_image.vue index a313854f5e4..a313854f5e4 100644 --- a/app/assets/javascripts/registry/explorer/components/delete_image.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_image.vue diff --git a/app/assets/javascripts/registry/explorer/components/details_page/delete_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue index 56d2ff86fb7..56d2ff86fb7 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/delete_alert.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue diff --git a/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue index f857c96c9d1..f857c96c9d1 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue index e9e36151fe6..e9e36151fe6 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue diff --git a/app/assets/javascripts/registry/explorer/components/details_page/empty_state.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue index a16d95a6b30..a16d95a6b30 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/empty_state.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue diff --git a/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue index 12095655126..12095655126 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue diff --git a/app/assets/javascripts/registry/explorer/components/details_page/status_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue index fc1504f6c31..fc1504f6c31 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/status_alert.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue index 3e19a646f53..3e19a646f53 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue index 0556fd298aa..0556fd298aa 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue index b7afa5fba33..b7afa5fba33 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue diff --git a/app/assets/javascripts/registry/explorer/components/list_page/cleanup_status.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue index 1f52e319ad0..1f52e319ad0 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/cleanup_status.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue diff --git a/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue index 07ee3c6083b..07ee3c6083b 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue diff --git a/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue index a68c4de5aa6..a68c4de5aa6 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue index 5bd13322ebb..5bd13322ebb 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue index c1ec523574a..c1ec523574a 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue diff --git a/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue index 5aa04419ca0..5aa04419ca0 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue diff --git a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue index 6d2ff9ea7b6..6d2ff9ea7b6 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue diff --git a/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue index e77eda31596..e77eda31596 100644 --- a/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue diff --git a/app/assets/javascripts/registry/explorer/constants/common.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js index f7beec2c935..f7beec2c935 100644 --- a/app/assets/javascripts/registry/explorer/constants/common.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js index 19e1a75fb2f..19e1a75fb2f 100644 --- a/app/assets/javascripts/registry/explorer/constants/details.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js diff --git a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js index 40f9b09a982..40f9b09a982 100644 --- a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js diff --git a/app/assets/javascripts/registry/explorer/constants/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/index.js index 6886356d8e2..6886356d8e2 100644 --- a/app/assets/javascripts/registry/explorer/constants/index.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/index.js diff --git a/app/assets/javascripts/registry/explorer/constants/list.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js index d21a154d1b8..d21a154d1b8 100644 --- a/app/assets/javascripts/registry/explorer/constants/list.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js diff --git a/app/assets/javascripts/registry/explorer/constants/quick_start.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/quick_start.js index 6a39c07eba2..6a39c07eba2 100644 --- a/app/assets/javascripts/registry/explorer/constants/quick_start.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/quick_start.js diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js new file mode 100644 index 00000000000..9694bfd4e77 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +export const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + batchMax: 1, + }, + ), +}); diff --git a/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql index 4c88b726ee5..4c88b726ee5 100644 --- a/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql diff --git a/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql index a31f2829e13..a31f2829e13 100644 --- a/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repositories_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql index 01cb7fa1cab..01cb7fa1cab 100644 --- a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repositories_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql index b5a99fd9ac1..b5a99fd9ac1 100644 --- a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql index a703c2dd0ac..a703c2dd0ac 100644 --- a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql index 9092a71edb0..9092a71edb0 100644 --- a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql diff --git a/app/assets/javascripts/registry/explorer/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js index 246a6768593..246a6768593 100644 --- a/app/assets/javascripts/registry/explorer/index.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue index feabc4f770b..feabc4f770b 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue diff --git a/app/assets/javascripts/registry/explorer/pages/index.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue index dca63e1a569..dca63e1a569 100644 --- a/app/assets/javascripts/registry/explorer/pages/index.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue index 73b957f42f2..73b957f42f2 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue diff --git a/app/assets/javascripts/registry/explorer/router.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/router.js index a0c4417d549..a0c4417d549 100644 --- a/app/assets/javascripts/registry/explorer/router.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/router.js diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue index 73fb3656af1..71e8cf4f634 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue @@ -1,32 +1,49 @@ <script> -import { GlAlert, GlFormGroup, GlFormInputGroup, GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { + GlAlert, + GlFormGroup, + GlFormInputGroup, + GlSkeletonLoader, + GlSprintf, + GlEmptyState, +} from '@gitlab/ui'; +import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import ManifestsList from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue'; import { DEPENDENCY_PROXY_SETTINGS_DESCRIPTION, DEPENDENCY_PROXY_DOCS_PATH, } from '~/packages_and_registries/settings/group/constants'; +import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/dependency_proxy/constants'; import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql'; export default { components: { - GlFormGroup, GlAlert, + GlEmptyState, + GlFormGroup, GlFormInputGroup, + GlSkeletonLoader, GlSprintf, ClipboardButton, TitleArea, - GlSkeletonLoader, + ManifestsList, }, - inject: ['groupPath', 'dependencyProxyAvailable'], + inject: ['groupPath', 'dependencyProxyAvailable', 'noManifestsIllustration'], i18n: { - proxyNotAvailableText: __('Dependency Proxy feature is limited to public groups for now.'), - proxyDisabledText: __('Dependency Proxy disabled. To enable it, contact the group owner.'), - proxyImagePrefix: __('Dependency Proxy image prefix'), - copyImagePrefixText: __('Copy prefix'), - blobCountAndSize: __('Contains %{count} blobs of images (%{size})'), + proxyNotAvailableText: s__( + 'DependencyProxy|Dependency Proxy feature is limited to public groups for now.', + ), + proxyDisabledText: s__( + 'DependencyProxy|Dependency Proxy disabled. To enable it, contact the group owner.', + ), + proxyImagePrefix: s__('DependencyProxy|Dependency Proxy image prefix'), + copyImagePrefixText: s__('DependencyProxy|Copy prefix'), + blobCountAndSize: s__('DependencyProxy|Contains %{count} blobs of images (%{size})'), + pageTitle: s__('DependencyProxy|Dependency Proxy'), + noManifestTitle: s__('DependencyProxy|There are no images in the cache'), }, data() { return { @@ -40,7 +57,7 @@ export default { return !this.dependencyProxyAvailable; }, variables() { - return { fullPath: this.groupPath }; + return this.queryVariables; }, }, }, @@ -56,13 +73,45 @@ export default { dependencyProxyEnabled() { return this.group?.dependencyProxySetting?.enabled; }, + queryVariables() { + return { fullPath: this.groupPath, first: GRAPHQL_PAGE_SIZE }; + }, + pageInfo() { + return this.group.dependencyProxyManifests.pageInfo; + }, + manifests() { + return this.group.dependencyProxyManifests.nodes; + }, + }, + methods: { + fetchNextPage() { + this.fetchMore({ + first: GRAPHQL_PAGE_SIZE, + after: this.pageInfo?.endCursor, + }); + }, + fetchPreviousPage() { + this.fetchMore({ + first: null, + last: GRAPHQL_PAGE_SIZE, + before: this.pageInfo?.startCursor, + }); + }, + fetchMore(variables) { + this.$apollo.queries.group.fetchMore({ + variables: { ...this.queryVariables, ...variables }, + updateQuery(_, { fetchMoreResult }) { + return fetchMoreResult; + }, + }); + }, }, }; </script> <template> <div> - <title-area :title="__('Dependency Proxy')" :info-messages="infoMessages" /> + <title-area :title="$options.i18n.pageTitle" :info-messages="infoMessages" /> <gl-alert v-if="!dependencyProxyAvailable" :dismissible="false" @@ -97,6 +146,20 @@ export default { </span> </template> </gl-form-group> + + <manifests-list + v-if="manifests && manifests.length" + :manifests="manifests" + :pagination="pageInfo" + @prev-page="fetchPreviousPage" + @next-page="fetchNextPage" + /> + + <gl-empty-state + v-else + :svg-path="noManifestsIllustration" + :title="$options.i18n.noManifestTitle" + /> </div> <gl-alert v-else :dismissible="false" data-testid="proxy-disabled"> {{ $options.i18n.proxyDisabledText }} diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue new file mode 100644 index 00000000000..78880b6e3f4 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue @@ -0,0 +1,49 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { s__ } from '~/locale'; + +export default { + name: 'ManifestRow', + components: { + GlSprintf, + ListItem, + TimeagoTooltip, + }, + props: { + manifest: { + type: Object, + required: true, + }, + }, + computed: { + name() { + return this.manifest?.imageName.split(':')[0]; + }, + version() { + return this.manifest?.imageName.split(':')[1]; + }, + }, + i18n: { + cachedAgoMessage: s__('DependencyProxy|Cached %{time}'), + }, +}; +</script> + +<template> + <list-item> + <template #left-primary> {{ name }} </template> + <template #left-secondary> {{ version }} </template> + <template #right-primary> </template> + <template #right-secondary> + <timeago-tooltip :time="manifest.createdAt" data-testid="cached-message"> + <template #default="{ timeAgo }"> + <gl-sprintf :message="$options.i18n.cachedAgoMessage"> + <template #time>{{ timeAgo }}</template> + </gl-sprintf> + </template> + </timeago-tooltip> + </template> + </list-item> +</template> diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue new file mode 100644 index 00000000000..005c8feea3a --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue @@ -0,0 +1,52 @@ +<script> +import { GlKeysetPagination } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import ManifestRow from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue'; + +export default { + name: 'ManifestsLists', + components: { + ManifestRow, + GlKeysetPagination, + }, + props: { + manifests: { + type: Array, + required: false, + default: () => [], + }, + pagination: { + type: Object, + required: true, + }, + }, + i18n: { + listTitle: s__('DependencyProxy|Image list'), + }, + computed: { + showPagination() { + return this.pagination.hasNextPage || this.pagination.hasPreviousPage; + }, + }, +}; +</script> + +<template> + <div class="gl-mt-6"> + <h3 class="gl-font-base">{{ $options.i18n.listTitle }}</h3> + <div + class="gl-border-t-1 gl-border-gray-100 gl-border-t-solid gl-display-flex gl-flex-direction-column" + > + <manifest-row v-for="(manifest, index) in manifests" :key="index" :manifest="manifest" /> + </div> + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-if="showPagination" + v-bind="pagination" + class="gl-mt-3" + @prev="$emit('prev-page')" + @next="$emit('next-page')" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js new file mode 100644 index 00000000000..3c6ede6fdce --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js @@ -0,0 +1 @@ +export const GRAPHQL_PAGE_SIZE = 20; diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js index 16152eb81f6..56f95fa2c1f 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js @@ -5,10 +5,5 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); export const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - assumeImmutableResults: true, - }, - ), + defaultClient: createDefaultClient(), }); diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql index 9058d349bf3..63d5469c955 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql @@ -1,4 +1,12 @@ -query getDependencyProxyDetails($fullPath: ID!) { +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getDependencyProxyDetails( + $fullPath: ID! + $first: Int + $last: Int + $after: String + $before: String +) { group(fullPath: $fullPath) { dependencyProxyBlobCount dependencyProxyTotalSize @@ -6,5 +14,14 @@ query getDependencyProxyDetails($fullPath: ID!) { dependencyProxySetting { enabled } + dependencyProxyManifests(after: $after, before: $before, first: $first, last: $last) { + nodes { + createdAt + imageName + } + pageInfo { + ...PageInfo + } + } } } diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue index 3d3fa62fd43..bcbeec72961 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue @@ -23,6 +23,7 @@ import PackageFiles from '~/packages_and_registries/package_registry/components/ import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue'; import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; +import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; import { PACKAGE_TYPE_NUGET, PACKAGE_TYPE_COMPOSER, @@ -35,12 +36,10 @@ import { CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, SHOW_DELETE_SUCCESS_ALERT, FETCH_PACKAGE_DETAILS_ERROR_MESSAGE, - DELETE_PACKAGE_ERROR_MESSAGE, DELETE_PACKAGE_FILE_ERROR_MESSAGE, DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, } from '~/packages_and_registries/package_registry/constants'; -import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql'; import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql'; import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql'; import Tracking from '~/tracking'; @@ -62,6 +61,7 @@ export default { AdditionalMetadata, InstallationCommands, PackageFiles, + DeletePackage, }, directives: { GlTooltip: GlTooltipDirective, @@ -148,40 +148,15 @@ export default { formatSize(size) { return numberToHumanSize(size); }, - async deletePackage() { - const { data } = await this.$apollo.mutate({ - mutation: destroyPackageMutation, - variables: { - id: this.packageEntity.id, - }, - }); + navigateToListWithSuccessModal() { + const returnTo = + !this.groupListUrl || document.referrer.includes(this.projectName) + ? this.projectListUrl + : this.groupListUrl; // to avoid security issue url are supplied from backend - if (data?.destroyPackage?.errors[0]) { - throw data.destroyPackage.errors[0]; - } - }, - async confirmPackageDeletion() { - this.track(DELETE_PACKAGE_TRACKING_ACTION); - - try { - await this.deletePackage(); - - const returnTo = - !this.groupListUrl || document.referrer.includes(this.projectName) - ? this.projectListUrl - : this.groupListUrl; // to avoid security issue url are supplied from backend - - const modalQuery = objectToQuery({ [SHOW_DELETE_SUCCESS_ALERT]: true }); + const modalQuery = objectToQuery({ [SHOW_DELETE_SUCCESS_ALERT]: true }); - window.location.replace(`${returnTo}?${modalQuery}`); - } catch (error) { - createFlash({ - message: DELETE_PACKAGE_ERROR_MESSAGE, - type: 'warning', - captureError: true, - error, - }); - } + window.location.replace(`${returnTo}?${modalQuery}`); }, async deletePackageFile(id) { try { @@ -322,26 +297,33 @@ export default { </gl-tab> </gl-tabs> - <gl-modal - ref="deleteModal" - modal-id="delete-modal" - data-testid="delete-modal" - :action-primary="$options.modal.packageDeletePrimaryAction" - :action-cancel="$options.modal.cancelAction" - @primary="confirmPackageDeletion" - @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)" + <delete-package + @start="track($options.trackingActions.DELETE_PACKAGE_TRACKING_ACTION)" + @end="navigateToListWithSuccessModal" > - <template #modal-title>{{ $options.i18n.deleteModalTitle }}</template> - <gl-sprintf :message="$options.i18n.deleteModalContent"> - <template #version> - <strong>{{ packageEntity.version }}</strong> - </template> + <template #default="{ deletePackage }"> + <gl-modal + ref="deleteModal" + modal-id="delete-modal" + data-testid="delete-modal" + :action-primary="$options.modal.packageDeletePrimaryAction" + :action-cancel="$options.modal.cancelAction" + @primary="deletePackage(packageEntity)" + @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)" + > + <template #modal-title>{{ $options.i18n.deleteModalTitle }}</template> + <gl-sprintf :message="$options.i18n.deleteModalContent"> + <template #version> + <strong>{{ packageEntity.version }}</strong> + </template> - <template #name> - <strong>{{ packageEntity.name }}</strong> - </template> - </gl-sprintf> - </gl-modal> + <template #name> + <strong>{{ packageEntity.name }}</strong> + </template> + </gl-sprintf> + </gl-modal> + </template> + </delete-package> <gl-modal ref="deleteFileModal" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue new file mode 100644 index 00000000000..7a85fd3052e --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue @@ -0,0 +1,62 @@ +<script> +import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; + +import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/package_registry/constants'; + +export default { + props: { + refetchQueries: { + type: Array, + required: false, + default: null, + }, + showSuccessAlert: { + type: Boolean, + required: false, + default: false, + }, + }, + i18n: { + errorMessage: s__('PackageRegistry|Something went wrong while deleting the package.'), + successMessage: DELETE_PACKAGE_SUCCESS_MESSAGE, + }, + methods: { + async deletePackage(packageEntity) { + try { + this.$emit('start'); + const { data } = await this.$apollo.mutate({ + mutation: destroyPackageMutation, + variables: { + id: packageEntity.id, + }, + awaitRefetchQueries: Boolean(this.refetchQueries), + refetchQueries: this.refetchQueries, + }); + + if (data?.destroyPackage?.errors[0]) { + throw data.destroyPackage.errors[0]; + } + if (this.showSuccessAlert) { + createFlash({ + message: this.$options.i18n.successMessage, + type: 'success', + }); + } + } catch (error) { + createFlash({ + message: this.$options.i18n.errorMessage, + type: 'warning', + captureError: true, + error, + }); + } + this.$emit('end'); + }, + }, + render() { + return this.$scopedSlots.default({ deletePackage: this.deletePackage }); + }, +}; +</script> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue index 08481ac5655..11eeaf933ff 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue @@ -1,33 +1,31 @@ <script> -/* - * The following component has several commented lines, this is because we are refactoring them piece by piece on several mrs - * For a complete overview of the plan please check: https://gitlab.com/gitlab-org/gitlab/-/issues/330846 - * This work is behind feature flag: https://gitlab.com/gitlab-org/gitlab/-/issues/341136 - */ -// import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import createFlash from '~/flash'; import { historyReplaceState } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; -import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants'; import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; -import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; import { PROJECT_RESOURCE_TYPE, GROUP_RESOURCE_TYPE, - LIST_QUERY_DEBOUNCE_TIME, + GRAPHQL_PAGE_SIZE, + DELETE_PACKAGE_SUCCESS_MESSAGE, } from '~/packages_and_registries/package_registry/constants'; +import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; + +import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; import PackageTitle from './package_title.vue'; import PackageSearch from './package_search.vue'; -// import PackageList from './packages_list.vue'; +import PackageList from './packages_list.vue'; export default { components: { - // GlEmptyState, - // GlLink, - // GlSprintf, - // PackageList, + GlEmptyState, + GlLink, + GlSprintf, + PackageList, PackageTitle, PackageSearch, + DeletePackage, }, inject: [ 'packageHelpUrl', @@ -41,6 +39,7 @@ export default { packages: {}, sort: '', filters: {}, + mutationLoading: false, }; }, apollo: { @@ -52,7 +51,9 @@ export default { update(data) { return data[this.graphqlResource].packages; }, - debounce: LIST_QUERY_DEBOUNCE_TIME, + skip() { + return !this.sort; + }, }, }, computed: { @@ -64,22 +65,40 @@ export default { groupSort: this.isGroupPage ? this.sort : undefined, packageName: this.filters?.packageName, packageType: this.filters?.packageType, + first: GRAPHQL_PAGE_SIZE, }; }, graphqlResource() { return this.isGroupPage ? GROUP_RESOURCE_TYPE : PROJECT_RESOURCE_TYPE; }, + pageInfo() { + return this.packages?.pageInfo ?? {}; + }, packagesCount() { return this.packages?.count; }, hasFilters() { return this.filters.packageName && this.filters.packageType; }, + emptySearch() { + return !this.filters.packageName && !this.filters.packageType; + }, emptyStateTitle() { return this.emptySearch ? this.$options.i18n.emptyPageTitle : this.$options.i18n.noResultsTitle; }, + isLoading() { + return this.$apollo.queries.packages.loading || this.mutationLoading; + }, + refetchQueriesData() { + return [ + { + query: getPackagesQuery, + variables: this.queryVariables, + }, + ]; + }, }, mounted() { this.checkDeleteAlert(); @@ -99,6 +118,35 @@ export default { this.sort = sort; this.filters = { ...filters }; }, + updateQuery(_, { fetchMoreResult }) { + return fetchMoreResult; + }, + fetchNextPage() { + const variables = { + ...this.queryVariables, + first: GRAPHQL_PAGE_SIZE, + last: null, + after: this.pageInfo?.endCursor, + }; + + this.$apollo.queries.packages.fetchMore({ + variables, + updateQuery: this.updateQuery, + }); + }, + fetchPreviousPage() { + const variables = { + ...this.queryVariables, + first: null, + last: GRAPHQL_PAGE_SIZE, + before: this.pageInfo?.startCursor, + }; + + this.$apollo.queries.packages.fetchMore({ + variables, + updateQuery: this.updateQuery, + }); + }, }, i18n: { widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'), @@ -116,19 +164,35 @@ export default { <package-title :help-url="packageHelpUrl" :count="packagesCount" /> <package-search @update="handleSearchUpdate" /> - <!-- <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest"> - <template #empty-state> - <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration"> - <template #description> - <gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" /> - <gl-sprintf v-else :message="$options.i18n.noResultsText"> - <template #noPackagesLink="{ content }"> - <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link> + <delete-package + :refetch-queries="refetchQueriesData" + show-success-alert + @start="mutationLoading = true" + @end="mutationLoading = false" + > + <template #default="{ deletePackage }"> + <package-list + :list="packages.nodes" + :is-loading="isLoading" + :page-info="pageInfo" + @prev-page="fetchPreviousPage" + @next-page="fetchNextPage" + @package:delete="deletePackage" + > + <template #empty-state> + <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration"> + <template #description> + <gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" /> + <gl-sprintf v-else :message="$options.i18n.noResultsText"> + <template #noPackagesLink="{ content }"> + <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> </template> - </gl-sprintf> + </gl-empty-state> </template> - </gl-empty-state> + </package-list> </template> - </package-list> --> + </delete-package> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue index 836df59ca58..3483d23e251 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue @@ -1,6 +1,6 @@ <script> import { s__ } from '~/locale'; -import { sortableFields } from '~/packages/list/utils'; +import { sortableFields } from '~/packages_and_registries/package_registry/utils'; import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue index 6e00a48586e..bf41c36e09b 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue @@ -1,6 +1,5 @@ <script> -import { n__ } from '~/locale'; -import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '~/packages/list/constants'; +import { n__, s__ } from '~/locale'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; @@ -29,11 +28,14 @@ export default { return n__(`%d Package`, `%d Packages`, this.count); }, infoMessages() { - return [{ text: LIST_INTRO_TEXT, link: this.helpUrl }]; + return [{ text: this.$options.i18n.LIST_INTRO_TEXT, link: this.helpUrl }]; }, }, i18n: { - LIST_TITLE_TEXT, + LIST_TITLE_TEXT: s__('PackageRegistry|Package Registry'), + LIST_INTRO_TEXT: s__( + 'PackageRegistry|Publish and share packages for a variety of common package managers. %{docLinkStart}More information%{docLinkEnd}', + ), }, }; </script> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue index 25bac687dbf..2a946544c2f 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue @@ -1,82 +1,94 @@ <script> -import { GlPagination, GlModal, GlSprintf } from '@gitlab/ui'; -import { mapState, mapGetters } from 'vuex'; +import { GlModal, GlSprintf, GlKeysetPagination } from '@gitlab/ui'; import { s__ } from '~/locale'; -import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; +import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; -import { TrackingActions } from '~/packages/shared/constants'; -import { packageTypeToTrackCategory } from '~/packages/shared/utils'; +import { + DELETE_PACKAGE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE_TRACKING_ACTION, +} from '~/packages_and_registries/package_registry/constants'; +import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils'; import Tracking from '~/tracking'; export default { components: { - GlPagination, + GlKeysetPagination, GlModal, GlSprintf, PackagesListLoader, PackagesListRow, }, mixins: [Tracking.mixin()], + props: { + list: { + type: Array, + required: false, + default: () => [], + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + pageInfo: { + type: Object, + required: true, + }, + }, + data() { return { itemToBeDeleted: null, }; }, computed: { - ...mapState({ - perPage: (state) => state.pagination.perPage, - totalItems: (state) => state.pagination.total, - page: (state) => state.pagination.page, - isGroupPage: (state) => state.config.isGroupPage, - isLoading: 'isLoading', - }), - ...mapGetters({ list: 'getList' }), - currentPage: { - get() { - return this.page; - }, - set(value) { - this.$emit('page:changed', value); - }, - }, isListEmpty() { return !this.list || this.list.length === 0; }, - modalAction() { - return s__('PackageRegistry|Delete package'); - }, deletePackageName() { return this.itemToBeDeleted?.name ?? ''; }, tracking() { const category = this.itemToBeDeleted - ? packageTypeToTrackCategory(this.itemToBeDeleted.package_type) + ? packageTypeToTrackCategory(this.itemToBeDeleted.packageType) : undefined; return { category, }; }, + showPagination() { + return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage; + }, + showDeleteModal: { + get() { + return Boolean(this.itemToBeDeleted); + }, + set(value) { + if (!value) { + this.itemToBeDeleted = null; + } + }, + }, }, methods: { setItemToBeDeleted(item) { this.itemToBeDeleted = { ...item }; - this.track(TrackingActions.REQUEST_DELETE_PACKAGE); - this.$refs.packageListDeleteModal.show(); + this.track(REQUEST_DELETE_PACKAGE_TRACKING_ACTION); }, deleteItemConfirmation() { this.$emit('package:delete', this.itemToBeDeleted); - this.track(TrackingActions.DELETE_PACKAGE); - this.itemToBeDeleted = null; + this.track(DELETE_PACKAGE_TRACKING_ACTION); }, deleteItemCanceled() { - this.track(TrackingActions.CANCEL_DELETE_PACKAGE); - this.itemToBeDeleted = null; + this.track(CANCEL_DELETE_PACKAGE_TRACKING_ACTION); }, }, i18n: { deleteModalContent: s__( 'PackageRegistry|You are about to delete %{name}, this operation is irreversible, are you sure?', ), + modalAction: s__('PackageRegistry|Delete package'), }, }; </script> @@ -95,29 +107,29 @@ export default { v-for="packageEntity in list" :key="packageEntity.id" :package-entity="packageEntity" - :package-link="packageEntity._links.web_path" - :is-group="isGroupPage" @packageToDelete="setItemToBeDeleted" /> </div> - <gl-pagination - v-model="currentPage" - :per-page="perPage" - :total-items="totalItems" - align="center" - class="gl-w-full gl-mt-3" - /> + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-if="showPagination" + v-bind="pageInfo" + class="gl-mt-3" + @prev="$emit('prev-page')" + @next="$emit('next-page')" + /> + </div> <gl-modal - ref="packageListDeleteModal" + v-model="showDeleteModal" modal-id="confirm-delete-pacakge" ok-variant="danger" @ok="deleteItemConfirmation" @cancel="deleteItemCanceled" > - <template #modal-title>{{ modalAction }}</template> - <template #modal-ok>{{ modalAction }}</template> + <template #modal-title>{{ $options.i18n.modalAction }}</template> + <template #modal-ok>{{ $options.i18n.modalAction }}</template> <gl-sprintf :message="$options.i18n.deleteModalContent"> <template #name> <strong>{{ deletePackageName }}</strong> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue index 529a7893dfc..59354e77ee9 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue @@ -1,6 +1,6 @@ <script> import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; -import { PACKAGE_TYPES } from '~/packages/list/constants'; +import { PACKAGE_TYPES } from '~/packages_and_registries/package_registry/constants'; export default { components: { @@ -17,9 +17,9 @@ export default { <gl-filtered-search-suggestion v-for="(type, index) in $options.PACKAGE_TYPES" :key="index" - :value="type.type" + :value="type" > - {{ type.title }} + {{ type }} </gl-filtered-search-suggestion> </template> </gl-filtered-search-token> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js index 6a88880fa90..9fd8880861c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; export const PACKAGE_TYPE_CONAN = 'CONAN'; export const PACKAGE_TYPE_MAVEN = 'MAVEN'; @@ -59,16 +59,7 @@ export const TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND = export const TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND = 'copy_composer_package_include_command'; -export const TrackingCategories = { - [PACKAGE_TYPE_MAVEN]: 'MavenPackages', - [PACKAGE_TYPE_NPM]: 'NpmPackages', - [PACKAGE_TYPE_CONAN]: 'ConanPackages', -}; - export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert'; -export const DELETE_PACKAGE_ERROR_MESSAGE = s__( - 'PackageRegistry|Something went wrong while deleting the package.', -); export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while deleting the package file.', ); @@ -79,6 +70,8 @@ export const FETCH_PACKAGE_DETAILS_ERROR_MESSAGE = s__( 'PackageRegistry|Failed to load the package data', ); +export const DELETE_PACKAGE_SUCCESS_MESSAGE = s__('PackageRegistry|Package deleted successfully'); + export const PACKAGE_ERROR_STATUS = 'ERROR'; export const PACKAGE_DEFAULT_STATUS = 'DEFAULT'; export const PACKAGE_HIDDEN_STATUS = 'HIDDEN'; @@ -92,4 +85,52 @@ export const INSTANCE_PACKAGE_ENDPOINT_TYPE = 'instance'; export const PROJECT_RESOURCE_TYPE = 'project'; export const GROUP_RESOURCE_TYPE = 'group'; -export const LIST_QUERY_DEBOUNCE_TIME = 50; +export const GRAPHQL_PAGE_SIZE = 20; + +export const LIST_KEY_NAME = 'name'; +export const LIST_KEY_PROJECT = 'project_path'; +export const LIST_KEY_VERSION = 'version'; +export const LIST_KEY_PACKAGE_TYPE = 'type'; +export const LIST_KEY_CREATED_AT = 'created_at'; + +export const LIST_LABEL_NAME = __('Name'); +export const LIST_LABEL_PROJECT = __('Project'); +export const LIST_LABEL_VERSION = __('Version'); +export const LIST_LABEL_PACKAGE_TYPE = __('Type'); +export const LIST_LABEL_CREATED_AT = __('Published'); + +export const SORT_FIELDS = [ + { + orderBy: LIST_KEY_NAME, + label: LIST_LABEL_NAME, + }, + { + orderBy: LIST_KEY_PROJECT, + label: LIST_LABEL_PROJECT, + }, + { + orderBy: LIST_KEY_VERSION, + label: LIST_LABEL_VERSION, + }, + { + orderBy: LIST_KEY_PACKAGE_TYPE, + label: LIST_LABEL_PACKAGE_TYPE, + }, + { + orderBy: LIST_KEY_CREATED_AT, + label: LIST_LABEL_CREATED_AT, + }, +]; + +export const PACKAGE_TYPES = [ + s__('PackageRegistry|Composer'), + s__('PackageRegistry|Conan'), + s__('PackageRegistry|Generic'), + s__('PackageRegistry|Maven'), + s__('PackageRegistry|npm'), + s__('PackageRegistry|NuGet'), + s__('PackageRegistry|PyPI'), + s__('PackageRegistry|RubyGems'), + s__('PackageRegistry|Debian'), + s__('PackageRegistry|Helm'), +]; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js index f8cb5c516e2..21d6fbc9e1f 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js @@ -17,7 +17,6 @@ export const apolloProvider = new VueApollo({ cacheConfig: { fragmentMatcher, }, - assumeImmutableResults: true, }, ), }); diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql index 74e6de87866..e3115365f8b 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql @@ -1,4 +1,5 @@ #import "~/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql" +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" query getPackages( $fullPath: ID! @@ -7,21 +8,47 @@ query getPackages( $groupSort: PackageGroupSort $packageName: String $packageType: PackageTypeEnum + $first: Int + $last: Int + $after: String + $before: String ) { project(fullPath: $fullPath) @skip(if: $isGroupPage) { - packages(sort: $sort, packageName: $packageName, packageType: $packageType) { + packages( + sort: $sort + packageName: $packageName + packageType: $packageType + after: $after + before: $before + first: $first + last: $last + ) { count nodes { ...PackageData } + pageInfo { + ...PageInfo + } } } group(fullPath: $fullPath) @include(if: $isGroupPage) { - packages(sort: $groupSort, packageName: $packageName, packageType: $packageType) { + packages( + sort: $groupSort + packageName: $packageName + packageType: $packageType + after: $after + before: $before + first: $first + last: $last + ) { count nodes { ...PackageData } + pageInfo { + ...PageInfo + } } } } diff --git a/app/assets/javascripts/packages_and_registries/package_registry/utils.js b/app/assets/javascripts/packages_and_registries/package_registry/utils.js index ae886952c3e..4ff8edb8f66 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/utils.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/utils.js @@ -1,3 +1,4 @@ +import { capitalize } from 'lodash'; import { s__ } from '~/locale'; import { PACKAGE_TYPE_CONAN, @@ -10,6 +11,8 @@ import { PACKAGE_TYPE_GENERIC, PACKAGE_TYPE_DEBIAN, PACKAGE_TYPE_HELM, + LIST_KEY_PROJECT, + SORT_FIELDS, } from './constants'; export const getPackageTypeLabel = (packageType) => { @@ -38,3 +41,8 @@ export const getPackageTypeLabel = (packageType) => { return null; } }; + +export const packageTypeToTrackCategory = (type) => `UI::${capitalize(type)}Packages`; + +export const sortableFields = (isGroupPage) => + SORT_FIELDS.filter((f) => f.orderBy !== LIST_KEY_PROJECT || isGroupPage); diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue index 2dbe36def0e..5815c6393a7 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue @@ -103,6 +103,7 @@ export default { :disabled="isLoading" :label="$options.i18n.label" data-qa-selector="dependency_proxy_setting_toggle" + data-testid="dependency-proxy-setting-toggle" /> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/index.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/index.js index 16152eb81f6..56f95fa2c1f 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/index.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/index.js @@ -5,10 +5,5 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); export const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - assumeImmutableResults: true, - }, - ), + defaultClient: createDefaultClient(), }); diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js b/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js index 16152eb81f6..56f95fa2c1f 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js @@ -5,10 +5,5 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); export const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - assumeImmutableResults: true, - }, - ), + defaultClient: createDefaultClient(), }); diff --git a/app/assets/javascripts/pages/admin/deploy_keys/index/index.js b/app/assets/javascripts/pages/admin/deploy_keys/index/index.js new file mode 100644 index 00000000000..1e52aa3efd8 --- /dev/null +++ b/app/assets/javascripts/pages/admin/deploy_keys/index/index.js @@ -0,0 +1,3 @@ +import { initAdminDeployKeysTable } from '~/admin/deploy_keys'; + +initAdminDeployKeysTable(); diff --git a/app/assets/javascripts/pages/admin/dev_ops_report/index.js b/app/assets/javascripts/pages/admin/dev_ops_report/index.js index a94a60af7ff..4cad87492cf 100644 --- a/app/assets/javascripts/pages/admin/dev_ops_report/index.js +++ b/app/assets/javascripts/pages/admin/dev_ops_report/index.js @@ -1,5 +1,5 @@ -import initDevOpsScore from '~/analytics/devops_report/devops_score'; -import initDevOpsScoreDisabledServicePing from '~/analytics/devops_report/devops_score_disabled_service_ping'; +import initDevOpsScore from '~/analytics/devops_reports/devops_score'; +import initDevOpsScoreDisabledServicePing from '~/analytics/devops_reports/devops_score_disabled_service_ping'; initDevOpsScoreDisabledServicePing(); initDevOpsScore(); diff --git a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue index 055d6f40c14..b06c804f3ca 100644 --- a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue +++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue @@ -46,7 +46,7 @@ export default { return sprintf( s__(`AdminProjects| You’re about to permanently delete the project %{projectName}, its repository, - and all related resources, including issues and merge requests. Once you confirm and press + and all related resources, including issues and merge requests. After you confirm and press %{strong_start}Delete project%{strong_end}, it cannot be undone or recovered.`), { projectName: `<strong>${escape(this.projectName)}</strong>`, @@ -70,7 +70,7 @@ export default { }, primaryProps() { return { - text: s__('Delete project'), + text: __('Delete project'), attributes: [{ variant: 'danger' }, { category: 'primary' }, { disabled: !this.canSubmit }], }; }, diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index 946076cfb29..a1e7eb5d3de 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -1,4 +1,4 @@ -/* eslint-disable class-methods-use-this, no-unneeded-ternary */ +/* eslint-disable class-methods-use-this */ import $ from 'jquery'; import { getGroups } from '~/api/groups_api'; @@ -78,7 +78,7 @@ export default class Todos { initDeprecatedJQueryDropdown($dropdown, { fieldName, selectable: true, - filterable: searchFields ? true : false, + filterable: Boolean(searchFields), search: { fields: searchFields }, data: $dropdown.data('data'), clicked: () => { @@ -172,8 +172,8 @@ export default class Todos { updateBadges(data) { $(document).trigger('todo:toggle', data.count); - document.querySelector('.todos-pending .badge').innerHTML = addDelimiter(data.count); - document.querySelector('.todos-done .badge').innerHTML = addDelimiter(data.done_count); + document.querySelector('.js-todos-pending .badge').innerHTML = addDelimiter(data.count); + document.querySelector('.js-todos-done .badge').innerHTML = addDelimiter(data.done_count); } goToTodoUrl(e) { diff --git a/app/assets/javascripts/pages/groups/crm/contacts/index.js b/app/assets/javascripts/pages/groups/crm/contacts/index.js new file mode 100644 index 00000000000..a595246957f --- /dev/null +++ b/app/assets/javascripts/pages/groups/crm/contacts/index.js @@ -0,0 +1,3 @@ +import initCrmContactsApp from '~/crm/contacts_bundle'; + +initCrmContactsApp(); diff --git a/app/assets/javascripts/pages/groups/crm/organizations/index.js b/app/assets/javascripts/pages/groups/crm/organizations/index.js new file mode 100644 index 00000000000..16479b43d52 --- /dev/null +++ b/app/assets/javascripts/pages/groups/crm/organizations/index.js @@ -0,0 +1,3 @@ +import initCrmOrganizationsApp from '~/crm/organizations_bundle'; + +initCrmOrganizationsApp(); diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index 49b9822795c..604da77f60c 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -10,10 +10,12 @@ import projectSelect from '~/project_select'; import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; import setupTransferEdit from '~/transfer_edit'; +import initConfirmDanger from '~/init_confirm_danger'; document.addEventListener('DOMContentLoaded', () => { initFilePickers(); initConfirmDangerModal(); + initConfirmDanger(); initSettingsPanels(); dirtySubmitFactory( document.querySelectorAll('.js-general-settings-form, .js-general-permissions-form'), diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue index 9aac364d20e..c3ac074cd7a 100644 --- a/app/assets/javascripts/pages/groups/new/components/app.vue +++ b/app/assets/javascripts/pages/groups/new/components/app.vue @@ -47,7 +47,7 @@ export default { <template> <new-namespace-page :jump-to-last-persisted-panel="hasErrors" - :initial-breadcrumb="s__('New group')" + :initial-breadcrumb="__('New group')" :panels="$options.PANELS" :title="s__('GroupsNew|Create new group')" persistence-key="new_group_last_active_tab" diff --git a/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue b/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue index ea08a0821a8..35193171fb8 100644 --- a/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue +++ b/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue @@ -20,7 +20,7 @@ export default { <gl-sprintf :message=" s__( - 'GroupsNew|%{linkStart}Groups%{linkEnd} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects. Groups can also be nested by creating subgroups.', + 'GroupsNew|%{linkStart}Groups%{linkEnd} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.', ) " > diff --git a/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js b/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js deleted file mode 100644 index 301e0b4f7a2..00000000000 --- a/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js +++ /dev/null @@ -1,12 +0,0 @@ -import { buildApiUrl } from '~/api/api_utils'; -import axios from '~/lib/utils/axios_utils'; - -const NAMESPACE_EXISTS_PATH = '/api/:version/namespaces/:id/exists'; - -export default function fetchGroupPathAvailability(groupPath, parentId) { - const url = buildApiUrl(NAMESPACE_EXISTS_PATH).replace(':id', encodeURIComponent(groupPath)); - - return axios.get(url, { - params: { parent_id: parentId }, - }); -} diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js index c58be202043..8ce73be6e74 100644 --- a/app/assets/javascripts/pages/groups/new/group_path_validator.js +++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js @@ -3,7 +3,7 @@ import { debounce } from 'lodash'; import createFlash from '~/flash'; import { __ } from '~/locale'; import InputValidator from '~/validators/input_validator'; -import fetchGroupPathAvailability from './fetch_group_path_availability'; +import { getGroupPathAvailability } from '~/rest_api'; const debounceTimeoutDuration = 1000; const invalidInputClass = 'gl-field-error-outline'; @@ -45,7 +45,7 @@ export default class GroupPathValidator extends InputValidator { if (inputDomElement.checkValidity() && groupPath.length > 1) { GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector); - fetchGroupPathAvailability(groupPath, parentId) + getGroupPathAvailability(groupPath, parentId) .then(({ data }) => data) .then((data) => { GroupPathValidator.setInputState(inputDomElement, !data.exists); diff --git a/app/assets/javascripts/pages/groups/packages/index/index.js b/app/assets/javascripts/pages/groups/packages/index/index.js index 95522573b53..f9eecff4ac4 100644 --- a/app/assets/javascripts/pages/groups/packages/index/index.js +++ b/app/assets/javascripts/pages/groups/packages/index/index.js @@ -1,10 +1,3 @@ -(async function packageApp() { - if (window.gon.features.packageListApollo) { - const newPackageList = await import('~/packages_and_registries/package_registry/pages/list'); +import packageList from '~/packages_and_registries/package_registry/pages/list'; - newPackageList.default(); - } else { - const packageList = await import('~/packages/list/packages_list_app_bundle'); - packageList.default(); - } -})(); +packageList(); diff --git a/app/assets/javascripts/pages/groups/registry/repositories/index.js b/app/assets/javascripts/pages/groups/registry/repositories/index.js index 6fd32321568..44579ee1217 100644 --- a/app/assets/javascripts/pages/groups/registry/repositories/index.js +++ b/app/assets/javascripts/pages/groups/registry/repositories/index.js @@ -1,4 +1,4 @@ -import registryExplorer from '~/registry/explorer/index'; +import registryExplorer from '~/packages_and_registries/container_registry/explorer/index'; const explorer = registryExplorer(); diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue index e42e89ce021..b41611001ab 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue +++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue @@ -3,7 +3,7 @@ import { GlModal } from '@gitlab/ui'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; -import { s__, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; export default { components: { @@ -83,7 +83,7 @@ export default { attributes: [{ variant: 'warning' }], }, cancelAction: { - text: s__('Cancel'), + text: __('Cancel'), attributes: [], }, }; diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 80bcbefab46..b365e039191 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -14,7 +14,7 @@ import '~/sourcegraph/load'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), + defaultClient: createDefaultClient(), }); const viewBlobEl = document.querySelector('#js-view-blob-app'); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index 335d8d481fc..f4beefea90c 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -1,5 +1,5 @@ import { PROJECT_BADGE } from '~/badges/constants'; -import initConfirmDangerModal from '~/confirm_danger_modal'; +import initLegacyConfirmDangerModal from '~/confirm_danger_modal'; import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import initFilePickers from '~/file_pickers'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; @@ -9,11 +9,12 @@ import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; import setupTransferEdit from '~/transfer_edit'; import UserCallout from '~/user_callout'; +import initTopicsTokenSelector from '~/projects/settings/topics'; import initProjectPermissionsSettings from '../shared/permissions'; import initProjectLoadingSpinner from '../shared/save_project_loader'; initFilePickers(); -initConfirmDangerModal(); +initLegacyConfirmDangerModal(); initSettingsPanels(); initProjectDeleteButton(); mountBadgeSettings(PROJECT_BADGE); @@ -28,3 +29,4 @@ setupTransferEdit('.js-project-transfer-form', 'select.select2'); dirtySubmitFactory(document.querySelectorAll('.js-general-settings-form, .js-mr-settings-form')); initSearchSettings(); +initTopicsTokenSelector(); diff --git a/app/assets/javascripts/pages/projects/environments/index/index.js b/app/assets/javascripts/pages/projects/environments/index/index.js index 554ed4f9786..f0554d64ddc 100644 --- a/app/assets/javascripts/pages/projects/environments/index/index.js +++ b/app/assets/javascripts/pages/projects/environments/index/index.js @@ -1,3 +1,11 @@ import initEnvironments from '~/environments/'; +import initNewEnvironments from '~/environments/new_index'; -initEnvironments(); +let el = document.getElementById('environments-list-view'); + +if (el) { + initEnvironments(el); +} else { + el = document.getElementById('environments-table'); + initNewEnvironments(el); +} diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue index 795ae713c08..25b62e6c971 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -382,7 +382,11 @@ export default { :data-testid="`radio-${value}`" > <div> - <gl-icon :name="icon" /> + <gl-icon + data-qa-selector="fork_privacy_button" + :name="icon" + :data-qa-privacy-level="`${value}`" + /> <span>{{ text }}</span> </div> <template #help>{{ help }}</template> diff --git a/app/assets/javascripts/pages/projects/google_cloud/index.js b/app/assets/javascripts/pages/projects/google_cloud/index.js new file mode 100644 index 00000000000..4506ea8efd1 --- /dev/null +++ b/app/assets/javascripts/pages/projects/google_cloud/index.js @@ -0,0 +1,3 @@ +import initGoogleCloud from '~/google_cloud/index'; + +initGoogleCloud(); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 62aa5df888f..24aa2f0da13 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -3,7 +3,7 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import initIssuableSidebar from '~/init_issuable_sidebar'; import { IssuableType } from '~/issuable_show/constants'; import Issue from '~/issue'; -import initIncidentApp from '~/issue_show/incident'; +import { initIncidentApp, initIncidentHeaderActions } from '~/issue_show/incident'; import { initIssuableApp, initIssueHeaderActions } from '~/issue_show/issue'; import { parseIssuableData } from '~/issue_show/utils/parse_data'; import initNotesApp from '~/notes'; @@ -22,16 +22,18 @@ export default function initShowIssue() { switch (issueType) { case IssuableType.Incident: initIncidentApp(issuableData); + initIncidentHeaderActions(store); break; case IssuableType.Issue: initIssuableApp(issuableData, store); + initIssueHeaderActions(store); break; default: + initIssueHeaderActions(store); break; } initIssuableHeaderWarning(store); - initIssueHeaderActions(store); initSentryErrorStackTraceApp(); initRelatedMergeRequestsApp(); diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue index 51980b2d971..95afcb6bda8 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue @@ -1,5 +1,6 @@ <script> import { GlProgressBar, GlSprintf } from '@gitlab/ui'; +import eventHub from '~/invite_members/event_hub'; import { s__ } from '~/locale'; import { ACTION_LABELS, ACTION_SECTIONS } from '../constants'; import LearnGitlabSectionCard from './learn_gitlab_section_card.vue'; @@ -22,6 +23,11 @@ export default { required: true, type: Object, }, + inviteMembersOpen: { + type: Boolean, + required: false, + default: false, + }, }, maxValue: Object.keys(ACTION_LABELS).length, actionSections: Object.keys(ACTION_SECTIONS), @@ -33,7 +39,15 @@ export default { return Math.round((this.progressValue / this.$options.maxValue) * 100); }, }, + mounted() { + if (this.inviteMembersOpen) { + this.openInviteMembersModal('celebrate'); + } + }, methods: { + openInviteMembersModal(mode) { + eventHub.$emit('openModal', { mode, inviteeType: 'members', source: 'learn-gitlab' }); + }, actionsFor(section) { const actions = Object.fromEntries( Object.entries(this.actions).filter( diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue index 69fb5878f5c..0995947f3e7 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue @@ -40,6 +40,7 @@ export default { data-track-action="click_link" :data-track-label="$options.i18n.ACTION_LABELS[action].title" data-track-property="Growth::Conversion::Experiment::LearnGitLab" + data-track-experiment="change_continuous_onboarding_link_urls" > {{ $options.i18n.ACTION_LABELS[action].title }} </gl-link> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js index 6da0a8fd212..ea9eec2595f 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js +++ b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import LearnGitlab from '../components/learn_gitlab.vue'; @@ -11,15 +12,17 @@ function initLearnGitlab() { const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions)); const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections)); + const { inviteMembersOpen } = el.dataset; return new Vue({ el, render(createElement) { return createElement(LearnGitlab, { - props: { actions, sections }, + props: { actions, sections, inviteMembersOpen }, }); }, }); } +initInviteMembersModal(); initLearnGitlab(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index dadf0988582..99094617b0a 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -28,7 +28,6 @@ export default function initMergeRequestShow() { const el = document.querySelector('.js-mr-status-box'); const apolloProvider = new VueApollo({ - assumeImmutableResults: true, defaultClient: createDefaultClient(), }); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/pages/projects/packages/packages/index/index.js b/app/assets/javascripts/pages/projects/packages/packages/index/index.js index 95522573b53..f9eecff4ac4 100644 --- a/app/assets/javascripts/pages/projects/packages/packages/index/index.js +++ b/app/assets/javascripts/pages/projects/packages/packages/index/index.js @@ -1,10 +1,3 @@ -(async function packageApp() { - if (window.gon.features.packageListApollo) { - const newPackageList = await import('~/packages_and_registries/package_registry/pages/list'); +import packageList from '~/packages_and_registries/package_registry/pages/list'; - newPackageList.default(); - } else { - const packageList = await import('~/packages/list/packages_list_app_bundle'); - packageList.default(); - } -})(); +packageList(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index 0e646e8c505..85443843684 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue @@ -72,18 +72,18 @@ export default { return [ { value: KEY_EVERY_DAY, - text: sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime }), + text: sprintf(__(`Every day (at %{time})`), { time: this.formattedTime }), }, { value: KEY_EVERY_WEEK, - text: sprintf(s__('Every week (%{weekday} at %{time})'), { + text: sprintf(__('Every week (%{weekday} at %{time})'), { weekday: this.weekday, time: this.formattedTime, }), }, { value: KEY_EVERY_MONTH, - text: sprintf(s__('Every month (Day %{day} at %{time})'), { + text: sprintf(__('Every month (Day %{day} at %{time})'), { day: this.randomDay, time: this.formattedTime, }), diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 9e93f709937..a26aeeb6db4 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -23,14 +23,14 @@ export default class Project { }); } - $('.hide-no-ssh-message').on('click', function (e) { + $('.js-hide-no-ssh-message').on('click', function (e) { Cookies.set('hide_no_ssh_message', 'false'); - $(this).parents('.no-ssh-key-message').remove(); + $(this).parents('.js-no-ssh-key-message').remove(); return e.preventDefault(); }); - $('.hide-no-password-message').on('click', function (e) { + $('.js-hide-no-password-message').on('click', function (e) { Cookies.set('hide_no_password_message', 'false'); - $(this).parents('.no-password-message').remove(); + $(this).parents('.js-no-password-message').remove(); return e.preventDefault(); }); $('.hide-auto-devops-implicitly-enabled-banner').on('click', function (e) { diff --git a/app/assets/javascripts/pages/projects/registry/repositories/index.js b/app/assets/javascripts/pages/projects/registry/repositories/index.js index 6fd32321568..44579ee1217 100644 --- a/app/assets/javascripts/pages/projects/registry/repositories/index.js +++ b/app/assets/javascripts/pages/projects/registry/repositories/index.js @@ -1,4 +1,4 @@ -import registryExplorer from '~/registry/explorer/index'; +import registryExplorer from '~/packages_and_registries/container_registry/explorer/index'; const explorer = registryExplorer(); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue index b7546a6bed7..cc92a8cd476 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue @@ -1,10 +1,5 @@ <script> -import { GlIcon } from '@gitlab/ui'; - export default { - components: { - GlIcon, - }, props: { label: { type: String, @@ -29,10 +24,14 @@ export default { <div class="project-feature-row"> <label v-if="label" class="label-bold"> {{ label }} - <a v-if="helpPath" :href="helpPath" target="_blank"> - <gl-icon name="question-o" /> - </a> </label> - <span v-if="helpText" class="form-text text-muted"> {{ helpText }} </span> <slot></slot> + <div> + <span v-if="helpText" class="text-muted"> {{ helpText }} </span> + <span v-if="helpPath" + ><a :href="helpPath" target="_blank">{{ __('Learn more') }}</a + >.</span + > + </div> + <slot></slot> </div> </template> diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index c53d367ed71..384ee1f5034 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -2,7 +2,7 @@ import { GlIcon, GlSprintf, GlLink, GlFormCheckbox, GlToggle } from '@gitlab/ui'; import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import { visibilityOptions, visibilityLevelDescriptions, @@ -31,7 +31,7 @@ export default { operationsLabel: s__('ProjectSettings|Operations'), packagesLabel: s__('ProjectSettings|Packages'), pagesLabel: s__('ProjectSettings|Pages'), - ciCdLabel: s__('CI/CD'), + ciCdLabel: __('CI/CD'), repositoryLabel: s__('ProjectSettings|Repository'), requirementsLabel: s__('ProjectSettings|Requirements'), securityAndComplianceLabel: s__('ProjectSettings|Security & Compliance'), @@ -400,6 +400,9 @@ export default { ref="project-visibility-settings" :help-path="visibilityHelpPath" :label="s__('ProjectSettings|Project visibility')" + :help-text=" + s__('ProjectSettings|Manage who can see the project in the public access directory.') + " > <div class="project-feature-controls gl-display-flex gl-align-items-center gl-my-3 gl-mx-0"> <div class="select-wrapper gl-flex-grow-1"> diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 78b3f2f1b30..31d69a731fe 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -7,7 +7,7 @@ import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import leaveByUrl from '~/namespaces/leave_by_url'; import initVueNotificationsDropdown from '~/notifications'; -import { initUploadFileTrigger } from '~/projects/upload_file_experiment'; +import { initUploadFileTrigger } from '~/projects/upload_file'; import initReadMore from '~/read_more'; import UserCallout from '~/user_callout'; import Star from '../../../star'; diff --git a/app/assets/javascripts/pages/projects/work_items/index/index.js b/app/assets/javascripts/pages/projects/work_items/index.js index 11c257611f0..11c257611f0 100644 --- a/app/assets/javascripts/pages/projects/work_items/index/index.js +++ b/app/assets/javascripts/pages/projects/work_items/index.js diff --git a/app/assets/javascripts/pages/shared/mount_runner_instructions.js b/app/assets/javascripts/pages/shared/mount_runner_instructions.js index 1cb7259be64..e83c73edfde 100644 --- a/app/assets/javascripts/pages/shared/mount_runner_instructions.js +++ b/app/assets/javascripts/pages/shared/mount_runner_instructions.js @@ -9,12 +9,7 @@ export function initInstallRunner(componentId = 'js-install-runner') { const installRunnerEl = document.getElementById(componentId); if (installRunnerEl) { - const defaultClient = createDefaultClient( - {}, - { - assumeImmutableResults: true, - }, - ); + const defaultClient = createDefaultClient(); const apolloProvider = new VueApollo({ defaultClient, diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index a8ec731e105..6f19a9f4379 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -12,13 +12,15 @@ import { import axios from '~/lib/utils/axios_utils'; import csrf from '~/lib/utils/csrf'; import { setUrlFragment } from '~/lib/utils/url_utility'; -import { s__, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import Tracking from '~/tracking'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { - WIKI_CONTENT_EDITOR_TRACKING_LABEL, CONTENT_EDITOR_LOADED_ACTION, SAVED_USING_CONTENT_EDITOR_ACTION, + WIKI_CONTENT_EDITOR_TRACKING_LABEL, + WIKI_FORMAT_LABEL, + WIKI_FORMAT_UPDATED_ACTION, } from '../constants'; const trackingMixin = Tracking.mixin({ @@ -83,7 +85,7 @@ export default { ), }, }, - feedbackTip: s__( + feedbackTip: __( 'Tell us your experiences with the new Markdown editor %{linkStart}in this feedback issue%{linkEnd}.', ), }, @@ -219,6 +221,8 @@ export default { this.trackFormSubmit(); } + this.trackWikiFormat(); + // Wait until form field values are refreshed await this.$nextTick(); @@ -304,6 +308,14 @@ export default { } }, + trackWikiFormat() { + this.track(WIKI_FORMAT_UPDATED_ACTION, { + label: WIKI_FORMAT_LABEL, + value: this.format, + extra: { project_path: this.pageInfo.path, old_format: this.pageInfo.format }, + }); + }, + dismissContentEditorAlert() { this.isContentEditorAlertDismissed = true; }, diff --git a/app/assets/javascripts/pages/shared/wikis/constants.js b/app/assets/javascripts/pages/shared/wikis/constants.js index b358ac9cf52..94d086158f1 100644 --- a/app/assets/javascripts/pages/shared/wikis/constants.js +++ b/app/assets/javascripts/pages/shared/wikis/constants.js @@ -1,4 +1,5 @@ -export const WIKI_CONTENT_EDITOR_TRACKING_LABEL = 'wiki_content_editor'; - export const CONTENT_EDITOR_LOADED_ACTION = 'content_editor_loaded'; export const SAVED_USING_CONTENT_EDITOR_ACTION = 'saved_using_content_editor'; +export const WIKI_CONTENT_EDITOR_TRACKING_LABEL = 'wiki_content_editor'; +export const WIKI_FORMAT_LABEL = 'wiki_format'; +export const WIKI_FORMAT_UPDATED_ACTION = 'wiki_format_updated'; diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 0fab4678bc3..7f4e79976bc 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -5,6 +5,7 @@ import { last } from 'lodash'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility'; +import { formatDate } from '~/lib/utils/datetime/date_format_utility'; import { n__, s__, __ } from '~/locale'; const d3 = { select }; @@ -294,7 +295,15 @@ export default class ActivityCalendar { }, responseType: 'text', }) - .then(({ data }) => $(this.activitiesContainer).html(data)) + .then(({ data }) => { + $(this.activitiesContainer).html(data); + document + .querySelector(this.activitiesContainer) + .querySelectorAll('.js-localtime') + .forEach((el) => { + el.setAttribute('title', formatDate(el.getAttribute('data-datetime'))); + }); + }) .catch(() => createFlash({ message: __('An error occurred while retrieving calendar activity'), diff --git a/app/assets/javascripts/pages/users/terms/index/index.js b/app/assets/javascripts/pages/users/terms/index/index.js new file mode 100644 index 00000000000..29ddde6da94 --- /dev/null +++ b/app/assets/javascripts/pages/users/terms/index/index.js @@ -0,0 +1,4 @@ +import { initTermsApp } from '~/terms'; +import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; + +waitForCSSLoaded(initTermsApp); diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index f163a7c3a8e..1bb82e1d8e6 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlModal, GlModalDirective, GlSegmentedControl } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import { sortOrders, sortOrderOptions } from '../constants'; import RequestWarning from './request_warning.vue'; @@ -55,7 +55,7 @@ export default { const summary = {}; if (!this.metricDetails.summaryOptions?.hideTotal) { - summary[s__('Total')] = this.metricDetails.calls; + summary[__('Total')] = this.metricDetails.calls; } if (!this.metricDetails.summaryOptions?.hideDuration) { diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue index f1fe8cf10fd..905a5f2d271 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue @@ -36,6 +36,11 @@ export default { required: false, default: false, }, + scrollToCommitForm: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -52,6 +57,13 @@ export default { return !(this.message && this.targetBranch); }, }, + watch: { + scrollToCommitForm(flag) { + if (flag) { + this.scrollIntoView(); + } + }, + }, methods: { onSubmit() { this.$emit('submit', { @@ -63,6 +75,10 @@ export default { onReset() { this.$emit('cancel'); }, + scrollIntoView() { + this.$el.scrollIntoView({ behavior: 'smooth' }); + this.$emit('scrolled-to-commit-form'); + }, }, i18n: { commitMessage: __('Commit message'), diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue index 0308cd9c565..14c11099756 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue @@ -10,9 +10,8 @@ import { import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql'; import updateCurrentBranchMutation from '../../graphql/mutations/update_current_branch.mutation.graphql'; import updateLastCommitBranchMutation from '../../graphql/mutations/update_last_commit_branch.mutation.graphql'; +import updatePipelineEtag from '../../graphql/mutations/update_pipeline_etag.mutation.graphql'; import getCurrentBranch from '../../graphql/queries/client/current_branch.graphql'; -import getIsNewCiConfigFile from '../../graphql/queries/client/is_new_ci_config_file.graphql'; -import getPipelineEtag from '../../graphql/queries/client/pipeline_etag.graphql'; import CommitForm from './commit_form.vue'; @@ -41,18 +40,24 @@ export default { required: false, default: '', }, + isNewCiConfigFile: { + type: Boolean, + required: false, + default: false, + }, + scrollToCommitForm: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { commit: {}, - isNewCiConfigFile: false, isSaving: false, }; }, apollo: { - isNewCiConfigFile: { - query: getIsNewCiConfigFile, - }, currentBranch: { query: getCurrentBranch, }, @@ -96,10 +101,10 @@ export default { content: this.ciFileContent, lastCommitId: this.commitSha, }, - update(store, { data }) { + update(_, { data }) { const pipelineEtag = data?.commitCreate?.commit?.commitPipelinePath; if (pipelineEtag) { - store.writeQuery({ query: getPipelineEtag, data: { pipelineEtag } }); + this.$apollo.mutate({ mutation: updatePipelineEtag, variables: pipelineEtag }); } }, }); @@ -146,6 +151,8 @@ export default { :current-branch="currentBranch" :default-message="defaultCommitMessage" :is-saving="isSaving" + :scroll-to-commit-form="scrollToCommitForm" + v-on="$listeners" @cancel="onCommitCancel" @submit="onCommitSubmit" /> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue index ff1e0b6388f..d7594fb318a 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue +++ b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue @@ -2,6 +2,7 @@ import { GlButton, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import { experiment } from '~/experimentation/utils'; import { DRAWER_EXPANDED_KEY } from '../../constants'; import FirstPipelineCard from './cards/first_pipeline_card.vue'; import GettingStartedCard from './cards/getting_started_card.vue'; @@ -53,12 +54,23 @@ export default { }, methods: { setInitialExpandState() { + let isExpanded; + + experiment('pipeline_editor_walkthrough', { + control: () => { + isExpanded = true; + }, + candidate: () => { + isExpanded = false; + }, + }); + // We check in the local storage and if no value is defined, we want the default // to be true. We want to explicitly set it to true here so that the drawer // animates to open on load. const localValue = localStorage.getItem(this.$options.localDrawerKey); if (localValue === null) { - this.isExpanded = true; + this.isExpanded = isExpanded; } }, setTopPosition() { diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue index 68065cc3c73..baf1d17b233 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue @@ -12,7 +12,7 @@ import { produce } from 'immer'; import { fetchPolicies } from '~/lib/graphql'; import { historyPushState } from '~/lib/utils/common_utils'; import { setUrlParams } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; +import { __ } from '~/locale'; import { BRANCH_PAGINATION_LIMIT, BRANCH_SEARCH_DEBOUNCE, @@ -25,9 +25,9 @@ import getLastCommitBranchQuery from '~/pipeline_editor/graphql/queries/client/l export default { i18n: { - dropdownHeader: s__('Switch branch'), - title: s__('Branches'), - fetchError: s__('Unable to fetch branch list for this project.'), + dropdownHeader: __('Switch branch'), + title: __('Branches'), + fetchError: __('Unable to fetch branch list for this project.'), }, inputDebounce: BRANCH_SEARCH_DEBOUNCE, components: { @@ -43,14 +43,25 @@ export default { }, inject: ['projectFullPath', 'totalBranches'], props: { + hasUnsavedChanges: { + type: Boolean, + required: false, + default: false, + }, paginationLimit: { type: Number, required: false, default: BRANCH_PAGINATION_LIMIT, }, + shouldLoadNewBranch: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { + branchSelected: null, availableBranches: [], filteredBranches: [], isSearchingBranches: false, @@ -101,10 +112,17 @@ export default { isBranchesLoading() { return this.$apollo.queries.availableBranches.loading || this.isSearchingBranches; }, - showBranchSwitcher() { + enableBranchSwitcher() { return this.branches.length > 0 || this.searchTerm.length > 0; }, }, + watch: { + shouldLoadNewBranch(flag) { + if (flag) { + this.changeBranch(this.branchSelected); + } + }, + }, methods: { availableBranchesQueryVars(varsOverride = {}) { if (this.searchTerm.length > 0) { @@ -149,11 +167,7 @@ export default { }) .catch(this.showFetchError); }, - async selectBranch(newBranch) { - if (newBranch === this.currentBranch) { - return; - } - + async changeBranch(newBranch) { this.updateCurrentBranch(newBranch); const updatedPath = setUrlParams({ branch_name: newBranch }); historyPushState(updatedPath); @@ -164,6 +178,19 @@ export default { await this.$nextTick(); this.$emit('refetchContent'); }, + selectBranch(newBranch) { + if (newBranch !== this.currentBranch) { + // If there are unsaved changes, we want to show the user + // a modal to confirm what to do with these before changing + // branches. + if (this.hasUnsavedChanges) { + this.branchSelected = newBranch; + this.$emit('select-branch', newBranch); + } else { + this.changeBranch(newBranch); + } + } + }, async setSearchTerm(newSearchTerm) { this.pageCounter = 0; this.searchTerm = newSearchTerm.trim(); @@ -203,11 +230,11 @@ export default { <template> <gl-dropdown - v-if="showBranchSwitcher" v-gl-tooltip.hover :title="$options.i18n.dropdownHeader" :header-text="$options.i18n.dropdownHeader" :text="currentBranch" + :disabled="!enableBranchSwitcher" icon="branch" data-qa-selector="branch_selector_button" data-testid="branch-selector" diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue index 551a0430fbf..83b074dd55c 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue @@ -5,10 +5,26 @@ export default { components: { BranchSwitcher, }, + props: { + hasUnsavedChanges: { + type: Boolean, + required: false, + default: false, + }, + shouldLoadNewBranch: { + type: Boolean, + required: false, + default: false, + }, + }, }; </script> <template> <div class="gl-mb-4"> - <branch-switcher v-on="$listeners" /> + <branch-switcher + :has-unsaved-changes="hasUnsavedChanges" + :should-load-new-branch="shouldLoadNewBranch" + v-on="$listeners" + /> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue index fcc31f087ff..ec6ee52b6b2 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue @@ -63,6 +63,7 @@ export default { v-if="showPipelineStatus" :commit-sha="commitSha" :class="$options.pipelineStatusClasses" + v-on="$listeners" /> <validation-segment :class="validationStyling" :ci-config="ciConfigData" /> </div> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue index 75b1398a3c2..25a78aab933 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue @@ -1,17 +1,52 @@ <script> +import { __ } from '~/locale'; import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; +import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql'; +import { PIPELINE_FAILURE } from '../../constants'; export default { + i18n: { + linkedPipelinesFetchError: __('Unable to fetch upstream and downstream pipelines.'), + }, components: { PipelineMiniGraph, + LinkedPipelinesMiniList: () => + import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), }, + inject: ['projectFullPath'], props: { pipeline: { type: Object, required: true, }, }, + apollo: { + linkedPipelines: { + query: getLinkedPipelinesQuery, + variables() { + return { + fullPath: this.projectFullPath, + iid: this.pipeline.iid, + }; + }, + skip() { + return !this.pipeline.iid; + }, + update({ project }) { + return project?.pipeline; + }, + error() { + this.$emit('showError', { + type: PIPELINE_FAILURE, + reasons: [this.$options.i18n.linkedPipelinesFetchError], + }); + }, + }, + }, computed: { + downstreamPipelines() { + return this.linkedPipelines?.downstream?.nodes || []; + }, pipelinePath() { return this.pipeline.detailedStatus?.detailsPath || ''; }, @@ -38,12 +73,29 @@ export default { }; }); }, + showDownstreamPipelines() { + return this.downstreamPipelines.length > 0; + }, + upstreamPipeline() { + return this.linkedPipelines?.upstream; + }, }, }; </script> <template> <div v-if="pipelineStages.length > 0" class="stage-cell gl-mr-5"> + <linked-pipelines-mini-list + v-if="upstreamPipeline" + :triggered-by="[upstreamPipeline]" + data-testid="pipeline-editor-mini-graph-upstream" + /> <pipeline-mini-graph class="gl-display-inline" :stages="pipelineStages" /> + <linked-pipelines-mini-list + v-if="showDownstreamPipelines" + :triggered="downstreamPipelines" + :pipeline-path="pipelinePath" + data-testid="pipeline-editor-mini-graph-downstream" + /> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue index a1fa2147994..6fe1459c80c 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__ } from '~/locale'; @@ -10,7 +10,6 @@ import { toggleQueryPollingByVisibility, } from '~/pipelines/components/graph/utils'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue'; const POLL_INTERVAL = 10000; @@ -21,6 +20,10 @@ export const i18n = { `Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}`, ), viewBtn: s__('Pipeline|View pipeline'), + viewCommit: s__('Pipeline|View commit'), + pipelineNotTriggeredMsg: s__( + 'Pipeline|No pipeline was triggered for the latest changes due to the current CI/CD configuration.', + ), }; export default { @@ -34,7 +37,9 @@ export default { GlSprintf, PipelineEditorMiniGraph, }, - mixins: [glFeatureFlagMixin()], + directives: { + GlTooltip: GlTooltipDirective, + }, inject: ['projectFullPath'], props: { commitSha: { @@ -59,12 +64,13 @@ export default { }; }, update(data) { - const { id, commitPath = '', detailedStatus = {}, stages, status } = + const { id, iid, commit = {}, detailedStatus = {}, stages, status } = data.project?.pipeline || {}; return { id, - commitPath, + iid, + commit, detailedStatus, stages, status, @@ -73,20 +79,36 @@ export default { result(res) { if (res.data?.project?.pipeline) { this.hasError = false; + } else { + this.hasError = true; + this.pipelineNotTriggered = true; } }, error() { this.hasError = true; + this.networkError = true; }, pollInterval: POLL_INTERVAL, }, }, data() { return { + networkError: false, + pipelineNotTriggered: false, hasError: false, }; }, computed: { + commitText() { + const shortSha = truncateSha(this.commitSha); + const commitTitle = this.pipeline.commit.title || ''; + + if (commitTitle.length > 0) { + return `${shortSha}: ${commitTitle}`; + } + + return shortSha; + }, hasPipelineData() { return Boolean(this.pipeline?.id); }, @@ -126,13 +148,19 @@ export default { </div> </template> <template v-else-if="hasError"> - <div> + <div v-if="networkError"> <gl-icon class="gl-mr-auto" name="warning-solid" /> <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span> </div> + <div v-else> + <gl-icon class="gl-mr-auto" name="information-o" /> + <span data-testid="pipeline-not-triggered-error-msg"> + {{ $options.i18n.pipelineNotTriggeredMsg }} + </span> + </div> </template> <template v-else> - <div> + <div class="gl-text-truncate gl-md-max-w-50p gl-mr-1"> <a :href="status.detailsPath" class="gl-mr-auto"> <ci-icon :status="status" :size="16" data-testid="pipeline-status-icon" /> </a> @@ -144,25 +172,21 @@ export default { <template #status>{{ status.text }}</template> <template #commit> <gl-link - :href="pipeline.commitPath" - class="commit-sha gl-font-weight-normal" - target="_blank" + v-gl-tooltip.hover + :href="pipeline.commit.webPath" + :title="$options.i18n.viewCommit" data-testid="pipeline-commit" > - {{ shortSha }} + {{ commitText }} </gl-link> </template> </gl-sprintf> </span> </div> <div class="gl-display-flex gl-flex-wrap"> - <pipeline-editor-mini-graph - v-if="glFeatures.pipelineEditorMiniGraph" - :pipeline="pipeline" - /> + <pipeline-editor-mini-graph :pipeline="pipeline" v-on="$listeners" /> <gl-button class="gl-mt-2 gl-md-mt-0" - target="_blank" category="secondary" variant="confirm" :href="status.detailsPath" diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue index 8bffd893473..611b78b3c5e 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue @@ -75,7 +75,7 @@ export default { return this.$options.i18n.valid; default: // Only display first error as a reason - return this.ciConfig?.errors.length > 0 + return this.ciConfig?.errors?.length > 0 ? sprintf(this.$options.i18n.invalidWithReason, { reason }, false) : this.$options.i18n.invalid; } diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index f7c9f10ea46..0cd0d17d944 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -3,15 +3,18 @@ import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import { s__ } from '~/locale'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; +import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; import { CREATE_TAB, EDITOR_APP_STATUS_EMPTY, - EDITOR_APP_STATUS_ERROR, EDITOR_APP_STATUS_INVALID, EDITOR_APP_STATUS_LOADING, EDITOR_APP_STATUS_VALID, LINT_TAB, MERGED_TAB, + TAB_QUERY_PARAM, + TABS_INDEX, VISUALIZE_TAB, } from '../constants'; import getAppStatus from '../graphql/queries/client/app_status.graphql'; @@ -20,6 +23,7 @@ import CiEditorHeader from './editor/ci_editor_header.vue'; import TextEditor from './editor/text_editor.vue'; import CiLint from './lint/ci_lint.vue'; import EditorTab from './ui/editor_tab.vue'; +import WalkthroughPopover from './walkthrough_popover.vue'; export default { i18n: { @@ -42,6 +46,9 @@ export default { errorTexts: { loadMergedYaml: s__('Pipelines|Could not load merged YAML content'), }, + query: { + TAB_QUERY_PARAM, + }, tabConstants: { CREATE_TAB, LINT_TAB, @@ -58,6 +65,8 @@ export default { GlTabs, PipelineGraph, TextEditor, + GitlabExperiment, + WalkthroughPopover, }, mixins: [glFeatureFlagsMixin()], props: { @@ -74,6 +83,10 @@ export default { required: false, default: '', }, + isNewCiConfigFile: { + type: Boolean, + required: true, + }, }, apollo: { appStatus: { @@ -81,9 +94,8 @@ export default { }, }, computed: { - hasAppError() { - // Not an invalid config and with `mergedYaml` data missing - return this.appStatus === EDITOR_APP_STATUS_ERROR; + isMergedYamlAvailable() { + return this.ciConfigData?.mergedYaml; }, isEmpty() { return this.appStatus === EDITOR_APP_STATUS_EMPTY; @@ -98,22 +110,51 @@ export default { return this.appStatus === EDITOR_APP_STATUS_LOADING; }, }, + created() { + const [tabQueryParam] = getParameterValues(TAB_QUERY_PARAM); + + if (tabQueryParam && TABS_INDEX[tabQueryParam]) { + this.setDefaultTab(tabQueryParam); + } + }, methods: { setCurrentTab(tabName) { this.$emit('set-current-tab', tabName); }, + setDefaultTab(tabName) { + // We associate tab name with the index so that we can use tab name + // in other part of the app and load the corresponding tab closer to the + // actual component using a hash that binds the name to the indexes. + // This also means that if we ever changed tab order, we would justs need to + // update `TABS_INDEX` hash instead of all the instances in the app + // where we used the individual indexes + const newUrl = setUrlParams({ [TAB_QUERY_PARAM]: TABS_INDEX[tabName] }); + + this.setCurrentTab(tabName); + updateHistory({ url: newUrl, title: document.title, replace: true }); + }, }, }; </script> <template> - <gl-tabs class="file-editor gl-mb-3"> + <gl-tabs + class="file-editor gl-mb-3" + :query-param-name="$options.query.TAB_QUERY_PARAM" + sync-active-tab-with-query-params + > <editor-tab class="gl-mb-3" + title-link-class="js-walkthrough-popover-target" :title="$options.i18n.tabEdit" lazy data-testid="editor-tab" @click="setCurrentTab($options.tabConstants.CREATE_TAB)" > + <gitlab-experiment name="pipeline_editor_walkthrough"> + <template #candidate> + <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" /> + </template> + </gitlab-experiment> <ci-editor-header /> <text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" /> </editor-tab> @@ -154,7 +195,7 @@ export default { @click="setCurrentTab($options.tabConstants.MERGED_TAB)" > <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> - <gl-alert v-else-if="hasAppError" variant="danger" :dismissible="false"> + <gl-alert v-else-if="!isMergedYamlAvailable" variant="danger" :dismissible="false"> {{ $options.errorTexts.loadMergedYaml }} </gl-alert> <ci-config-merged-preview v-else :ci-config-data="ciConfigData" v-on="$listeners" /> diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue index 091b202e10b..7206f19d060 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue +++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue @@ -8,6 +8,7 @@ import { DEFAULT_FAILURE, DEFAULT_SUCCESS, LOAD_FAILURE_UNKNOWN, + PIPELINE_FAILURE, } from '../../constants'; import CodeSnippetAlert from '../code_snippet_alert/code_snippet_alert.vue'; import { @@ -24,6 +25,7 @@ export default { [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'), [DEFAULT_FAILURE]: __('Something went wrong on our end.'), [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'), + [PIPELINE_FAILURE]: s__('Pipelines|There was a problem with loading the pipeline data.'), }, successTexts: { [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'), @@ -74,6 +76,11 @@ export default { text: this.$options.errorTexts[COMMIT_FAILURE], variant: 'danger', }; + case PIPELINE_FAILURE: + return { + text: this.$options.errorTexts[PIPELINE_FAILURE], + variant: 'danger', + }; default: return { text: this.$options.errorTexts[DEFAULT_FAILURE], diff --git a/app/assets/javascripts/pipeline_editor/components/walkthrough_popover.vue b/app/assets/javascripts/pipeline_editor/components/walkthrough_popover.vue new file mode 100644 index 00000000000..5742b11b841 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/walkthrough_popover.vue @@ -0,0 +1,83 @@ +<script> +import { GlButton, GlPopover, GlSprintf, GlOutsideDirective as Outside } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + directives: { Outside }, + i18n: { + title: s__('pipelineEditorWalkthrough|See how GitLab pipelines work'), + description: s__( + 'pipelineEditorWalkthrough|This %{codeStart}.gitlab-ci.yml%{codeEnd} file creates a simple test pipeline.', + ), + instruction: s__( + 'pipelineEditorWalkthrough|Use the %{boldStart}commit changes%{boldEnd} button at the bottom of the page to run the pipeline.', + ), + ctaText: s__("pipelineEditorWalkthrough|Let's do this!"), + }, + components: { + GlButton, + GlPopover, + GlSprintf, + }, + data() { + return { + show: true, + }; + }, + computed: { + targetElement() { + return document.querySelector('.js-walkthrough-popover-target'); + }, + }, + methods: { + close() { + this.show = false; + }, + handleClickCta() { + this.close(); + this.$emit('walkthrough-popover-cta-clicked'); + }, + }, +}; +</script> + +<template> + <gl-popover + :show.sync="show" + :title="$options.i18n.title" + :target="targetElement" + placement="right" + triggers="focus" + > + <div v-outside="close" class="gl-display-flex gl-flex-direction-column"> + <p> + <gl-sprintf :message="$options.i18n.description"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + + <p> + <gl-sprintf :message="$options.i18n.instruction"> + <template #bold="{ content }"> + <strong> + {{ content }} + </strong> + </template> + </gl-sprintf> + </p> + + <gl-button + class="gl-align-self-end" + category="tertiary" + data-testid="ctaBtn" + variant="confirm" + @click="handleClickCta" + > + <gl-emoji data-name="rocket" /> + {{ this.$options.i18n.ctaText }} + </gl-button> + </div> + </gl-popover> +</template> diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index bb03fa126a5..a2eaeeef286 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -5,24 +5,37 @@ export const CI_CONFIG_STATUS_VALID = 'VALID'; // Values for EDITOR_APP_STATUS_* are frontend specifics and // represent the global state of the pipeline editor app. export const EDITOR_APP_STATUS_EMPTY = 'EMPTY'; -export const EDITOR_APP_STATUS_ERROR = 'ERROR'; export const EDITOR_APP_STATUS_INVALID = CI_CONFIG_STATUS_INVALID; export const EDITOR_APP_STATUS_LOADING = 'LOADING'; export const EDITOR_APP_STATUS_VALID = CI_CONFIG_STATUS_VALID; +export const EDITOR_APP_VALID_STATUSES = [ + EDITOR_APP_STATUS_EMPTY, + EDITOR_APP_STATUS_INVALID, + EDITOR_APP_STATUS_LOADING, + EDITOR_APP_STATUS_VALID, +]; + export const COMMIT_FAILURE = 'COMMIT_FAILURE'; export const COMMIT_SUCCESS = 'COMMIT_SUCCESS'; export const DEFAULT_FAILURE = 'DEFAULT_FAILURE'; export const DEFAULT_SUCCESS = 'DEFAULT_SUCCESS'; export const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN'; +export const PIPELINE_FAILURE = 'PIPELINE_FAILURE'; export const CREATE_TAB = 'CREATE_TAB'; export const LINT_TAB = 'LINT_TAB'; export const MERGED_TAB = 'MERGED_TAB'; export const VISUALIZE_TAB = 'VISUALIZE_TAB'; -export const TABS_WITH_COMMIT_FORM = [CREATE_TAB, LINT_TAB, VISUALIZE_TAB]; +export const TABS_INDEX = { + [CREATE_TAB]: '0', + [VISUALIZE_TAB]: '1', + [LINT_TAB]: '2', + [MERGED_TAB]: '3', +}; +export const TAB_QUERY_PARAM = 'tab'; export const COMMIT_ACTION_CREATE = 'CREATE'; export const COMMIT_ACTION_UPDATE = 'UPDATE'; diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql new file mode 100644 index 00000000000..7487e328668 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateAppStatus($appStatus: String) { + updateAppStatus(appStatus: $appStatus) @client +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql new file mode 100644 index 00000000000..9025f00b343 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql @@ -0,0 +1,3 @@ +mutation updatePipelineEtag($pipelineEtag: String) { + updatePipelineEtag(pipelineEtag: $pipelineEtag) @client +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql deleted file mode 100644 index 8c2ca276f50..00000000000 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query getIsNewCiConfigFile { - isNewCiConfigFile @client -} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql index 0c3653a2880..34e98ae3eb3 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql @@ -1,10 +1,13 @@ query getPipeline($fullPath: ID!, $sha: String!) { project(fullPath: $fullPath) { pipeline(sha: $sha) { - commitPath id iid status + commit { + title + webPath + } detailedStatus { detailsPath icon diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js index a34652b1495..e4965e00af3 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js @@ -1,7 +1,8 @@ -import produce from 'immer'; import axios from '~/lib/utils/axios_utils'; +import getAppStatus from './queries/client/app_status.graphql'; import getCurrentBranchQuery from './queries/client/current_branch.graphql'; import getLastCommitBranchQuery from './queries/client/last_commit_branch.query.graphql'; +import getPipelineEtag from './queries/client/pipeline_etag.graphql'; export const resolvers = { Mutation: { @@ -31,20 +32,28 @@ export const resolvers = { __typename: 'CiLintContent', })); }, + updateAppStatus: (_, { appStatus }, { cache }) => { + cache.writeQuery({ + query: getAppStatus, + data: { appStatus }, + }); + }, updateCurrentBranch: (_, { currentBranch }, { cache }) => { cache.writeQuery({ query: getCurrentBranchQuery, - data: produce(cache.readQuery({ query: getCurrentBranchQuery }), (draftData) => { - draftData.currentBranch = currentBranch; - }), + data: { currentBranch }, }); }, updateLastCommitBranch: (_, { lastCommitBranch }, { cache }) => { cache.writeQuery({ query: getLastCommitBranchQuery, - data: produce(cache.readQuery({ query: getLastCommitBranchQuery }), (draftData) => { - draftData.lastCommitBranch = lastCommitBranch; - }), + data: { lastCommitBranch }, + }); + }, + updatePipelineEtag: (_, { pipelineEtag }, { cache }) => { + cache.writeQuery({ + query: getPipelineEtag, + data: { pipelineEtag }, }); }, }, diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index 89b9091e6f9..4f7f2743aca 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -3,8 +3,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; +import { EDITOR_APP_STATUS_LOADING } from './constants'; import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants'; import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; +import getAppStatus from './graphql/queries/client/app_status.graphql'; import getLastCommitBranchQuery from './graphql/queries/client/last_commit_branch.query.graphql'; import getPipelineEtag from './graphql/queries/client/pipeline_etag.graphql'; import { resolvers } from './graphql/resolvers'; @@ -59,12 +61,18 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { defaultClient: createDefaultClient(resolvers, { typeDefs, useGet: true, - assumeImmutableResults: true, }), }); const { cache } = apolloProvider.clients.defaultClient; cache.writeQuery({ + query: getAppStatus, + data: { + appStatus: EDITOR_APP_STATUS_LOADING, + }, + }); + + cache.writeQuery({ query: getCurrentBranch, data: { currentBranch: initialBranchName || defaultBranch, @@ -93,6 +101,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { ciExamplesHelpPagePath, ciHelpPagePath, configurationPaths, + dataMethod: 'graphql', defaultBranch, emptyStateIllustrationPath, helpPaths, diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index e70417145ab..68db5d8078f 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -12,16 +12,16 @@ import PipelineEditorMessages from './components/ui/pipeline_editor_messages.vue import { COMMIT_SHA_POLL_INTERVAL, EDITOR_APP_STATUS_EMPTY, - EDITOR_APP_STATUS_ERROR, + EDITOR_APP_VALID_STATUSES, EDITOR_APP_STATUS_LOADING, LOAD_FAILURE_UNKNOWN, STARTER_TEMPLATE_NAME, } from './constants'; +import updateAppStatus from './graphql/mutations/update_app_status.mutation.graphql'; import getBlobContent from './graphql/queries/blob_content.graphql'; import getCiConfigData from './graphql/queries/ci_config.graphql'; import getAppStatus from './graphql/queries/client/app_status.graphql'; import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; -import getIsNewCiConfigFile from './graphql/queries/client/is_new_ci_config_file.graphql'; import getTemplate from './graphql/queries/get_starter_template.query.graphql'; import getLatestCommitShaQuery from './graphql/queries/latest_commit_sha.query.graphql'; import PipelineEditorHome from './pipeline_editor_home.vue'; @@ -44,23 +44,23 @@ export default { }, data() { return { - starterTemplateName: STARTER_TEMPLATE_NAME, ciConfigData: {}, + currentCiFileContent: '', failureType: null, failureReasons: [], initialCiFileContent: '', isFetchingCommitSha: false, isNewCiConfigFile: false, lastCommittedContent: '', - currentCiFileContent: '', - successType: null, + shouldSkipStartScreen: false, + showFailure: false, showStartScreen: false, showSuccess: false, - showFailure: false, starterTemplate: '', + starterTemplateName: STARTER_TEMPLATE_NAME, + successType: null, }; }, - apollo: { initialCiFileContent: { fetchPolicy: fetchPolicies.NETWORK_ONLY, @@ -103,7 +103,11 @@ export default { } if (!hasCIFile) { - this.showStartScreen = true; + if (this.shouldSkipStartScreen) { + this.setNewEmptyCiConfigFile(); + } else { + this.showStartScreen = true; + } } else if (fileContent.length) { // If the file content is > 0, then we make sure to reset the // start screen flag during a refetch @@ -141,10 +145,10 @@ export default { return { ...ciConfig, stages }; }, result({ data }) { - this.setAppStatus(data?.ciConfig?.status || EDITOR_APP_STATUS_ERROR); + this.setAppStatus(data?.ciConfig?.status); }, - error() { - this.reportFailure(LOAD_FAILURE_UNKNOWN); + error(err) { + this.reportFailure(LOAD_FAILURE_UNKNOWN, [String(err)]); }, watchLoading(isLoading) { if (isLoading) { @@ -179,9 +183,6 @@ export default { currentBranch: { query: getCurrentBranch, }, - isNewCiConfigFile: { - query: getIsNewCiConfigFile, - }, starterTemplate: { query: getTemplate, variables() { @@ -232,6 +233,7 @@ export default { }, mounted() { this.loadTemplateFromURL(); + this.checkShouldSkipStartScreen(); }, methods: { hideFailure() { @@ -245,8 +247,6 @@ export default { await this.$apollo.queries.initialCiFileContent.refetch(); }, reportFailure(type, reasons = []) { - this.setAppStatus(EDITOR_APP_STATUS_ERROR); - window.scrollTo({ top: 0, behavior: 'smooth' }); this.showFailure = true; this.failureType = type; @@ -261,12 +261,12 @@ export default { this.currentCiFileContent = this.lastCommittedContent; }, setAppStatus(appStatus) { - this.$apollo.getClient().writeQuery({ query: getAppStatus, data: { appStatus } }); + if (EDITOR_APP_VALID_STATUSES.includes(appStatus)) { + this.$apollo.mutate({ mutation: updateAppStatus, variables: { appStatus } }); + } }, setNewEmptyCiConfigFile() { - this.$apollo - .getClient() - .writeQuery({ query: getIsNewCiConfigFile, data: { isNewCiConfigFile: true } }); + this.isNewCiConfigFile = true; this.showStartScreen = false; }, showErrorAlert({ type, reasons = [] }) { @@ -283,9 +283,7 @@ export default { this.reportSuccess(type); if (this.isNewCiConfigFile) { - this.$apollo - .getClient() - .writeQuery({ query: getIsNewCiConfigFile, data: { isNewCiConfigFile: false } }); + this.isNewCiConfigFile = false; } // Keep track of the latest committed content to know @@ -300,6 +298,10 @@ export default { this.setNewEmptyCiConfigFile(); } }, + checkShouldSkipStartScreen() { + const params = queryToObject(window.location.search); + this.shouldSkipStartScreen = Boolean(params?.add_new_config_file); + }, }, }; </script> @@ -325,8 +327,9 @@ export default { <pipeline-editor-home :ci-config-data="ciConfigData" :ci-file-content="currentCiFileContent" - :is-new-ci-config-file="isNewCiConfigFile" :commit-sha="commitSha" + :has-unsaved-changes="hasUnsavedChanges" + :is-new-ci-config-file="isNewCiConfigFile" @commit="updateOnCommit" @resetContent="resetContent" @showError="showErrorAlert" diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue index ba567023946..8e8f31a4acc 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue @@ -1,14 +1,31 @@ <script> +import { GlModal } from '@gitlab/ui'; +import { __ } from '~/locale'; import CommitSection from './components/commit/commit_section.vue'; import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue'; import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue'; import PipelineEditorHeader from './components/header/pipeline_editor_header.vue'; import PipelineEditorTabs from './components/pipeline_editor_tabs.vue'; -import { TABS_WITH_COMMIT_FORM, CREATE_TAB } from './constants'; +import { CREATE_TAB } from './constants'; export default { + commitSectionRef: 'commitSectionRef', + modal: { + switchBranch: { + title: __('You have unsaved changes'), + body: __('Uncommitted changes will be lost if you change branches. Do you want to continue?'), + actionPrimary: { + text: __('Switch Branches'), + }, + actionSecondary: { + text: __('Cancel'), + attributes: { variant: 'default' }, + }, + }, + }, components: { CommitSection, + GlModal, PipelineEditorDrawer, PipelineEditorFileNav, PipelineEditorHeader, @@ -28,6 +45,11 @@ export default { required: false, default: '', }, + hasUnsavedChanges: { + type: Boolean, + required: false, + default: false, + }, isNewCiConfigFile: { type: Boolean, required: true, @@ -36,40 +58,82 @@ export default { data() { return { currentTab: CREATE_TAB, + scrollToCommitForm: false, + shouldLoadNewBranch: false, + showSwitchBranchModal: false, }; }, computed: { showCommitForm() { - return TABS_WITH_COMMIT_FORM.includes(this.currentTab); + return this.currentTab === CREATE_TAB; }, }, methods: { + closeBranchModal() { + this.showSwitchBranchModal = false; + }, + handleConfirmSwitchBranch() { + this.showSwitchBranchModal = true; + }, + switchBranch() { + this.showSwitchBranchModal = false; + this.shouldLoadNewBranch = true; + }, setCurrentTab(tabName) { this.currentTab = tabName; }, + setScrollToCommitForm(newValue = true) { + this.scrollToCommitForm = newValue; + }, }, }; </script> <template> <div class="gl-pr-9 gl-transition-medium gl-w-full"> - <pipeline-editor-file-nav v-on="$listeners" /> + <gl-modal + v-if="showSwitchBranchModal" + visible + modal-id="switchBranchModal" + :title="$options.modal.switchBranch.title" + :action-primary="$options.modal.switchBranch.actionPrimary" + :action-secondary="$options.modal.switchBranch.actionSecondary" + @primary="switchBranch" + @secondary="closeBranchModal" + @cancel="closeBranchModal" + @hide="closeBranchModal" + > + {{ $options.modal.switchBranch.body }} + </gl-modal> + <pipeline-editor-file-nav + :has-unsaved-changes="hasUnsavedChanges" + :should-load-new-branch="shouldLoadNewBranch" + @select-branch="handleConfirmSwitchBranch" + v-on="$listeners" + /> <pipeline-editor-header :ci-config-data="ciConfigData" :commit-sha="commitSha" :is-new-ci-config-file="isNewCiConfigFile" + v-on="$listeners" /> <pipeline-editor-tabs :ci-config-data="ciConfigData" :ci-file-content="ciFileContent" :commit-sha="commitSha" + :is-new-ci-config-file="isNewCiConfigFile" v-on="$listeners" @set-current-tab="setCurrentTab" + @walkthrough-popover-cta-clicked="setScrollToCommitForm" /> <commit-section v-if="showCommitForm" + :ref="$options.commitSectionRef" :ci-file-content="ciFileContent" :commit-sha="commitSha" + :is-new-ci-config-file="isNewCiConfigFile" + :scroll-to-commit-form="scrollToCommitForm" + @scrolled-to-commit-form="setScrollToCommitForm(false)" v-on="$listeners" /> <pipeline-editor-drawer /> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue index b778fe28e59..9725e882d5e 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue @@ -95,9 +95,9 @@ export default { {{ $options.i18n.artifactsFetchErrorMessage }} </gl-alert> - <gl-loading-icon v-if="isLoading" size="sm" /> + <gl-loading-icon v-else-if="isLoading" size="sm" /> - <gl-dropdown-item v-if="!artifacts.length && !isLoading" data-testid="artifacts-empty-message"> + <gl-dropdown-item v-else-if="!artifacts.length" data-testid="artifacts-empty-message"> {{ $options.i18n.emptyArtifactsMessage }} </gl-dropdown-item> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue index 1c7c4d7c704..7d0cea67099 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue @@ -1,20 +1,15 @@ <script> import { - GlAlert, GlDropdown, GlDropdownItem, GlDropdownSectionHeader, - GlLoadingIcon, GlTooltipDirective, } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; -import { __, s__ } from '~/locale'; +import { __ } from '~/locale'; export const i18n = { artifacts: __('Artifacts'), artifactSectionHeader: __('Download artifacts'), - artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'), - noArtifacts: s__('Pipelines|No artifacts available'), }; export default { @@ -23,11 +18,9 @@ export default { GlTooltip: GlTooltipDirective, }, components: { - GlAlert, GlDropdown, GlDropdownItem, GlDropdownSectionHeader, - GlLoadingIcon, }, inject: { artifactsEndpoint: { @@ -42,44 +35,22 @@ export default { type: Number, required: true, }, - }, - data() { - return { - artifacts: [], - hasError: false, - isLoading: false, - }; - }, - computed: { - hasArtifacts() { - return Boolean(this.artifacts.length); + artifacts: { + type: Array, + required: false, + default: () => [], }, }, - methods: { - fetchArtifacts() { - this.isLoading = true; - // Replace the placeholder with the ID of the pipeline we are viewing - const endpoint = this.artifactsEndpoint.replace( - this.artifactsEndpointPlaceholder, - this.pipelineId, - ); - return axios - .get(endpoint) - .then(({ data }) => { - this.artifacts = data.artifacts; - }) - .catch(() => { - this.hasError = true; - }) - .finally(() => { - this.isLoading = false; - }); + computed: { + shouldShowDropdown() { + return this.artifacts?.length; }, }, }; </script> <template> <gl-dropdown + v-if="shouldShowDropdown" v-gl-tooltip class="build-artifacts js-pipeline-dropdown-download" :title="$options.i18n.artifacts" @@ -89,22 +60,11 @@ export default { right lazy text-sr-only - @show.once="fetchArtifacts" > <gl-dropdown-section-header>{{ $options.i18n.artifactSectionHeader }}</gl-dropdown-section-header> - <gl-alert v-if="hasError" variant="danger" :dismissible="false"> - {{ $options.i18n.artifactsFetchErrorMessage }} - </gl-alert> - - <gl-loading-icon v-if="isLoading" size="sm" /> - - <gl-alert v-else-if="!hasArtifacts" variant="info" :dismissible="false"> - {{ $options.i18n.noArtifacts }} - </gl-alert> - <gl-dropdown-item v-for="(artifact, i) in artifacts" :key="i" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue index c6c81d5253b..83f6356f31a 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue @@ -76,7 +76,7 @@ export default { </p> <div class="row gl-mb-8"> - <div class="col-lg-3"> + <div class="col-12"> <gl-card> <div class="gl-flex-direction-row"> <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index 12ee82f0390..d64decc81ec 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -1,5 +1,5 @@ <script> -import { GlTable, GlTooltipDirective } from '@gitlab/ui'; +import { GlTableLite, GlTooltipDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub from '../../event_hub'; import PipelineMiniGraph from './pipeline_mini_graph.vue'; @@ -18,7 +18,7 @@ const DEFAULT_TH_CLASSES = export default { components: { - GlTable, + GlTableLite, LinkedPipelinesMiniList: () => import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), PipelinesCommit, @@ -156,7 +156,7 @@ export default { </script> <template> <div class="ci-table"> - <gl-table + <gl-table-lite :fields="tableFields" :items="pipelines" tbody-tr-class="commit" @@ -225,7 +225,7 @@ export default { <template #cell(actions)="{ item }"> <pipeline-operations :pipeline="item" :canceling-pipeline="cancelingPipeline" /> </template> - </gl-table> + </gl-table-lite> <pipeline-stop-modal :pipeline="pipeline" @submit="onSubmit" /> </div> diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index 5678b613ec6..d123f7a203c 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -49,3 +49,5 @@ export const PipelineKeyOptions = [ key: 'iid', }, ]; + +export const TOAST_MESSAGE = s__('Pipeline|Creating pipeline.'); diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql index de8de651eea..8fcae9dbad8 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql @@ -18,8 +18,11 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { } createdAt user { + id name + username webPath + webUrl email avatarUrl status { diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js index 082d67c938c..3201f88a9e3 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js @@ -4,7 +4,7 @@ import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/commo import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; import { validateParams } from '~/pipelines/utils'; -import { CANCEL_REQUEST } from '../constants'; +import { CANCEL_REQUEST, TOAST_MESSAGE } from '../constants'; import eventHub from '../event_hub'; export default { @@ -191,7 +191,10 @@ export default { this.service .runMRPipeline(options) - .then(() => this.updateTable()) + .then(() => { + this.$toast.show(TOAST_MESSAGE); + this.updateTable(); + }) .catch(() => { createFlash({ message: __( diff --git a/app/assets/javascripts/pipelines/pipeline_shared_client.js b/app/assets/javascripts/pipelines/pipeline_shared_client.js index 7a922acd0b3..c3be487caae 100644 --- a/app/assets/javascripts/pipelines/pipeline_shared_client.js +++ b/app/assets/javascripts/pipelines/pipeline_shared_client.js @@ -5,7 +5,6 @@ export const apolloProvider = new VueApollo({ defaultClient: createDefaultClient( {}, { - assumeImmutableResults: true, useGet: true, }, ), diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue index 17cbcabeedb..3cb2dce87d3 100644 --- a/app/assets/javascripts/profile/account/components/delete_account_modal.vue +++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue @@ -37,7 +37,7 @@ export default { }, primaryProps() { return { - text: s__('Delete account'), + text: __('Delete account'), attributes: [ { variant: 'danger', 'data-qa-selector': 'confirm_delete_account_button' }, { category: 'primary' }, @@ -47,7 +47,7 @@ export default { }, cancelProps() { return { - text: s__('Cancel'), + text: __('Cancel'), }; }, canSubmit() { diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue index 7917a9a75e0..45a6130826d 100644 --- a/app/assets/javascripts/profile/account/components/update_username.vue +++ b/app/assets/javascripts/profile/account/components/update_username.vue @@ -3,7 +3,7 @@ import { GlSafeHtmlDirective as SafeHtml, GlButton, GlModal, GlModalDirective } import { escape } from 'lodash'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { s__, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; export default { components: { @@ -58,7 +58,7 @@ Please update your Git repository remotes as soon as possible.`), }, primaryProps() { return { - text: s__('Update username'), + text: __('Update username'), attributes: [ { variant: 'warning' }, { category: 'primary' }, @@ -68,7 +68,7 @@ Please update your Git repository remotes as soon as possible.`), }, cancelProps() { return { - text: s__('Cancel'), + text: __('Cancel'), }; }, }, diff --git a/app/assets/javascripts/project_visibility.js b/app/assets/javascripts/project_visibility.js index e3868e2925d..1b57a69d464 100644 --- a/app/assets/javascripts/project_visibility.js +++ b/app/assets/javascripts/project_visibility.js @@ -1,42 +1,58 @@ import $ from 'jquery'; +import eventHub from '~/projects/new/event_hub'; -function setVisibilityOptions(namespaceSelector) { - if (!namespaceSelector || !('selectedIndex' in namespaceSelector)) { - return; - } - const selectedNamespace = namespaceSelector.options[namespaceSelector.selectedIndex]; - const { name, visibility, visibilityLevel, showPath, editPath } = selectedNamespace.dataset; +// Values are from lib/gitlab/visibility_level.rb +const visibilityLevel = { + private: 0, + internal: 10, + public: 20, +}; +function setVisibilityOptions({ name, visibility, showPath, editPath }) { document.querySelectorAll('.visibility-level-setting .form-check').forEach((option) => { + // Don't change anything if the option is restricted by admin + if (option.classList.contains('restricted')) { + return; + } + const optionInput = option.querySelector('input[type=radio]'); - const optionValue = optionInput ? optionInput.value : 0; - const optionTitle = option.querySelector('.option-title'); - const optionName = optionTitle ? optionTitle.innerText.toLowerCase() : ''; + const optionValue = optionInput ? parseInt(optionInput.value, 10) : 0; - // don't change anything if the option is restricted by admin - if (!option.classList.contains('restricted')) { - if (visibilityLevel < optionValue) { - option.classList.add('disabled'); - optionInput.disabled = true; - const reason = option.querySelector('.option-disabled-reason'); - if (reason) { - reason.innerHTML = `This project cannot be ${optionName} because the visibility of + if (visibilityLevel[visibility] < optionValue) { + option.classList.add('disabled'); + optionInput.disabled = true; + const reason = option.querySelector('.option-disabled-reason'); + if (reason) { + const optionTitle = option.querySelector('.option-title'); + const optionName = optionTitle ? optionTitle.innerText.toLowerCase() : ''; + reason.innerHTML = `This project cannot be ${optionName} because the visibility of <a href="${showPath}">${name}</a> is ${visibility}. To make this project ${optionName}, you must first <a href="${editPath}">change the visibility</a> of the parent group.`; - } - } else { - option.classList.remove('disabled'); - optionInput.disabled = false; } + } else { + option.classList.remove('disabled'); + optionInput.disabled = false; } }); } +function handleSelect2DropdownChange(namespaceSelector) { + if (!namespaceSelector || !('selectedIndex' in namespaceSelector)) { + return; + } + const selectedNamespace = namespaceSelector.options[namespaceSelector.selectedIndex]; + setVisibilityOptions(selectedNamespace.dataset); +} + export default function initProjectVisibilitySelector() { + eventHub.$on('update-visibility', setVisibilityOptions); + const namespaceSelector = document.querySelector('select.js-select-namespace'); if (namespaceSelector) { - $('.select2.js-select-namespace').on('change', () => setVisibilityOptions(namespaceSelector)); - setVisibilityOptions(namespaceSelector); + $('.select2.js-select-namespace').on('change', () => + handleSelect2DropdownChange(namespaceSelector), + ); + handleSelect2DropdownChange(namespaceSelector); } } diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue index ec7d37644a8..f9dd72119d1 100644 --- a/app/assets/javascripts/projects/commit/components/form_modal.vue +++ b/app/assets/javascripts/projects/commit/components/form_modal.vue @@ -1,6 +1,7 @@ <script> import { GlModal, GlForm, GlFormCheckbox, GlSprintf, GlFormGroup } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; +import api from '~/api'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import csrf from '~/lib/utils/csrf'; import eventHub from '../event_hub'; @@ -40,6 +41,11 @@ export default { required: false, default: false, }, + primaryActionEventName: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -83,6 +89,10 @@ export default { this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, handlePrimary() { + if (this.primaryActionEventName) { + api.trackRedisHllUserEvent(this.primaryActionEventName); + } + this.$refs.form.$el.submit(); }, resetModalHandler() { diff --git a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js index 47ee8237fea..b21fd1a74de 100644 --- a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js +++ b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js @@ -9,7 +9,7 @@ import { } from './constants'; import createStore from './store'; -export default function initInviteMembersModal() { +export default function initInviteMembersModal(primaryActionEventName) { const el = document.querySelector('.js-cherry-pick-commit-modal'); if (!el) { return false; @@ -52,6 +52,7 @@ export default function initInviteMembersModal() { openModal: OPEN_CHERRY_PICK_MODAL, modalId: CHERRY_PICK_MODAL_ID, isCherryPick: true, + primaryActionEventName, }, }), }); diff --git a/app/assets/javascripts/projects/commit/init_revert_commit_modal.js b/app/assets/javascripts/projects/commit/init_revert_commit_modal.js index df26aa3c830..849b2f4858c 100644 --- a/app/assets/javascripts/projects/commit/init_revert_commit_modal.js +++ b/app/assets/javascripts/projects/commit/init_revert_commit_modal.js @@ -10,7 +10,7 @@ import { } from './constants'; import createStore from './store'; -export default function initInviteMembersModal() { +export default function initInviteMembersModal(primaryActionEventName) { const el = document.querySelector('.js-revert-commit-modal'); if (!el) { return false; @@ -49,6 +49,7 @@ export default function initInviteMembersModal() { i18n: { ...I18N_REVERT_MODAL, ...I18N_MODAL }, openModal: OPEN_REVERT_MODAL, modalId: REVERT_MODAL_ID, + primaryActionEventName, }, }), }); diff --git a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js index 2505c47147f..1d4ec4c110b 100644 --- a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js +++ b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js @@ -5,12 +5,7 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - assumeImmutableResults: true, - }, - ), + defaultClient: createDefaultClient(), }); export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipeline-mini-graph') => { diff --git a/app/assets/javascripts/projects/components/project_delete_button.vue b/app/assets/javascripts/projects/components/project_delete_button.vue index 06711e4025a..eaf93e2da4f 100644 --- a/app/assets/javascripts/projects/components/project_delete_button.vue +++ b/app/assets/javascripts/projects/components/project_delete_button.vue @@ -18,12 +18,36 @@ export default { type: String, required: true, }, + isFork: { + type: Boolean, + required: true, + }, + issuesCount: { + type: Number, + required: true, + }, + mergeRequestsCount: { + type: Number, + required: true, + }, + forksCount: { + type: Number, + required: true, + }, + starsCount: { + type: Number, + required: true, + }, }, strings: { alertTitle: __('You are about to permanently delete this project'), alertBody: __( - 'Once a project is permanently deleted, it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd}, including issues, merge requests etc.', + 'After a project is permanently deleted, it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd}, including issues, merge requests etc.', + ), + isNotForkMessage: __( + 'This project is %{strongStart}NOT%{strongEnd} a fork, and has the following:', ), + isForkMessage: __('This forked project has the following:'), }, }; </script> @@ -37,6 +61,38 @@ export default { :title="$options.strings.alertTitle" :dismissible="false" > + <p> + <gl-sprintf v-if="isFork" :message="$options.strings.isForkMessage" /> + <gl-sprintf v-else :message="$options.strings.isNotForkMessage"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> + <ul> + <li> + <gl-sprintf :message="n__('%d issue', '%d issues', issuesCount)"> + <template #issuesCount>{{ issuesCount }}</template> + </gl-sprintf> + </li> + <li> + <gl-sprintf + :message="n__('%d merge requests', '%d merge requests', mergeRequestsCount)" + > + <template #mergeRequestsCount>{{ mergeRequestsCount }}</template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="n__('%d fork', '%d forks', forksCount)"> + <template #forksCount>{{ forksCount }}</template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="n__('%d star', '%d stars', starsCount)"> + <template #starsCount>{{ starsCount }}</template> + </gl-sprintf> + </li> + </ul> <gl-sprintf :message="$options.strings.alertBody"> <template #strong="{ content }"> <strong>{{ content }}</strong> diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js index 2da9449d24e..0393d82ca36 100644 --- a/app/assets/javascripts/projects/default_project_templates.js +++ b/app/assets/javascripts/projects/default_project_templates.js @@ -93,6 +93,10 @@ export default { text: s__('ProjectTemplates|Serverless Framework/JS'), icon: '.template-option .icon-serverless_framework', }, + tencent_serverless_framework: { + text: s__('ProjectTemplates|Tencent Serverless Framework/NextjsSSR'), + icon: '.template-option .icon-tencent_serverless_framework', + }, cluster_management: { text: s__('ProjectTemplates|GitLab Cluster Management'), icon: '.template-option .icon-cluster_management', diff --git a/app/assets/javascripts/projects/details/upload_button.vue b/app/assets/javascripts/projects/details/upload_button.vue index 5b19f15c233..e1c8c66a214 100644 --- a/app/assets/javascripts/projects/details/upload_button.vue +++ b/app/assets/javascripts/projects/details/upload_button.vue @@ -1,7 +1,6 @@ <script> import { GlButton, GlModalDirective } from '@gitlab/ui'; import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; -import { trackFileUploadEvent } from '../upload_file_experiment_tracking'; const UPLOAD_BLOB_MODAL_ID = 'details-modal-upload-blob'; @@ -30,11 +29,6 @@ export default { default: '', }, }, - methods: { - trackOpenModal() { - trackFileUploadEvent('click_upload_modal_trigger'); - }, - }, uploadBlobModalId: UPLOAD_BLOB_MODAL_ID, }; </script> @@ -44,7 +38,6 @@ export default { v-gl-modal="$options.uploadBlobModalId" icon="upload" data-testid="upload-file-button" - @click="trackOpenModal" >{{ __('Upload File') }}</gl-button > <upload-blob-modal diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue index 6e9efc50be8..476d6466cbb 100644 --- a/app/assets/javascripts/projects/new/components/app.vue +++ b/app/assets/javascripts/projects/new/components/app.vue @@ -95,7 +95,7 @@ export default { <template> <new-namespace-page - :initial-breadcrumb="s__('New project')" + :initial-breadcrumb="__('New project')" :panels="availablePanels" :jump-to-last-persisted-panel="hasErrors" :title="s__('ProjectsNew|Create new project')" diff --git a/app/assets/javascripts/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/projects/new/components/new_project_url_select.vue index bf44ff70562..e0ba60074af 100644 --- a/app/assets/javascripts/projects/new/components/new_project_url_select.vue +++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue @@ -6,9 +6,9 @@ import { GlDropdownItem, GlDropdownText, GlDropdownSectionHeader, - GlLoadingIcon, GlSearchBoxByType, } from '@gitlab/ui'; +import { joinPaths } from '~/lib/utils/url_utility'; import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import Tracking from '~/tracking'; @@ -24,7 +24,6 @@ export default { GlDropdownItem, GlDropdownText, GlDropdownSectionHeader, - GlLoadingIcon, GlSearchBoxByType, }, mixins: [Tracking.mixin()], @@ -103,6 +102,15 @@ export default { focusInput() { this.$refs.search.focusInput(); }, + handleDropdownItemClick(namespace) { + eventHub.$emit('update-visibility', { + name: namespace.name, + visibility: namespace.visibility, + showPath: namespace.webUrl, + editPath: joinPaths(namespace.webUrl, '-', 'edit'), + }); + this.setNamespace(namespace); + }, handleSelectTemplate(groupId) { this.groupToFilterBy = this.userGroups.find( (group) => getIdFromGraphQLId(group.id) === groupId, @@ -134,23 +142,23 @@ export default { <gl-search-box-by-type ref="search" v-model.trim="search" + :is-loading="$apollo.queries.currentUser.loading" data-qa-selector="select_namespace_dropdown_search_field" /> - <gl-loading-icon v-if="$apollo.queries.currentUser.loading" /> - <template v-else> + <template v-if="!$apollo.queries.currentUser.loading"> <template v-if="hasGroupMatches"> <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header> <gl-dropdown-item v-for="group of filteredGroups" :key="group.id" - @click="setNamespace(group)" + @click="handleDropdownItemClick(group)" > {{ group.fullPath }} </gl-dropdown-item> </template> <template v-if="hasNamespaceMatches"> <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header> - <gl-dropdown-item @click="setNamespace(userNamespace)"> + <gl-dropdown-item @click="handleDropdownItemClick(userNamespace)"> {{ userNamespace.fullPath }} </gl-dropdown-item> </template> @@ -158,6 +166,11 @@ export default { </template> </gl-dropdown> - <input type="hidden" name="project[namespace_id]" :value="selectedNamespace.id" /> + <input + id="project_namespace_id" + type="hidden" + name="project[namespace_id]" + :value="selectedNamespace.id" + /> </gl-button-group> </template> diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js index 572d3276e4f..010c6a29ae3 100644 --- a/app/assets/javascripts/projects/new/index.js +++ b/app/assets/javascripts/projects/new/index.js @@ -50,7 +50,7 @@ export function initNewProjectUrlSelect() { new Vue({ el, apolloProvider: new VueApollo({ - defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), + defaultClient: createDefaultClient(), }), provide: { namespaceFullPath: el.dataset.namespaceFullPath, diff --git a/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql index e16fe5dde49..74febec5a51 100644 --- a/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql +++ b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql @@ -4,6 +4,9 @@ query searchNamespacesWhereUserCanCreateProjects($search: String) { nodes { id fullPath + name + visibility + webUrl } } namespace { diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue index 25bacc1cc4a..7379d5caed7 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue @@ -11,12 +11,17 @@ export default { DeploymentFrequencyCharts: () => import('ee_component/dora/components/deployment_frequency_charts.vue'), LeadTimeCharts: () => import('ee_component/dora/components/lead_time_charts.vue'), + ProjectQualitySummary: () => import('ee_component/project_quality_summary/app.vue'), }, inject: { shouldRenderDoraCharts: { type: Boolean, default: false, }, + shouldRenderQualitySummary: { + type: Boolean, + default: false, + }, }, data() { return { @@ -31,6 +36,10 @@ export default { chartsToShow.push('deployment-frequency', 'lead-time'); } + if (this.shouldRenderQualitySummary) { + chartsToShow.push('project-quality'); + } + return chartsToShow; }, }, @@ -68,6 +77,9 @@ export default { <lead-time-charts /> </gl-tab> </template> + <gl-tab v-if="shouldRenderQualitySummary" :title="s__('QualitySummary|Project quality')"> + <project-quality-summary /> + </gl-tab> </gl-tabs> <pipeline-charts v-else /> </div> diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js index f7ea89068a0..003b61d94b1 100644 --- a/app/assets/javascripts/projects/pipelines/charts/index.js +++ b/app/assets/javascripts/projects/pipelines/charts/index.js @@ -7,13 +7,14 @@ import ProjectPipelinesCharts from './components/app.vue'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), + defaultClient: createDefaultClient(), }); const mountPipelineChartsApp = (el) => { const { projectPath } = el.dataset; const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts); + const shouldRenderQualitySummary = parseBoolean(el.dataset.shouldRenderQualitySummary); return new Vue({ el, @@ -25,6 +26,7 @@ const mountPipelineChartsApp = (el) => { provide: { projectPath, shouldRenderDoraCharts, + shouldRenderQualitySummary, }, render: (createElement) => createElement(ProjectPipelinesCharts, {}), }); diff --git a/app/assets/javascripts/projects/project_delete_button.js b/app/assets/javascripts/projects/project_delete_button.js index aa7fc31d307..b4d388eda3a 100644 --- a/app/assets/javascripts/projects/project_delete_button.js +++ b/app/assets/javascripts/projects/project_delete_button.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import ProjectDeleteButton from './components/project_delete_button.vue'; export default (selector = '#js-project-delete-button') => { @@ -6,7 +7,15 @@ export default (selector = '#js-project-delete-button') => { if (!el) return; - const { confirmPhrase, formPath } = el.dataset; + const { + confirmPhrase, + formPath, + isFork, + issuesCount, + mergeRequestsCount, + forksCount, + starsCount, + } = el.dataset; // eslint-disable-next-line no-new new Vue({ @@ -16,6 +25,11 @@ export default (selector = '#js-project-delete-button') => { props: { confirmPhrase, formPath, + isFork: parseBoolean(isFork), + issuesCount: parseInt(issuesCount, 10), + mergeRequestsCount: parseInt(mergeRequestsCount, 10), + forksCount: parseInt(forksCount, 10), + starsCount: parseInt(starsCount, 10), }, }); }, diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index b350db0c838..8d71a3dab68 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -43,6 +43,8 @@ const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingPr }; const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => { + const specialRepo = document.querySelector('.js-user-readme-repo'); + // eslint-disable-next-line @gitlab/no-global-event-off $projectNameInput.off('keyup change').on('keyup change', () => { onProjectNameChange($projectNameInput, $projectPathInput); @@ -54,6 +56,11 @@ const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => { $projectPathInput.off('keyup change').on('keyup change', () => { onProjectPathChange($projectNameInput, $projectPathInput, hasUserDefinedProjectName); hasUserDefinedProjectPath = $projectPathInput.val().trim().length > 0; + + specialRepo.classList.toggle( + 'gl-display-none', + $projectPathInput.val() !== $projectPathInput.data('username'), + ); }); }; diff --git a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue new file mode 100644 index 00000000000..e8b0e95b142 --- /dev/null +++ b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue @@ -0,0 +1,92 @@ +<script> +import { GlTokenSelector, GlAvatarLabeled } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import searchProjectTopics from '../queries/project_topics_search.query.graphql'; + +export default { + components: { + GlTokenSelector, + GlAvatarLabeled, + }, + i18n: { + placeholder: s__('ProjectSettings|Search for topic'), + }, + props: { + selected: { + type: Array, + required: false, + default: () => [], + }, + }, + apollo: { + topics: { + query: searchProjectTopics, + variables() { + return { + search: this.search, + }; + }, + update(data) { + return ( + data.topics?.nodes.filter( + (topic) => !this.selectedTokens.some((token) => token.name === topic.name), + ) || [] + ); + }, + debounce: 250, + }, + }, + data() { + return { + topics: [], + selectedTokens: this.selected, + search: '', + }; + }, + computed: { + loading() { + return this.$apollo.queries.topics.loading; + }, + placeholderText() { + return this.selectedTokens.length ? '' : this.$options.i18n.placeholder; + }, + }, + methods: { + handleEnter(event) { + // Prevent form from submitting when adding a token + if (event.target.value !== '') { + event.preventDefault(); + } + }, + filterTopics(searchTerm) { + this.search = searchTerm; + }, + onTokensUpdate(tokens) { + this.$emit('update', tokens); + }, + }, +}; +</script> +<template> + <gl-token-selector + ref="tokenSelector" + v-model="selectedTokens" + :dropdown-items="topics" + :loading="loading" + allow-user-defined-tokens + :placeholder="placeholderText" + @keydown.enter="handleEnter" + @text-input="filterTopics" + @input="onTokensUpdate" + > + <template #dropdown-item-content="{ dropdownItem }"> + <gl-avatar-labeled + :src="dropdownItem.avatarUrl" + :entity-name="dropdownItem.name" + :label="dropdownItem.name" + :size="32" + shape="rect" + /> + </template> + </gl-token-selector> +</template> diff --git a/app/assets/javascripts/projects/settings/topics/index.js b/app/assets/javascripts/projects/settings/topics/index.js new file mode 100644 index 00000000000..3fbd1a61abe --- /dev/null +++ b/app/assets/javascripts/projects/settings/topics/index.js @@ -0,0 +1,51 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import TopicsTokenSelector from './components/topics_token_selector.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export default () => { + const el = document.querySelector('.js-topics-selector'); + + if (!el) return null; + + const { hiddenInputId } = el.dataset; + const hiddenInput = document.getElementById(hiddenInputId); + + const selected = hiddenInput.value + ? hiddenInput.value.split(/,\s*/).map((token, index) => ({ + id: index, + name: token, + })) + : []; + + return new Vue({ + el, + apolloProvider, + render(createElement) { + return createElement(TopicsTokenSelector, { + props: { + selected, + }, + on: { + update(tokens) { + const value = tokens.map(({ name }) => name).join(', '); + hiddenInput.value = value; + // Dispatch `input` event so form submit button becomes active + hiddenInput.dispatchEvent( + new Event('input', { + bubbles: true, + cancelable: true, + }), + ); + }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql b/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql new file mode 100644 index 00000000000..b193165062a --- /dev/null +++ b/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql @@ -0,0 +1,9 @@ +query searchProjectTopics($search: String) { + topics(search: $search) { + nodes { + id + name + avatarUrl + } + } +} diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue index 4c083ed5496..14c8c53dd19 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -31,6 +31,9 @@ export default { selectedTemplate: { default: '', }, + selectedFileTemplateProjectId: { + default: null, + }, outgoingName: { default: '', }, @@ -80,7 +83,7 @@ export default { }); }, - onSaveTemplate({ selectedTemplate, outgoingName, projectKey }) { + onSaveTemplate({ selectedTemplate, fileTemplateProjectId, outgoingName, projectKey }) { this.isTemplateSaving = true; const body = { @@ -88,6 +91,7 @@ export default { outgoing_name: outgoingName, project_key: projectKey, service_desk_enabled: this.isEnabled, + file_template_project_id: fileTemplateProjectId, }; return axios @@ -132,6 +136,7 @@ export default { :custom-email="updatedCustomEmail" :custom-email-enabled="customEmailEnabled" :initial-selected-template="selectedTemplate" + :initial-selected-file-template-project-id="selectedFileTemplateProjectId" :initial-outgoing-name="outgoingName" :initial-project-key="projectKey" :templates="templates" diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue index fe2d376f1da..b8053bf9ab5 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -1,15 +1,8 @@ <script> -import { - GlButton, - GlFormSelect, - GlToggle, - GlLoadingIcon, - GlSprintf, - GlFormInput, - GlLink, -} from '@gitlab/ui'; +import { GlButton, GlToggle, GlLoadingIcon, GlSprintf, GlFormInput, GlLink } from '@gitlab/ui'; import { __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ServiceDeskTemplateDropdown from './service_desk_template_dropdown.vue'; export default { i18n: { @@ -18,12 +11,12 @@ export default { components: { ClipboardButton, GlButton, - GlFormSelect, GlToggle, GlLoadingIcon, GlSprintf, GlFormInput, GlLink, + ServiceDeskTemplateDropdown, }, props: { isEnabled: { @@ -49,6 +42,11 @@ export default { required: false, default: '', }, + initialSelectedFileTemplateProjectId: { + type: Number, + required: false, + default: null, + }, initialOutgoingName: { type: String, required: false, @@ -73,14 +71,14 @@ export default { data() { return { selectedTemplate: this.initialSelectedTemplate, + selectedFileTemplateProjectId: this.initialSelectedFileTemplateProjectId, outgoingName: this.initialOutgoingName || __('GitLab Support Bot'), projectKey: this.initialProjectKey, + searchTerm: '', + projectKeyError: null, }; }, computed: { - templateOptions() { - return [''].concat(this.templates); - }, hasProjectKeySupport() { return Boolean(this.customEmailEnabled); }, @@ -100,8 +98,21 @@ export default { selectedTemplate: this.selectedTemplate, outgoingName: this.outgoingName, projectKey: this.projectKey, + fileTemplateProjectId: this.selectedFileTemplateProjectId, }); }, + templateChange({ selectedFileTemplateProjectId, selectedTemplate }) { + this.selectedFileTemplateProjectId = selectedFileTemplateProjectId; + this.selectedTemplate = selectedTemplate; + }, + validateProjectKey() { + if (this.projectKey && !new RegExp(/^[a-z0-9_]+$/).test(this.projectKey)) { + this.projectKeyError = __('Only use lowercase letters, numbers, and underscores.'); + return; + } + + this.projectKeyError = null; + }, }, }; </script> @@ -167,8 +178,17 @@ export default { v-model.trim="projectKey" data-testid="project-suffix" class="form-control" + :state="!projectKeyError" + @blur="validateProjectKey" /> - <span v-if="hasProjectKeySupport" class="form-text text-muted"> + <span v-if="hasProjectKeySupport && projectKeyError" class="form-text text-danger"> + {{ projectKeyError }} + </span> + <span + v-if="hasProjectKeySupport" + class="form-text text-muted" + :class="{ 'gl-mt-2!': hasProjectKeySupport && projectKeyError }" + > {{ __('A string appended to the project path to form the Service Desk email address.') }} </span> <span v-else class="form-text text-muted"> @@ -193,12 +213,13 @@ export default { <label for="service-desk-template-select" class="mt-3"> {{ __('Template to append to all Service Desk issues') }} </label> - <gl-form-select - id="service-desk-template-select" - v-model="selectedTemplate" - data-qa-selector="service_desk_template_dropdown" - :options="templateOptions" + <service-desk-template-dropdown + :selected-template="selectedTemplate" + :selected-file-template-project-id="selectedFileTemplateProjectId" + :templates="templates" + @change="templateChange" /> + <label for="service-desk-email-from-name" class="mt-3"> {{ __('Email display name') }} </label> @@ -210,6 +231,7 @@ export default { <gl-button variant="success" class="gl-mt-5" + data-testid="save_service_desk_settings_button" data-qa-selector="save_service_desk_settings_button" :disabled="isTemplateSaving" @click="onSaveTemplate" diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue new file mode 100644 index 00000000000..bdd9f940d79 --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue @@ -0,0 +1,115 @@ +<script> +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { GlDropdown, GlDropdownSectionHeader, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlDropdown, + GlDropdownSectionHeader, + GlDropdownItem, + GlSearchBoxByType, + }, + props: { + selectedTemplate: { + type: String, + required: false, + default: '', + }, + templates: { + type: Array, + required: true, + }, + selectedFileTemplateProjectId: { + type: Number, + required: false, + default: null, + }, + }, + data() { + return { + searchTerm: '', + }; + }, + computed: { + templateOptions() { + if (this.searchTerm) { + const filteredTemplates = []; + for (let i = 0; i < this.templates.length; i += 2) { + const sectionName = this.templates[i]; + const availableTemplates = this.templates[i + 1]; + + const matchedTemplates = fuzzaldrinPlus.filter(availableTemplates, this.searchTerm, { + key: 'name', + }); + + if (matchedTemplates.length > 0) { + filteredTemplates.push(sectionName, matchedTemplates); + } + } + + return filteredTemplates; + } + + return this.templates; + }, + }, + methods: { + templateClick(template) { + // Clicking on the same template should unselect it + if ( + template.name === this.selectedTemplate && + template.project_id === this.selectedFileTemplateProjectId + ) { + this.$emit('change', { + selectedFileTemplateProjectId: null, + selectedTemplate: null, + }); + return; + } + + this.$emit('change', { + selectedFileTemplateProjectId: template.project_id, + selectedTemplate: template.key, + }); + }, + }, + i18n: { + defaultDropdownText: __('Choose a template'), + }, +}; +</script> +<template> + <gl-dropdown + id="service-desk-template-select" + :text="selectedTemplate || $options.i18n.defaultDropdownText" + :header-text="$options.i18n.defaultDropdownText" + data-qa-selector="service_desk_template_dropdown" + :block="true" + class="service-desk-template-select" + toggle-class="gl-m-0" + > + <template #header> + <gl-search-box-by-type v-model.trim="searchTerm" /> + </template> + <template v-for="item in templateOptions"> + <gl-dropdown-section-header v-if="!Array.isArray(item)" :key="item"> + {{ item }} + </gl-dropdown-section-header> + <template v-else> + <gl-dropdown-item + v-for="template in item" + :key="template.key" + :is-check-item="true" + :is-checked=" + template.project_id === selectedFileTemplateProjectId && + template.name === selectedTemplate + " + @click="() => templateClick(template)" + > + {{ template.name }} + </gl-dropdown-item> + </template> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js index f842ffaaa2b..e14cdee17ce 100644 --- a/app/assets/javascripts/projects/settings_service_desk/index.js +++ b/app/assets/javascripts/projects/settings_service_desk/index.js @@ -18,6 +18,7 @@ export default () => { outgoingName, projectKey, selectedTemplate, + selectedFileTemplateProjectId, templates, } = el.dataset; @@ -32,6 +33,7 @@ export default () => { outgoingName, projectKey, selectedTemplate, + selectedFileTemplateProjectId: parseInt(selectedFileTemplateProjectId, 10) || null, templates: JSON.parse(templates), }, render: (createElement) => createElement(ServiceDeskRoot), diff --git a/app/assets/javascripts/projects/storage_counter/components/storage_table.vue b/app/assets/javascripts/projects/storage_counter/components/storage_table.vue index 7047fd925fb..a42a9711572 100644 --- a/app/assets/javascripts/projects/storage_counter/components/storage_table.vue +++ b/app/assets/javascripts/projects/storage_counter/components/storage_table.vue @@ -1,9 +1,10 @@ <script> -import { GlLink, GlIcon, GlTable, GlSprintf } from '@gitlab/ui'; +import { GlLink, GlIcon, GlTableLite as GlTable, GlSprintf } from '@gitlab/ui'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { thWidthClass } from '~/lib/utils/table_utility'; import { sprintf } from '~/locale'; import { PROJECT_TABLE_LABELS, HELP_LINK_ARIA_LABEL } from '../constants'; +import StorageTypeIcon from './storage_type_icon.vue'; export default { name: 'StorageTable', @@ -12,6 +13,7 @@ export default { GlIcon, GlTable, GlSprintf, + StorageTypeIcon, }, props: { storageTypes: { @@ -48,31 +50,39 @@ export default { <template> <gl-table :items="storageTypes" :fields="$options.projectTableFields"> <template #cell(storageType)="{ item }"> - <p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.storageType.id}-name`"> - {{ item.storageType.name }} - <gl-link - v-if="item.storageType.helpPath" - :href="item.storageType.helpPath" - target="_blank" - :aria-label="helpLinkAriaLabel(item.storageType.name)" - :data-testid="`${item.storageType.id}-help-link`" - > - <gl-icon name="question" :size="12" /> - </gl-link> - </p> - <p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`"> - {{ item.storageType.description }} - </p> - <p v-if="item.storageType.warningMessage" class="gl-mb-0 gl-font-sm"> - <gl-icon name="warning" :size="12" /> - <gl-sprintf :message="item.storageType.warningMessage"> - <template #warningLink="{ content }"> - <gl-link :href="item.storageType.warningLink" target="_blank" class="gl-font-sm">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </p> + <div class="gl-display-flex gl-flex-direction-row"> + <storage-type-icon + :name="item.storageType.id" + :data-testid="`${item.storageType.id}-icon`" + /> + <div> + <p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.storageType.id}-name`"> + {{ item.storageType.name }} + <gl-link + v-if="item.storageType.helpPath" + :href="item.storageType.helpPath" + target="_blank" + :aria-label="helpLinkAriaLabel(item.storageType.name)" + :data-testid="`${item.storageType.id}-help-link`" + > + <gl-icon name="question" :size="12" /> + </gl-link> + </p> + <p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`"> + {{ item.storageType.description }} + </p> + <p v-if="item.storageType.warningMessage" class="gl-mb-0 gl-font-sm"> + <gl-icon name="warning" :size="12" /> + <gl-sprintf :message="item.storageType.warningMessage"> + <template #warningLink="{ content }"> + <gl-link :href="item.storageType.warningLink" target="_blank" class="gl-font-sm">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> + </div> </template> </gl-table> </template> diff --git a/app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue b/app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue new file mode 100644 index 00000000000..bc7cd42df1e --- /dev/null +++ b/app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue @@ -0,0 +1,35 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +export default { + components: { GlIcon }, + props: { + name: { + type: String, + required: false, + default: '', + }, + }, + methods: { + iconName(storageTypeName) { + const defaultStorageTypeIcon = 'disk'; + const storageTypeIconMap = { + lfsObjectsSize: 'doc-image', + snippetsSize: 'snippet', + uploadsSize: 'upload', + repositorySize: 'infrastructure-registry', + packagesSize: 'package', + }; + + return storageTypeIconMap[`${storageTypeName}`] ?? defaultStorageTypeIcon; + }, + }, +}; +</script> +<template> + <span + class="gl-display-inline-flex gl-align-items-flex-start gl-justify-content-center gl-min-w-8 gl-pr-2 gl-pt-1" + > + <gl-icon :name="iconName(name)" :size="16" class="gl-mt-1" /> + </span> +</template> diff --git a/app/assets/javascripts/projects/storage_counter/constants.js b/app/assets/javascripts/projects/storage_counter/constants.js index d9b28abfbe7..df4b1800dff 100644 --- a/app/assets/javascripts/projects/storage_counter/constants.js +++ b/app/assets/javascripts/projects/storage_counter/constants.js @@ -6,13 +6,13 @@ export const PROJECT_STORAGE_TYPES = [ name: s__('UsageQuota|Artifacts'), description: s__('UsageQuota|Pipeline artifacts and job artifacts, created with CI/CD.'), warningMessage: s__( - 'UsageQuota|There is a known issue with Artifact storage where the total could be incorrect for some projects. More details and progress are available in %{warningLinkStart}the epic%{warningLinkEnd}.', + 'UsageQuota|Because of a known issue, the artifact total for some projects may be incorrect. For more details, read %{warningLinkStart}the epic%{warningLinkEnd}.', ), warningLink: 'https://gitlab.com/groups/gitlab-org/-/epics/5380', }, { id: 'lfsObjectsSize', - name: s__('UsageQuota|LFS Storage'), + name: s__('UsageQuota|LFS storage'), description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'), }, { @@ -23,7 +23,7 @@ export const PROJECT_STORAGE_TYPES = [ { id: 'repositorySize', name: s__('UsageQuota|Repository'), - description: s__('UsageQuota|Git repository, managed by the Gitaly service.'), + description: s__('UsageQuota|Git repository.'), }, { id: 'snippetsSize', @@ -51,11 +51,11 @@ export const ERROR_MESSAGE = s__( 'UsageQuota|Something went wrong while fetching project storage statistics', ); -export const LEARN_MORE_LABEL = s__('Learn more.'); +export const LEARN_MORE_LABEL = __('Learn more.'); export const USAGE_QUOTAS_LABEL = s__('UsageQuota|Usage Quotas'); export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link'); export const TOTAL_USAGE_DEFAULT_TEXT = __('N/A'); -export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage Breakdown'); +export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage breakdown'); export const TOTAL_USAGE_SUBTITLE = s__( - 'UsageQuota|Includes project registry, artifacts, packages, wiki, uploads and other items.', + 'UsageQuota|Includes artifacts, repositories, wiki, uploads, and other items.', ); diff --git a/app/assets/javascripts/projects/storage_counter/index.js b/app/assets/javascripts/projects/storage_counter/index.js index 10668f08402..15796bc1870 100644 --- a/app/assets/javascripts/projects/storage_counter/index.js +++ b/app/assets/javascripts/projects/storage_counter/index.js @@ -25,7 +25,7 @@ export default (containerId = 'js-project-storage-count-app') => { } = el.dataset; const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), + defaultClient: createDefaultClient(), }); return new Vue({ diff --git a/app/assets/javascripts/projects/storage_counter/utils.js b/app/assets/javascripts/projects/storage_counter/utils.js index cb26603fff5..9fca9d88f46 100644 --- a/app/assets/javascripts/projects/storage_counter/utils.js +++ b/app/assets/javascripts/projects/storage_counter/utils.js @@ -14,10 +14,6 @@ export const parseGetProjectStorageResults = (data, helpLinks) => { } const { storageSize, ...storageStatistics } = projectStatistics; const storageTypes = PROJECT_STORAGE_TYPES.reduce((types, currentType) => { - if (!storageStatistics[currentType.id]) { - return types; - } - const helpPathKey = currentType.id.replace(`Size`, `HelpPagePath`); const helpPath = helpLinks[helpPathKey]; diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue index f6f409873c8..a79da00de43 100644 --- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue +++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue @@ -58,7 +58,7 @@ export default { }; this.isLoading = false; createFlash({ - message: s__('Something went wrong on our end'), + message: __('Something went wrong on our end'), }); }, initPolling() { diff --git a/app/assets/javascripts/projects/upload_file.js b/app/assets/javascripts/projects/upload_file.js new file mode 100644 index 00000000000..597965eabfc --- /dev/null +++ b/app/assets/javascripts/projects/upload_file.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import createRouter from '~/repository/router'; +import UploadButton from './details/upload_button.vue'; + +export const initUploadFileTrigger = () => { + const uploadFileTriggerEl = document.querySelector('.js-upload-file-trigger'); + + if (!uploadFileTriggerEl) return false; + + const { + targetBranch, + originalBranch, + canPushCode, + path, + projectPath, + } = uploadFileTriggerEl.dataset; + + return new Vue({ + el: uploadFileTriggerEl, + router: createRouter(projectPath, originalBranch), + provide: { + targetBranch, + originalBranch, + canPushCode: parseBoolean(canPushCode), + path, + projectPath, + }, + render(h) { + return h(UploadButton); + }, + }); +}; diff --git a/app/assets/javascripts/projects/upload_file_experiment.js b/app/assets/javascripts/projects/upload_file_experiment.js deleted file mode 100644 index a7519f2bce8..00000000000 --- a/app/assets/javascripts/projects/upload_file_experiment.js +++ /dev/null @@ -1,33 +0,0 @@ -import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import createRouter from '~/repository/router'; -import UploadButton from './details/upload_button.vue'; - -export const initUploadFileTrigger = () => { - const uploadFileTriggerEl = document.querySelector('.js-upload-file-experiment-trigger'); - - if (!uploadFileTriggerEl) return false; - - const { - targetBranch, - originalBranch, - canPushCode, - path, - projectPath, - } = uploadFileTriggerEl.dataset; - - return new Vue({ - el: uploadFileTriggerEl, - router: createRouter(projectPath, originalBranch), - provide: { - targetBranch, - originalBranch, - canPushCode: parseBoolean(canPushCode), - path, - projectPath, - }, - render(h) { - return h(UploadButton); - }, - }); -}; diff --git a/app/assets/javascripts/projects/upload_file_experiment_tracking.js b/app/assets/javascripts/projects/upload_file_experiment_tracking.js deleted file mode 100644 index c5e93f19b32..00000000000 --- a/app/assets/javascripts/projects/upload_file_experiment_tracking.js +++ /dev/null @@ -1,9 +0,0 @@ -import ExperimentTracking from '~/experimentation/experiment_tracking'; - -export const trackFileUploadEvent = (eventName) => { - const isEmpty = Boolean(document.querySelector('.project-home-panel.empty-project')); - const property = isEmpty ? 'empty' : 'nonempty'; - const label = 'blob-upload-modal'; - const FileUploadTracking = new ExperimentTracking('empty_repo_upload', { label, property }); - FileUploadTracking.event(eventName); -}; diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js index 1cef986a83d..397e3ed2ac8 100644 --- a/app/assets/javascripts/ref/constants.js +++ b/app/assets/javascripts/ref/constants.js @@ -15,9 +15,9 @@ export const DEFAULT_I18N = Object.freeze({ searchPlaceholder: __('Search by Git revision'), noResultsWithQuery: __('No matching results for "%{query}"'), noResults: __('No matching results'), - branchesErrorMessage: __('An error occurred while fetching branches. Retry the search.'), + branchesErrorMessage: __('An error occurred while fetching branches. Retry the search.'), tagsErrorMessage: __('An error occurred while fetching tags. Retry the search.'), - commitsErrorMessage: __('An error occurred while fetching commits. Retry the search.'), + commitsErrorMessage: __('An error occurred while fetching commits. Retry the search.'), branches: __('Branches'), tags: __('Tags'), commits: __('Commits'), diff --git a/app/assets/javascripts/registry/explorer/graphql/index.js b/app/assets/javascripts/registry/explorer/graphql/index.js deleted file mode 100644 index d934bcc7419..00000000000 --- a/app/assets/javascripts/registry/explorer/graphql/index.js +++ /dev/null @@ -1,15 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; - -Vue.use(VueApollo); - -export const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - batchMax: 1, - assumeImmutableResults: true, - }, - ), -}); diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue index 05858c7469d..50835142d28 100644 --- a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue +++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue @@ -1,7 +1,7 @@ <script> import { GlLink, GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; -import { sprintf, n__, s__ } from '~/locale'; +import { sprintf, __, n__ } from '~/locale'; import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; import { parseIssuableData } from '../../issue_show/utils/parse_data'; @@ -40,7 +40,7 @@ export default { this.totalCount, ); - return sprintf(s__('%{mrText}, this issue will be closed automatically.'), { mrText }); + return sprintf(__('%{mrText}, this issue will be closed automatically.'), { mrText }); }, }, mounted() { @@ -64,58 +64,51 @@ export default { </script> <template> - <div - v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)" - id="related-merge-requests" - > - <div id="merge-requests" class="card card-slim mt-3"> + <div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)"> + <div class="card card-slim gl-mt-5"> <div class="card-header"> - <div class="card-title mt-0 mb-0 h5 merge-requests-title position-relative"> + <div + class="card-title gl-relative gl-display-flex gl-align-items-center gl-line-height-20 gl-font-weight-bold gl-m-0" + > <gl-link - id="user-content-related-merge-requests" - class="anchor position-absolute text-decoration-none" + class="anchor gl-absolute gl-text-decoration-none" href="#related-merge-requests" - aria-hidden="true" + aria-labelledby="related-merge-requests" /> - <span class="mr-1"> + <h3 id="related-merge-requests" class="gl-font-base gl-m-0"> {{ __('Related merge requests') }} - </span> - <div v-if="totalCount" class="d-inline-flex lh-100 align-middle"> - <div - class="mr-count-badge gl-display-inline-flex gl-align-items-center gl-py-2 gl-px-3" - > - <svg class="s16 mr-1 text-secondary"> - <gl-icon name="merge-request" class="mr-1 text-secondary" /> - </svg> - <span class="js-items-count">{{ totalCount }}</span> - </div> - </div> + </h3> + <template v-if="totalCount"> + <gl-icon name="merge-request" class="gl-ml-5 gl-mr-2 gl-text-gray-500" /> + <span data-testid="count">{{ totalCount }}</span> + </template> </div> </div> - <div> - <div v-if="isFetchingMergeRequests" class="qa-related-merge-requests-loading-icon"> - <gl-loading-icon size="sm" label="Fetching related merge requests" class="py-2" /> - </div> - <ul v-else class="content-list related-items-list"> - <li v-for="mr in mergeRequests" :key="mr.id" class="list-item pt-0 pb-0"> - <related-issuable-item - :id-key="mr.id" - :display-reference="mr.reference" - :title="mr.title" - :milestone="mr.milestone" - :assignees="getAssignees(mr)" - :created-at="mr.created_at" - :closed-at="mr.closed_at" - :merged-at="mr.merged_at" - :path="mr.web_url" - :state="mr.state" - :is-merge-request="true" - :pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status" - path-id-separator="!" - /> - </li> - </ul> - </div> + <gl-loading-icon + v-if="isFetchingMergeRequests" + size="sm" + label="Fetching related merge requests" + class="gl-py-3" + /> + <ul v-else class="content-list related-items-list"> + <li v-for="mr in mergeRequests" :key="mr.id" class="list-item gl-m-0! gl-p-0!"> + <related-issuable-item + :id-key="mr.id" + :display-reference="mr.reference" + :title="mr.title" + :milestone="mr.milestone" + :assignees="getAssignees(mr)" + :created-at="mr.created_at" + :closed-at="mr.closed_at" + :merged-at="mr.merged_at" + :path="mr.web_url" + :state="mr.state" + :is-merge-request="true" + :pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status" + path-id-separator="!" + /> + </li> + </ul> </div> <div v-if="hasClosingMergeRequest && !isFetchingMergeRequests" diff --git a/app/assets/javascripts/related_merge_requests/store/actions.js b/app/assets/javascripts/related_merge_requests/store/actions.js index 652d03a0fd0..94abb50de89 100644 --- a/app/assets/javascripts/related_merge_requests/store/actions.js +++ b/app/assets/javascripts/related_merge_requests/store/actions.js @@ -1,7 +1,7 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { normalizeHeaders } from '~/lib/utils/common_utils'; -import { s__ } from '~/locale'; +import { __ } from '~/locale'; import * as types from './mutation_types'; const REQUEST_PAGE_COUNT = 100; @@ -30,7 +30,7 @@ export const fetchMergeRequests = ({ state, dispatch }) => { .catch(() => { dispatch('receiveDataError'); createFlash({ - message: s__('Something went wrong while fetching related merge requests.'), + message: __('Something went wrong while fetching related merge requests.'), }); }); }; diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue index 80f59485426..9e05d00a98d 100644 --- a/app/assets/javascripts/releases/components/tag_field_new.vue +++ b/app/assets/javascripts/releases/components/tag_field_new.vue @@ -95,6 +95,8 @@ export default { noRefSelected: __('No tag selected'), dropdownHeader: __('Tag name'), searchPlaceholder: __('Search or create tag'), + label: __('Tag name'), + labelDescription: __('*Required'), }, createFrom: { noRefSelected: __('No source selected'), @@ -108,11 +110,12 @@ export default { <template> <div> <gl-form-group - :label="__('Tag name')" - :label-for="tagNameInputId" data-testid="tag-name-field" :state="!showTagNameValidationError" :invalid-feedback="__('Tag name is required')" + :label="$options.translations.tagName.label" + :label-for="tagNameInputId" + :label-description="$options.translations.tagName.labelDescription" > <form-field-container> <ref-selector diff --git a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql index f2d89dbe682..c69481150e0 100644 --- a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql +++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql @@ -6,7 +6,7 @@ # When the `releases_index_apollo_client` feature flag is # removed, this query should be removed entirely. -query allReleases( +query allReleasesDeprecated( $fullPath: ID! $first: Int $last: Int diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js index 59f6ebfc928..86fa72d1496 100644 --- a/app/assets/javascripts/releases/mount_index.js +++ b/app/assets/javascripts/releases/mount_index.js @@ -24,7 +24,6 @@ export default () => { // the purpose of making separate requests. So we explicitly // disable batching on this page. batchMax: 1, - assumeImmutableResults: true, }, ), }); diff --git a/app/assets/javascripts/releases/mount_show.js b/app/assets/javascripts/releases/mount_show.js index 686f9e294b7..7272880197a 100644 --- a/app/assets/javascripts/releases/mount_show.js +++ b/app/assets/javascripts/releases/mount_show.js @@ -6,12 +6,7 @@ import ReleaseShowApp from './components/app_show.vue'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - assumeImmutableResults: true, - }, - ), + defaultClient: createDefaultClient(), }); export default () => { diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 7ad9fb56972..2cc5a8a79d2 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import BlobContent from '~/blob/components/blob_content.vue'; import BlobHeader from '~/blob/components/blob_header.vue'; @@ -17,12 +17,16 @@ import ForkSuggestion from './fork_suggestion.vue'; import { loadViewer, viewerProps } from './blob_viewers'; export default { + i18n: { + pipelineEditor: __('Pipeline Editor'), + }, components: { BlobHeader, BlobEdit, BlobButtonGroup, BlobContent, GlLoadingIcon, + GlButton, ForkSuggestion, }, mixins: [getRefMixin], @@ -105,6 +109,7 @@ export default { rawPath: '', externalStorageUrl: '', replacePath: '', + pipelineEditorPath: '', deletePath: '', simpleViewer: {}, richViewer: null, @@ -242,6 +247,18 @@ export default { :needs-to-fork="showForkSuggestion" @edit="editBlob" /> + + <gl-button + v-if="blobInfo.pipelineEditorPath" + class="gl-mr-3" + category="secondary" + variant="confirm" + data-testid="pipeline-editor" + :href="blobInfo.pipelineEditorPath" + > + {{ $options.i18n.pipelineEditor }} + </gl-button> + <blob-button-group v-if="isLoggedIn" :path="path" diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue index a307b7c0b8a..4a8cedb60b4 100644 --- a/app/assets/javascripts/repository/components/delete_blob_modal.vue +++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue @@ -116,15 +116,14 @@ export default { ], }; }, - /* eslint-disable dot-notation */ showCreateNewMrToggle() { - return this.canPushCode && this.form.fields['branch_name'].value !== this.originalBranch; + return this.canPushCode && this.form.fields.branch_name.value !== this.originalBranch; }, formCompleted() { - return this.form.fields['commit_message'].value && this.form.fields['branch_name'].value; + return this.form.fields.commit_message.value && this.form.fields.branch_name.value; }, showHint() { - const splitCommitMessageByLineBreak = this.form.fields['commit_message'].value + const splitCommitMessageByLineBreak = this.form.fields.commit_message.value .trim() .split('\n'); const [firstLine, ...otherLines] = splitCommitMessageByLineBreak; @@ -136,7 +135,7 @@ export default { otherLines.some((text) => text.length > COMMIT_MESSAGE_BODY_MAX_LENGTH); return ( - !this.form.fields['commit_message'].feedback && + !this.form.fields.commit_message.feedback && (hasFirstLineExceedMaxLength || hasOtherLineExceedMaxLength) ); }, @@ -173,9 +172,7 @@ export default { <input type="hidden" name="_method" value="delete" /> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> <template v-if="emptyRepo"> - <!-- Once "empty_repo_upload_experiment" is made available, will need to add class 'js-branch-name' - Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/335721 --> - <input type="hidden" name="branch_name" :value="originalBranch" /> + <input type="hidden" name="branch_name" :value="originalBranch" class="js-branch-name" /> </template> <template v-else> <input type="hidden" name="original_branch" :value="originalBranch" /> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 5c713796bd6..62066973ee6 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -1,5 +1,12 @@ <script> -import { GlTooltipDirective, GlLink, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui'; +import { + GlTooltipDirective, + GlLink, + GlButton, + GlButtonGroup, + GlLoadingIcon, + GlSafeHtmlDirective, +} from '@gitlab/ui'; import defaultAvatarUrl from 'images/no_avatar.png'; import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql'; import { sprintf, s__ } from '~/locale'; @@ -23,6 +30,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, + SafeHtml: GlSafeHtmlDirective, }, mixins: [getRefMixin], apollo: { @@ -96,6 +104,9 @@ export default { }, }, defaultAvatarUrl, + safeHtmlConfig: { + ADD_TAGS: ['gl-emoji'], + }, }; </script> @@ -121,10 +132,10 @@ export default { <div class="commit-detail flex-list"> <div class="commit-content qa-commit-content"> <gl-link + v-safe-html:[$options.safeHtmlConfig]="commit.titleHtml" :href="commit.webPath" :class="{ 'font-italic': !commit.message }" class="commit-row-message item-title" - v-html="commit.titleHtml /* eslint-disable-line vue/no-v-html */" /> <gl-button v-if="commit.descriptionHtml" @@ -150,15 +161,15 @@ export default { </div> <pre v-if="commitDescription" + v-safe-html:[$options.safeHtmlConfig]="commitDescription" :class="{ 'd-block': showDescription }" class="commit-row-description gl-mb-3" - v-html="commitDescription /* eslint-disable-line vue/no-v-html */" ></pre> </div> <div class="commit-actions flex-row"> <div v-if="commit.signatureHtml" - v-html="commit.signatureHtml /* eslint-disable-line vue/no-v-html */" + v-safe-html:[$options.safeHtmlConfig]="commit.signatureHtml" ></div> <div v-if="commit.pipeline" class="ci-status-link"> <gl-link diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 5010d60f374..bd06c064ab7 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -11,7 +11,6 @@ import { GlIntersectionObserver, } from '@gitlab/ui'; import { escapeRegExp } from 'lodash'; -import filesQuery from 'shared_queries/repository/files.query.graphql'; import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql'; import { escapeFileUrl } from '~/lib/utils/url_utility'; import { TREE_PAGE_SIZE } from '~/repository/constants'; @@ -178,8 +177,7 @@ export default { return this.isFolder ? this.loadFolder() : this.loadBlob(); }, loadFolder() { - const query = this.glFeatures.paginatedTreeGraphqlQuery ? paginatedTreeQuery : filesQuery; - this.apolloQuery(query, { + this.apolloQuery(paginatedTreeQuery, { projectPath: this.projectPath, ref: this.ref, path: this.path, diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 16dfe3cfb14..ffe8d5531f8 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -1,5 +1,4 @@ <script> -import filesQuery from 'shared_queries/repository/files.query.graphql'; import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql'; import createFlash from '~/flash'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -72,9 +71,6 @@ export default { hasShowMore() { return !this.clickedShowMore && this.pageLimitReached; }, - paginatedTreeEnabled() { - return this.glFeatures.paginatedTreeGraphqlQuery; - }, }, watch: { @@ -101,7 +97,7 @@ export default { return this.$apollo .query({ - query: this.paginatedTreeEnabled ? paginatedTreeQuery : filesQuery, + query: paginatedTreeQuery, variables: { projectPath: this.projectPath, ref: this.ref, @@ -114,20 +110,19 @@ export default { if (data.errors) throw data.errors; if (!data?.project?.repository || originalPath !== (this.path || '/')) return; - const pageInfo = this.paginatedTreeEnabled - ? data.project.repository.paginatedTree.pageInfo - : this.hasNextPage(data.project.repository.tree); + const { + project: { + repository: { + paginatedTree: { pageInfo }, + }, + }, + } = data; this.isLoadingFiles = false; this.entries = Object.keys(this.entries).reduce( (acc, key) => ({ ...acc, - [key]: this.normalizeData( - key, - this.paginatedTreeEnabled - ? data.project.repository.paginatedTree.nodes[0][key] - : data.project.repository.tree[key].edges, - ), + [key]: this.normalizeData(key, data.project.repository.paginatedTree.nodes[0][key]), }), {}, ); @@ -149,9 +144,7 @@ export default { }); }, normalizeData(key, data) { - return this.entries[key].concat( - this.paginatedTreeEnabled ? data.nodes : data.map(({ node }) => node), - ); + return this.entries[key].concat(data.nodes); }, hasNextPage(data) { return [] diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue index 0199b893453..11e5b5608cb 100644 --- a/app/assets/javascripts/repository/components/upload_blob_modal.vue +++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue @@ -15,7 +15,6 @@ import { ContentTypeMultipartFormData } from '~/lib/utils/headers'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import { trackFileUploadEvent } from '~/projects/upload_file_experiment_tracking'; import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; import { SECONDARY_OPTIONS_TEXT, @@ -165,9 +164,6 @@ export default { }, }) .then((response) => { - if (!this.replacePath) { - trackFileUploadEvent('click_upload_modal_form_submit'); - } visitUrl(response.data.filePath); }) .catch(() => { diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index 4892e54ebef..96d712ce9b4 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -64,7 +64,6 @@ const defaultClient = createDefaultClient( /* eslint-enable @gitlab/require-i18n-strings */ }, }, - assumeImmutableResults: true, }, ); diff --git a/app/assets/javascripts/repository/mixins/preload.js b/app/assets/javascripts/repository/mixins/preload.js index a2ddcbf0e4c..30c36dee48f 100644 --- a/app/assets/javascripts/repository/mixins/preload.js +++ b/app/assets/javascripts/repository/mixins/preload.js @@ -1,4 +1,3 @@ -import filesQuery from 'shared_queries/repository/files.query.graphql'; import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql'; import projectPathQuery from '../queries/project_path.query.graphql'; import getRefMixin from './get_ref'; @@ -22,7 +21,7 @@ export default { return this.$apollo .query({ - query: gon.features.paginatedTreeGraphqlQuery ? paginatedTreeQuery : filesQuery, + query: paginatedTreeQuery, variables: { projectPath: this.projectPath, ref: this.ref, diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index 8e0b5e21ca3..cf3892802fd 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -31,6 +31,7 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) { storedExternally rawPath replacePath + pipelineEditorPath simpleViewer { fileType tooLarge diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js index 29642b6633f..48a15954035 100644 --- a/app/assets/javascripts/rest_api.js +++ b/app/assets/javascripts/rest_api.js @@ -3,6 +3,7 @@ export * from './api/projects_api'; export * from './api/user_api'; export * from './api/markdown_api'; export * from './api/bulk_imports_api'; +export * from './api/namespaces_api'; // Note: It's not possible to spy on methods imported from this file in // Jest tests. diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 381421cdc23..3c8533dd06d 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -3,6 +3,7 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; import { hide, fixTitle } from '~/tooltips'; +import { DEBOUNCE_DROPDOWN_DELAY } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import createFlash from './flash'; import axios from './lib/utils/axios_utils'; import { sprintf, s__, __ } from './locale'; @@ -130,8 +131,10 @@ Sidebar.prototype.openDropdown = function (blockOrName) { // Wait for the sidebar to trigger('click') open // so it doesn't cause our dropdown to close preemptively setTimeout(() => { - $block.find('.js-sidebar-dropdown-toggle').trigger('click'); - }); + if (!gon.features?.labelsWidget && !$block.hasClass('labels-select-wrapper')) { + $block.find('.js-sidebar-dropdown-toggle').trigger('click'); + } + }, DEBOUNCE_DROPDOWN_DELAY); }; Sidebar.prototype.setCollapseAfterUpdate = function ($block) { diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index c8513a0b803..3edb658eaf5 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -1,18 +1,26 @@ <script> -import { GlLink } from '@gitlab/ui'; +import { GlBadge, GlLink } from '@gitlab/ui'; import createFlash from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import { updateHistory } from '~/lib/utils/url_utility'; -import { formatNumber, sprintf, __ } from '~/locale'; +import { sprintf, __ } from '~/locale'; + +import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerList from '../components/runner_list.vue'; -import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue'; import RunnerName from '../components/runner_name.vue'; import RunnerPagination from '../components/runner_pagination.vue'; +import RunnerTypeTabs from '../components/runner_type_tabs.vue'; + import { statusTokenConfig } from '../components/search_tokens/status_token_config'; import { tagTokenConfig } from '../components/search_tokens/tag_token_config'; -import { typeTokenConfig } from '../components/search_tokens/type_token_config'; -import { ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants'; +import { + ADMIN_FILTERED_SEARCH_NAMESPACE, + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + I18N_FETCH_ERROR, +} from '../constants'; import getRunnersQuery from '../graphql/get_runners.query.graphql'; import { fromUrlQueryToSearch, @@ -24,19 +32,37 @@ import { captureException } from '../sentry_utils'; export default { name: 'AdminRunnersApp', components: { + GlBadge, GlLink, + RegistrationDropdown, RunnerFilteredSearchBar, RunnerList, - RunnerManualSetupHelp, RunnerName, RunnerPagination, + RunnerTypeTabs, }, props: { + registrationToken: { + type: String, + required: true, + }, activeRunnersCount: { - type: Number, + type: String, required: true, }, - registrationToken: { + allRunnersCount: { + type: String, + required: true, + }, + instanceRunnersCount: { + type: String, + required: true, + }, + groupRunnersCount: { + type: String, + required: true, + }, + projectRunnersCount: { type: String, required: true, }, @@ -86,13 +112,12 @@ export default { }, activeRunnersMessage() { return sprintf(__('Runners currently online: %{active_runners_count}'), { - active_runners_count: formatNumber(this.activeRunnersCount), + active_runners_count: this.activeRunnersCount, }); }, searchTokens() { return [ statusTokenConfig, - typeTokenConfig, { ...tagTokenConfig, recentTokenValuesStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`, @@ -116,6 +141,20 @@ export default { this.reportToSentry(error); }, methods: { + tabCount({ runnerType }) { + switch (runnerType) { + case null: + return this.allRunnersCount; + case INSTANCE_TYPE: + return this.instanceRunnersCount; + case GROUP_TYPE: + return this.groupRunnersCount; + case PROJECT_TYPE: + return this.projectRunnersCount; + default: + return null; + } + }, reportToSentry(error) { captureException({ error, component: this.$options.name }); }, @@ -126,10 +165,30 @@ export default { </script> <template> <div> - <runner-manual-setup-help - :registration-token="registrationToken" - :type="$options.INSTANCE_TYPE" - /> + <div + class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0" + > + <runner-type-tabs + v-model="search" + class="gl-w-full" + content-class="gl-display-none" + nav-class="gl-border-none!" + > + <template #title="{ tab }"> + {{ tab.title }} + <gl-badge v-if="tabCount(tab)" class="gl-ml-1" size="sm"> + {{ tabCount(tab) }} + </gl-badge> + </template> + </runner-type-tabs> + + <registration-dropdown + class="gl-w-full gl-sm-w-auto gl-mr-auto" + :registration-token="registrationToken" + :type="$options.INSTANCE_TYPE" + right + /> + </div> <runner-filtered-search-bar v-model="search" diff --git a/app/assets/javascripts/runner/admin_runners/index.js b/app/assets/javascripts/runner/admin_runners/index.js index 1eec1019b73..62da6cbfa2b 100644 --- a/app/assets/javascripts/runner/admin_runners/index.js +++ b/app/assets/javascripts/runner/admin_runners/index.js @@ -1,8 +1,10 @@ +import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import AdminRunnersApp from './admin_runners_app.vue'; +Vue.use(GlToast); Vue.use(VueApollo); export const initAdminRunners = (selector = '#js-admin-runners') => { @@ -14,15 +16,19 @@ export const initAdminRunners = (selector = '#js-admin-runners') => { // TODO `activeRunnersCount` should be implemented using a GraphQL API // https://gitlab.com/gitlab-org/gitlab/-/issues/333806 - const { activeRunnersCount, registrationToken, runnerInstallHelpPage } = el.dataset; + const { + runnerInstallHelpPage, + registrationToken, + + activeRunnersCount, + allRunnersCount, + instanceRunnersCount, + groupRunnersCount, + projectRunnersCount, + } = el.dataset; const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - assumeImmutableResults: true, - }, - ), + defaultClient: createDefaultClient(), }); return new Vue({ @@ -34,8 +40,15 @@ export const initAdminRunners = (selector = '#js-admin-runners') => { render(h) { return h(AdminRunnersApp, { props: { - activeRunnersCount: parseInt(activeRunnersCount, 10), registrationToken, + + // All runner counts are returned as formatted + // strings, we do not use `parseInt`. + activeRunnersCount, + allRunnersCount, + instanceRunnersCount, + groupRunnersCount, + projectRunnersCount, }, }); }, diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue index e26bdbf1aea..c4bddb7b398 100644 --- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue @@ -3,7 +3,7 @@ import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; import createFlash from '~/flash'; import { __, s__ } from '~/locale'; import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; -import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql'; +import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; const i18n = { @@ -71,7 +71,7 @@ export default { runnerUpdate: { errors }, }, } = await this.$apollo.mutate({ - mutation: runnerUpdateMutation, + mutation: runnerActionsUpdateMutation, variables: { input: { id: this.runner.id, diff --git a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue new file mode 100644 index 00000000000..9ba1192bc8c --- /dev/null +++ b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue @@ -0,0 +1,40 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; + +import RunnerContactedStateBadge from '../runner_contacted_state_badge.vue'; +import RunnerPausedBadge from '../runner_paused_badge.vue'; + +import { I18N_LOCKED_RUNNER_DESCRIPTION, I18N_PAUSED_RUNNER_DESCRIPTION } from '../../constants'; + +export default { + components: { + RunnerContactedStateBadge, + RunnerPausedBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + paused() { + return !this.runner.active; + }, + }, + i18n: { + I18N_LOCKED_RUNNER_DESCRIPTION, + I18N_PAUSED_RUNNER_DESCRIPTION, + }, +}; +</script> + +<template> + <div> + <runner-contacted-state-badge :runner="runner" size="sm" /> + <runner-paused-badge v-if="paused" size="sm" /> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue index 886b5cb29fc..3b476997915 100644 --- a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue @@ -1,11 +1,21 @@ <script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; + import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import RunnerName from '../runner_name.vue'; +import RunnerTypeBadge from '../runner_type_badge.vue'; + +import { I18N_LOCKED_RUNNER_DESCRIPTION } from '../../constants'; export default { components: { + GlIcon, TooltipOnTruncate, RunnerName, + RunnerTypeBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { runner: { @@ -14,10 +24,19 @@ export default { }, }, computed: { + runnerType() { + return this.runner.runnerType; + }, + locked() { + return this.runner.locked; + }, description() { return this.runner.description; }, }, + i18n: { + I18N_LOCKED_RUNNER_DESCRIPTION, + }, }; </script> @@ -26,6 +45,14 @@ export default { <slot :runner="runner" name="runner-name"> <runner-name :runner="runner" /> </slot> + + <runner-type-badge :type="runnerType" size="sm" /> + <gl-icon + v-if="locked" + v-gl-tooltip + :title="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION" + name="lock" + /> <tooltip-on-truncate class="gl-display-block" :title="description" truncate-target="child"> <div class="gl-text-truncate"> {{ description }} diff --git a/app/assets/javascripts/runner/components/cells/runner_type_cell.vue b/app/assets/javascripts/runner/components/cells/runner_type_cell.vue deleted file mode 100644 index c8cb0bf6088..00000000000 --- a/app/assets/javascripts/runner/components/cells/runner_type_cell.vue +++ /dev/null @@ -1,47 +0,0 @@ -<script> -import { GlTooltipDirective } from '@gitlab/ui'; -import RunnerTypeBadge from '../runner_type_badge.vue'; -import RunnerStateLockedBadge from '../runner_state_locked_badge.vue'; -import RunnerStatePausedBadge from '../runner_state_paused_badge.vue'; -import { I18N_LOCKED_RUNNER_DESCRIPTION, I18N_PAUSED_RUNNER_DESCRIPTION } from '../../constants'; - -export default { - components: { - RunnerTypeBadge, - RunnerStateLockedBadge, - RunnerStatePausedBadge, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - runner: { - type: Object, - required: true, - }, - }, - computed: { - runnerType() { - return this.runner.runnerType; - }, - locked() { - return this.runner.locked; - }, - paused() { - return !this.runner.active; - }, - }, - i18n: { - I18N_LOCKED_RUNNER_DESCRIPTION, - I18N_PAUSED_RUNNER_DESCRIPTION, - }, -}; -</script> - -<template> - <div> - <runner-type-badge :type="runnerType" size="sm" /> - <runner-state-locked-badge v-if="locked" size="sm" /> - <runner-state-paused-badge v-if="paused" size="sm" /> - </div> -</template> diff --git a/app/assets/javascripts/runner/components/helpers/masked_value.vue b/app/assets/javascripts/runner/components/helpers/masked_value.vue deleted file mode 100644 index feccb37de81..00000000000 --- a/app/assets/javascripts/runner/components/helpers/masked_value.vue +++ /dev/null @@ -1,60 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - components: { - GlButton, - }, - props: { - value: { - type: String, - required: false, - default: '', - }, - }, - data() { - return { - isMasked: true, - }; - }, - computed: { - label() { - if (this.isMasked) { - return __('Click to reveal'); - } - return __('Click to hide'); - }, - icon() { - if (this.isMasked) { - return 'eye'; - } - return 'eye-slash'; - }, - displayedValue() { - if (this.isMasked && this.value?.length) { - return '*'.repeat(this.value.length); - } - return this.value; - }, - }, - methods: { - toggleMasked() { - this.isMasked = !this.isMasked; - }, - }, -}; -</script> -<template> - <span - >{{ displayedValue }} - <gl-button - :aria-label="label" - :icon="icon" - class="gl-text-body!" - data-testid="toggle-masked" - variant="link" - @click="toggleMasked" - /> - </span> -</template> diff --git a/app/assets/javascripts/runner/components/registration/registration_dropdown.vue b/app/assets/javascripts/runner/components/registration/registration_dropdown.vue new file mode 100644 index 00000000000..3fbe3c1be74 --- /dev/null +++ b/app/assets/javascripts/runner/components/registration/registration_dropdown.vue @@ -0,0 +1,112 @@ +<script> +import { + GlFormGroup, + GlDropdown, + GlDropdownForm, + GlDropdownItem, + GlDropdownDivider, +} from '@gitlab/ui'; +import { s__ } from '~/locale'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants'; +import RegistrationToken from './registration_token.vue'; +import RegistrationTokenResetDropdownItem from './registration_token_reset_dropdown_item.vue'; + +export default { + i18n: { + showInstallationInstructions: s__( + 'Runners|Show runner installation and registration instructions', + ), + registrationToken: s__('Runners|Registration token'), + }, + components: { + GlFormGroup, + GlDropdown, + GlDropdownForm, + GlDropdownItem, + GlDropdownDivider, + RegistrationToken, + RunnerInstructionsModal, + RegistrationTokenResetDropdownItem, + }, + props: { + registrationToken: { + type: String, + required: true, + }, + type: { + type: String, + required: true, + validator(type) { + return [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE].includes(type); + }, + }, + }, + data() { + return { + currentRegistrationToken: this.registrationToken, + instructionsModalOpened: false, + }; + }, + computed: { + dropdownText() { + switch (this.type) { + case INSTANCE_TYPE: + return s__('Runners|Register an instance runner'); + case GROUP_TYPE: + return s__('Runners|Register a group runner'); + case PROJECT_TYPE: + return s__('Runners|Register a project runner'); + default: + return s__('Runners|Register a runner'); + } + }, + }, + methods: { + onShowInstructionsClick() { + // Rendering the modal on demand, to avoid + // loading instructions prematurely from API. + this.instructionsModalOpened = true; + + this.$nextTick(() => { + // $refs.runnerInstructionsModal is defined in + // the tick after the modal is rendered + this.$refs.runnerInstructionsModal.show(); + }); + }, + onTokenReset(token) { + this.currentRegistrationToken = token; + + this.$refs.runnerRegistrationDropdown.hide(true); + }, + }, +}; +</script> + +<template> + <gl-dropdown + ref="runnerRegistrationDropdown" + menu-class="gl-w-auto!" + :text="dropdownText" + variant="confirm" + v-bind="$attrs" + > + <gl-dropdown-item @click.capture.native.stop="onShowInstructionsClick"> + {{ $options.i18n.showInstallationInstructions }} + <runner-instructions-modal + v-if="instructionsModalOpened" + ref="runnerInstructionsModal" + :registration-token="registrationToken" + data-testid="runner-instructions-modal" + /> + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-dropdown-form class="gl-p-4!"> + <gl-form-group class="gl-mb-0" :label="$options.i18n.registrationToken"> + <registration-token :value="currentRegistrationToken" /> + </gl-form-group> + </gl-dropdown-form> + <gl-dropdown-divider /> + <registration-token-reset-dropdown-item :type="type" @tokenReset="onTokenReset" /> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/runner/components/registration/registration_token.vue b/app/assets/javascripts/runner/components/registration/registration_token.vue new file mode 100644 index 00000000000..d54a66ff0e4 --- /dev/null +++ b/app/assets/javascripts/runner/components/registration/registration_token.vue @@ -0,0 +1,83 @@ +<script> +import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; + +export default { + components: { + GlButtonGroup, + GlButton, + ModalCopyButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + value: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isMasked: true, + }; + }, + computed: { + maskLabel() { + if (this.isMasked) { + return __('Click to reveal'); + } + return __('Click to hide'); + }, + maskIcon() { + if (this.isMasked) { + return 'eye'; + } + return 'eye-slash'; + }, + displayedValue() { + if (this.isMasked && this.value?.length) { + return '*'.repeat(this.value.length); + } + return this.value; + }, + }, + methods: { + onToggleMasked() { + this.isMasked = !this.isMasked; + }, + onCopied() { + // value already in the clipboard, simply notify the user + this.$toast?.show(s__('Runners|Registration token copied!')); + }, + }, + i18n: { + copyLabel: s__('Runners|Copy registration token'), + }, +}; +</script> +<template> + <gl-button-group> + <gl-button class="gl-font-monospace" data-testid="token-value" label> + {{ displayedValue }} + </gl-button> + <gl-button + v-gl-tooltip + :aria-label="maskLabel" + :title="maskLabel" + :icon="maskIcon" + class="gl-w-auto! gl-flex-shrink-0!" + data-testid="toggle-masked" + @click.stop="onToggleMasked" + /> + <modal-copy-button + class="gl-w-auto! gl-flex-shrink-0!" + :aria-label="$options.i18n.copyLabel" + :title="$options.i18n.copyLabel" + :text="value" + @success="onCopied" + /> + </gl-button-group> +</template> diff --git a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue new file mode 100644 index 00000000000..3bb15bff8d8 --- /dev/null +++ b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue @@ -0,0 +1,113 @@ +<script> +import { GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { TYPE_GROUP, TYPE_PROJECT } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { __, s__ } from '~/locale'; +import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql'; +import { captureException } from '~/runner/sentry_utils'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants'; + +export default { + name: 'RunnerRegistrationTokenReset', + components: { + GlDropdownItem, + GlLoadingIcon, + }, + inject: { + groupId: { + default: null, + }, + projectId: { + default: null, + }, + }, + props: { + type: { + type: String, + required: true, + validator(type) { + return [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE].includes(type); + }, + }, + }, + data() { + return { + loading: false, + }; + }, + computed: { + resetTokenInput() { + switch (this.type) { + case INSTANCE_TYPE: + return { + type: this.type, + }; + case GROUP_TYPE: + return { + id: convertToGraphQLId(TYPE_GROUP, this.groupId), + type: this.type, + }; + case PROJECT_TYPE: + return { + id: convertToGraphQLId(TYPE_PROJECT, this.projectId), + type: this.type, + }; + default: + return null; + } + }, + }, + methods: { + async resetToken() { + // TODO Replace confirmation with gl-modal + // See: https://gitlab.com/gitlab-org/gitlab/-/issues/333810 + // eslint-disable-next-line no-alert + if (!window.confirm(__('Are you sure you want to reset the registration token?'))) { + return; + } + + this.loading = true; + try { + const { + data: { + runnersRegistrationTokenReset: { token, errors }, + }, + } = await this.$apollo.mutate({ + mutation: runnersRegistrationTokenResetMutation, + variables: { + input: this.resetTokenInput, + }, + }); + if (errors && errors.length) { + throw new Error(errors.join(' ')); + } + this.onSuccess(token); + } catch (e) { + this.onError(e); + } finally { + this.loading = false; + } + }, + onError(error) { + const { message } = error; + createFlash({ message }); + + this.reportToSentry(error); + }, + onSuccess(token) { + this.$toast?.show(s__('Runners|New registration token generated!')); + this.$emit('tokenReset', token); + }, + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + }, +}; +</script> +<template> + <gl-dropdown-item @click.capture.native.stop="resetToken"> + {{ __('Reset registration token') }} + <gl-loading-icon v-if="loading" inline /> + </gl-dropdown-item> +</template> diff --git a/app/assets/javascripts/runner/components/runner_contacted_state_badge.vue b/app/assets/javascripts/runner/components/runner_contacted_state_badge.vue new file mode 100644 index 00000000000..b4727f832f8 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_contacted_state_badge.vue @@ -0,0 +1,69 @@ +<script> +import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import { getTimeago } from '~/lib/utils/datetime_utility'; +import { + I18N_ONLINE_RUNNER_DESCRIPTION, + I18N_OFFLINE_RUNNER_DESCRIPTION, + I18N_NOT_CONNECTED_RUNNER_DESCRIPTION, + STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_NOT_CONNECTED, +} from '../constants'; + +export default { + components: { + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + required: true, + type: Object, + }, + }, + computed: { + contactedAtTimeAgo() { + if (this.runner.contactedAt) { + return getTimeago().format(this.runner.contactedAt); + } + return null; + }, + badge() { + switch (this.runner.status) { + case STATUS_ONLINE: + return { + variant: 'success', + label: s__('Runners|online'), + tooltip: sprintf(I18N_ONLINE_RUNNER_DESCRIPTION, { + timeAgo: this.contactedAtTimeAgo, + }), + }; + case STATUS_OFFLINE: + return { + variant: 'muted', + label: s__('Runners|offline'), + tooltip: sprintf(I18N_OFFLINE_RUNNER_DESCRIPTION, { + timeAgo: this.contactedAtTimeAgo, + }), + }; + case STATUS_NOT_CONNECTED: + return { + variant: 'muted', + label: s__('Runners|not connected'), + tooltip: I18N_NOT_CONNECTED_RUNNER_DESCRIPTION, + }; + default: + return null; + } + }, + }, +}; +</script> +<template> + <gl-badge v-if="badge" v-gl-tooltip="badge.tooltip" :variant="badge.variant" v-bind="$attrs"> + {{ badge.label }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue index e04ca8ddca0..a9dfec35479 100644 --- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue +++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue @@ -2,6 +2,7 @@ import { cloneDeep } from 'lodash'; import { __ } from '~/locale'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { searchValidator } from '~/runner/runner_search_utils'; import { CREATED_DESC, CREATED_ASC, CONTACTED_DESC, CONTACTED_ASC } from '../constants'; const sortOptions = [ @@ -31,9 +32,12 @@ export default { value: { type: Object, required: true, - validator(val) { - return Array.isArray(val?.filters) && typeof val?.sort === 'string'; - }, + validator: searchValidator, + }, + tokens: { + type: Array, + required: false, + default: () => [], }, namespace: { type: String, @@ -43,7 +47,7 @@ export default { data() { // filtered_search_bar_root.vue may mutate the inital // filters. Use `cloneDeep` to prevent those mutations - // from affecting this component + // from affecting this component const { filters, sort } = cloneDeep(this.value); return { initialFilterValue: filters, @@ -52,19 +56,17 @@ export default { }, methods: { onFilter(filters) { - const { sort } = this.value; - + // Apply new filters, from page 1 this.$emit('input', { + ...this.value, filters, - sort, pagination: { page: 1 }, }); }, onSort(sort) { - const { filters } = this.value; - + // Apply new sort, from page 1 this.$emit('input', { - filters, + ...this.value, sort, pagination: { page: 1 }, }); @@ -74,13 +76,16 @@ export default { }; </script> <template> - <div> + <div + class="gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1 gl-border-b-1" + > <filtered-search v-bind="$attrs" :namespace="namespace" recent-searches-storage-key="runners-search" :sort-options="$options.sortOptions" :initial-filter-value="initialFilterValue" + :tokens="tokens" :initial-sort-by="initialSortBy" :search-input-placeholder="__('Search or filter results...')" data-testid="runners-filtered-search" diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index 3f6ea389288..f8dbc469c22 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -1,12 +1,11 @@ <script> import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { formatNumber, __, s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; -import { RUNNER_JOB_COUNT_LIMIT } from '../constants'; import RunnerActionsCell from './cells/runner_actions_cell.vue'; import RunnerSummaryCell from './cells/runner_summary_cell.vue'; -import RunnerTypeCell from './cells/runner_type_cell.vue'; +import RunnerStatusCell from './cells/runner_status_cell.vue'; import RunnerTags from './runner_tags.vue'; const tableField = ({ key, label = '', width = 10 }) => { @@ -37,7 +36,7 @@ export default { RunnerActionsCell, RunnerSummaryCell, RunnerTags, - RunnerTypeCell, + RunnerStatusCell, }, directives: { GlTooltip: GlTooltipDirective, @@ -54,18 +53,6 @@ export default { }, }, methods: { - formatProjectCount(projectCount) { - if (projectCount === null) { - return __('n/a'); - } - return formatNumber(projectCount); - }, - formatJobCount(jobCount) { - if (jobCount > RUNNER_JOB_COUNT_LIMIT) { - return `${formatNumber(RUNNER_JOB_COUNT_LIMIT)}+`; - } - return formatNumber(jobCount); - }, runnerTrAttr(runner) { if (runner) { return { @@ -76,13 +63,11 @@ export default { }, }, fields: [ - tableField({ key: 'type', label: __('Type/State') }), - tableField({ key: 'summary', label: s__('Runners|Runner'), width: 30 }), + tableField({ key: 'status', label: s__('Runners|Status') }), + tableField({ key: 'summary', label: s__('Runners|Runner ID'), width: 30 }), tableField({ key: 'version', label: __('Version') }), tableField({ key: 'ipAddress', label: __('IP Address') }), - tableField({ key: 'projectCount', label: __('Projects'), width: 5 }), - tableField({ key: 'jobCount', label: __('Jobs'), width: 5 }), - tableField({ key: 'tagList', label: __('Tags') }), + tableField({ key: 'tagList', label: __('Tags'), width: 20 }), tableField({ key: 'contactedAt', label: __('Last contact') }), tableField({ key: 'actions', label: '' }), ], @@ -103,8 +88,8 @@ export default { <gl-skeleton-loader v-for="i in 4" :key="i" /> </template> - <template #cell(type)="{ item }"> - <runner-type-cell :runner="item" /> + <template #cell(status)="{ item }"> + <runner-status-cell :runner="item" /> </template> <template #cell(summary)="{ item, index }"> @@ -123,14 +108,6 @@ export default { {{ ipAddress }} </template> - <template #cell(projectCount)="{ item: { projectCount } }"> - {{ formatProjectCount(projectCount) }} - </template> - - <template #cell(jobCount)="{ item: { jobCount } }"> - {{ formatJobCount(jobCount) }} - </template> - <template #cell(tagList)="{ item: { tagList } }"> <runner-tags :tag-list="tagList" size="sm" /> </template> diff --git a/app/assets/javascripts/runner/components/runner_manual_setup_help.vue b/app/assets/javascripts/runner/components/runner_manual_setup_help.vue deleted file mode 100644 index 475d362bb52..00000000000 --- a/app/assets/javascripts/runner/components/runner_manual_setup_help.vue +++ /dev/null @@ -1,108 +0,0 @@ -<script> -import { GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import MaskedValue from '~/runner/components/helpers/masked_value.vue'; -import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; -import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants'; - -export default { - components: { - GlLink, - GlSprintf, - ClipboardButton, - MaskedValue, - RunnerInstructions, - RunnerRegistrationTokenReset, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - inject: { - runnerInstallHelpPage: { - default: null, - }, - }, - props: { - registrationToken: { - type: String, - required: true, - }, - type: { - type: String, - required: true, - validator(type) { - return [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE].includes(type); - }, - }, - }, - data() { - return { - currentRegistrationToken: this.registrationToken, - }; - }, - computed: { - rootUrl() { - return gon.gitlab_url || ''; - }, - typeName() { - switch (this.type) { - case INSTANCE_TYPE: - return s__('Runners|shared'); - case GROUP_TYPE: - return s__('Runners|group'); - case PROJECT_TYPE: - return s__('Runners|specific'); - default: - return ''; - } - }, - }, - methods: { - onTokenReset(token) { - this.currentRegistrationToken = token; - }, - }, -}; -</script> - -<template> - <div class="bs-callout"> - <h5 data-testid="runner-help-title"> - <gl-sprintf :message="__('Set up a %{type} runner manually')"> - <template #type> - {{ typeName }} - </template> - </gl-sprintf> - </h5> - - <ol> - <li> - <gl-link :href="runnerInstallHelpPage" data-testid="runner-help-link" target="_blank"> - {{ __("Install GitLab Runner and ensure it's running.") }} - </gl-link> - </li> - <li> - {{ __('Register the runner with this URL:') }} - <br /> - - <code data-testid="coordinator-url">{{ rootUrl }}</code> - <clipboard-button :title="__('Copy URL')" :text="rootUrl" /> - </li> - <li> - {{ __('And this registration token:') }} - <br /> - - <code data-testid="registration-token" - ><masked-value :value="currentRegistrationToken" - /></code> - <clipboard-button :title="__('Copy token')" :text="currentRegistrationToken" /> - </li> - </ol> - - <runner-registration-token-reset :type="type" @tokenReset="onTokenReset" /> - - <runner-instructions /> - </div> -</template> diff --git a/app/assets/javascripts/runner/components/runner_state_paused_badge.vue b/app/assets/javascripts/runner/components/runner_paused_badge.vue index d1e6fa05e4d..d1e6fa05e4d 100644 --- a/app/assets/javascripts/runner/components/runner_state_paused_badge.vue +++ b/app/assets/javascripts/runner/components/runner_paused_badge.vue diff --git a/app/assets/javascripts/runner/components/runner_registration_token_reset.vue b/app/assets/javascripts/runner/components/runner_registration_token_reset.vue deleted file mode 100644 index cdf14abd4f9..00000000000 --- a/app/assets/javascripts/runner/components/runner_registration_token_reset.vue +++ /dev/null @@ -1,114 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import createFlash, { FLASH_TYPES } from '~/flash'; -import { TYPE_GROUP, TYPE_PROJECT } from '~/graphql_shared/constants'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { __, s__ } from '~/locale'; -import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql'; -import { captureException } from '~/runner/sentry_utils'; -import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants'; - -export default { - name: 'RunnerRegistrationTokenReset', - components: { - GlButton, - }, - inject: { - groupId: { - default: null, - }, - projectId: { - default: null, - }, - }, - props: { - type: { - type: String, - required: true, - validator(type) { - return [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE].includes(type); - }, - }, - }, - data() { - return { - loading: false, - }; - }, - computed: { - resetTokenInput() { - switch (this.type) { - case INSTANCE_TYPE: - return { - type: this.type, - }; - case GROUP_TYPE: - return { - id: convertToGraphQLId(TYPE_GROUP, this.groupId), - type: this.type, - }; - case PROJECT_TYPE: - return { - id: convertToGraphQLId(TYPE_PROJECT, this.projectId), - type: this.type, - }; - default: - return null; - } - }, - }, - methods: { - async resetToken() { - // TODO Replace confirmation with gl-modal - // See: https://gitlab.com/gitlab-org/gitlab/-/issues/333810 - // eslint-disable-next-line no-alert - if (!window.confirm(__('Are you sure you want to reset the registration token?'))) { - return; - } - - this.loading = true; - try { - const { - data: { - runnersRegistrationTokenReset: { token, errors }, - }, - } = await this.$apollo.mutate({ - mutation: runnersRegistrationTokenResetMutation, - variables: { - input: this.resetTokenInput, - }, - }); - if (errors && errors.length) { - throw new Error(errors.join(' ')); - } - this.onSuccess(token); - } catch (e) { - this.onError(e); - } finally { - this.loading = false; - } - }, - onError(error) { - const { message } = error; - createFlash({ message }); - - this.reportToSentry(error); - }, - onSuccess(token) { - createFlash({ - message: s__('Runners|New registration token generated!'), - type: FLASH_TYPES.SUCCESS, - }); - this.$emit('tokenReset', token); - }, - reportToSentry(error) { - captureException({ error, component: this.$options.name }); - }, - }, -}; -</script> -<template> - <gl-button :loading="loading" @click="resetToken"> - {{ __('Reset registration token') }} - </gl-button> -</template> diff --git a/app/assets/javascripts/runner/components/runner_state_locked_badge.vue b/app/assets/javascripts/runner/components/runner_state_locked_badge.vue deleted file mode 100644 index 458526010bc..00000000000 --- a/app/assets/javascripts/runner/components/runner_state_locked_badge.vue +++ /dev/null @@ -1,25 +0,0 @@ -<script> -import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; -import { I18N_LOCKED_RUNNER_DESCRIPTION } from '../constants'; - -export default { - components: { - GlBadge, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - i18n: { - I18N_LOCKED_RUNNER_DESCRIPTION, - }, -}; -</script> -<template> - <gl-badge - v-gl-tooltip="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION" - variant="warning" - v-bind="$attrs" - > - {{ s__('Runners|locked') }} - </gl-badge> -</template> diff --git a/app/assets/javascripts/runner/components/runner_tag.vue b/app/assets/javascripts/runner/components/runner_tag.vue index 06562e618a8..6ad2023a866 100644 --- a/app/assets/javascripts/runner/components/runner_tag.vue +++ b/app/assets/javascripts/runner/components/runner_tag.vue @@ -1,11 +1,15 @@ <script> -import { GlBadge } from '@gitlab/ui'; +import { GlBadge, GlTooltipDirective, GlResizeObserverDirective } from '@gitlab/ui'; import { RUNNER_TAG_BADGE_VARIANT } from '../constants'; export default { components: { GlBadge, }, + directives: { + GlTooltip: GlTooltipDirective, + GlResizeObserver: GlResizeObserverDirective, + }, props: { tag: { type: String, @@ -14,14 +18,39 @@ export default { size: { type: String, required: false, - default: 'md', + default: 'sm', + }, + }, + data() { + return { + overflowing: false, + }; + }, + computed: { + tooltip() { + if (this.overflowing) { + return this.tag; + } + return ''; + }, + }, + methods: { + onResize() { + const { scrollWidth, offsetWidth } = this.$el; + this.overflowing = scrollWidth > offsetWidth; }, }, RUNNER_TAG_BADGE_VARIANT, }; </script> <template> - <gl-badge :size="size" :variant="$options.RUNNER_TAG_BADGE_VARIANT"> + <gl-badge + v-gl-tooltip="tooltip" + v-gl-resize-observer="onResize" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + :size="size" + :variant="$options.RUNNER_TAG_BADGE_VARIANT" + > {{ tag }} </gl-badge> </template> diff --git a/app/assets/javascripts/runner/components/runner_tags.vue b/app/assets/javascripts/runner/components/runner_tags.vue index aec0d8e2c66..8da5e33076f 100644 --- a/app/assets/javascripts/runner/components/runner_tags.vue +++ b/app/assets/javascripts/runner/components/runner_tags.vue @@ -14,13 +14,19 @@ export default { size: { type: String, required: false, - default: 'md', + default: 'sm', }, }, }; </script> <template> <div> - <runner-tag v-for="tag in tagList" :key="tag" :tag="tag" :size="size" /> + <runner-tag + v-for="tag in tagList" + :key="tag" + class="gl-display-inline gl-mr-1" + :tag="tag" + :size="size" + /> </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_type_alert.vue b/app/assets/javascripts/runner/components/runner_type_alert.vue index aa435aaa823..1400875a1d6 100644 --- a/app/assets/javascripts/runner/components/runner_type_alert.vue +++ b/app/assets/javascripts/runner/components/runner_type_alert.vue @@ -9,17 +9,14 @@ const ALERT_DATA = { message: s__( 'Runners|This runner is available to all groups and projects in your GitLab instance.', ), - variant: 'success', anchor: 'shared-runners', }, [GROUP_TYPE]: { message: s__('Runners|This runner is available to all projects and subgroups in a group.'), - variant: 'success', anchor: 'group-runners', }, [PROJECT_TYPE]: { message: s__('Runners|This runner is associated with one or more projects.'), - variant: 'info', anchor: 'specific-runners', }, }; @@ -50,7 +47,7 @@ export default { }; </script> <template> - <gl-alert v-if="alert" :variant="alert.variant" :dismissible="false"> + <gl-alert v-if="alert" variant="info" :dismissible="false"> {{ alert.message }} <gl-link :href="helpHref">{{ __('Learn more.') }}</gl-link> </gl-alert> diff --git a/app/assets/javascripts/runner/components/runner_type_badge.vue b/app/assets/javascripts/runner/components/runner_type_badge.vue index 1a61b80184b..b885dcefdcb 100644 --- a/app/assets/javascripts/runner/components/runner_type_badge.vue +++ b/app/assets/javascripts/runner/components/runner_type_badge.vue @@ -12,17 +12,14 @@ import { const BADGE_DATA = { [INSTANCE_TYPE]: { - variant: 'success', text: s__('Runners|shared'), tooltip: I18N_INSTANCE_RUNNER_DESCRIPTION, }, [GROUP_TYPE]: { - variant: 'success', text: s__('Runners|group'), tooltip: I18N_GROUP_RUNNER_DESCRIPTION, }, [PROJECT_TYPE]: { - variant: 'info', text: s__('Runners|specific'), tooltip: I18N_PROJECT_RUNNER_DESCRIPTION, }, @@ -53,7 +50,7 @@ export default { }; </script> <template> - <gl-badge v-if="badge" v-gl-tooltip="badge.tooltip" :variant="badge.variant" v-bind="$attrs"> + <gl-badge v-if="badge" v-gl-tooltip="badge.tooltip" variant="info" v-bind="$attrs"> {{ badge.text }} </gl-badge> </template> diff --git a/app/assets/javascripts/runner/components/runner_type_tabs.vue b/app/assets/javascripts/runner/components/runner_type_tabs.vue new file mode 100644 index 00000000000..b767dafaccf --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_type_tabs.vue @@ -0,0 +1,66 @@ +<script> +import { GlTabs, GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { searchValidator } from '~/runner/runner_search_utils'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants'; + +const tabs = [ + { + title: s__('Runners|All'), + runnerType: null, + }, + { + title: s__('Runners|Instance'), + runnerType: INSTANCE_TYPE, + }, + { + title: s__('Runners|Group'), + runnerType: GROUP_TYPE, + }, + { + title: s__('Runners|Project'), + runnerType: PROJECT_TYPE, + }, +]; + +export default { + components: { + GlTabs, + GlTab, + }, + props: { + value: { + type: Object, + required: true, + validator: searchValidator, + }, + }, + methods: { + onTabSelected({ runnerType }) { + this.$emit('input', { + ...this.value, + runnerType, + pagination: { page: 1 }, + }); + }, + isTabActive({ runnerType }) { + return runnerType === this.value.runnerType; + }, + }, + tabs, +}; +</script> +<template> + <gl-tabs v-bind="$attrs" data-testid="runner-type-tabs"> + <gl-tab + v-for="tab in $options.tabs" + :key="`${tab.runnerType}`" + :active="isTabActive(tab)" + @click="onTabSelected(tab)" + > + <template #title> + <slot name="title" :tab="tab">{{ tab.title }}</slot> + </template> + </gl-tab> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js index 03dff5e61a5..9963048ae1d 100644 --- a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js +++ b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js @@ -10,23 +10,29 @@ import { PARAM_KEY_STATUS, } from '../../constants'; +const options = [ + { value: STATUS_ACTIVE, title: s__('Runners|Active') }, + { value: STATUS_PAUSED, title: s__('Runners|Paused') }, + { value: STATUS_ONLINE, title: s__('Runners|Online') }, + { value: STATUS_OFFLINE, title: s__('Runners|Offline') }, + { value: STATUS_NOT_CONNECTED, title: s__('Runners|Not connected') }, +]; + export const statusTokenConfig = { icon: 'status', title: __('Status'), type: PARAM_KEY_STATUS, token: BaseToken, unique: true, - options: [ - { value: STATUS_ACTIVE, title: s__('Runners|Active') }, - { value: STATUS_PAUSED, title: s__('Runners|Paused') }, - { value: STATUS_ONLINE, title: s__('Runners|Online') }, - { value: STATUS_OFFLINE, title: s__('Runners|Offline') }, - - // Added extra quotes in this title to avoid splitting this value: + options: options.map(({ value, title }) => ({ + value, + // Replace whitespace with a special character to avoid + // splitting this value. + // Replacing in each option, as translations may also + // contain spaces! + // see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142 // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 - { value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` }, - ], - // TODO In principle we could support more complex search rules, - // this can be added to a separate issue. + title: title.replace(' ', '\u00a0'), + })), operators: OPERATOR_IS_ONLY, }; diff --git a/app/assets/javascripts/runner/components/search_tokens/type_token_config.js b/app/assets/javascripts/runner/components/search_tokens/type_token_config.js deleted file mode 100644 index 1da61c53386..00000000000 --- a/app/assets/javascripts/runner/components/search_tokens/type_token_config.js +++ /dev/null @@ -1,20 +0,0 @@ -import { __, s__ } from '~/locale'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; -import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, PARAM_KEY_RUNNER_TYPE } from '../../constants'; - -export const typeTokenConfig = { - icon: 'file-tree', - title: __('Type'), - type: PARAM_KEY_RUNNER_TYPE, - token: BaseToken, - unique: true, - options: [ - { value: INSTANCE_TYPE, title: s__('Runners|instance') }, - { value: GROUP_TYPE, title: s__('Runners|group') }, - { value: PROJECT_TYPE, title: s__('Runners|project') }, - ], - // TODO We should support more complex search rules, - // search for multiple states (OR) or have NOT operators - operators: OPERATOR_IS_ONLY, -}; diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index a2fb9d9efd8..3952e2398e0 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -1,21 +1,33 @@ import { s__ } from '~/locale'; export const RUNNER_PAGE_SIZE = 20; -export const RUNNER_JOB_COUNT_LIMIT = 1000; export const GROUP_RUNNER_COUNT_LIMIT = 1000; export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.'); export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}'); +// Type export const I18N_INSTANCE_RUNNER_DESCRIPTION = s__('Runners|Available to all projects'); export const I18N_GROUP_RUNNER_DESCRIPTION = s__( 'Runners|Available to all projects and subgroups in the group', ); export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects'); + +// Status +export const I18N_ONLINE_RUNNER_DESCRIPTION = s__( + 'Runners|Runner is online; last contact was %{timeAgo}', +); +export const I18N_OFFLINE_RUNNER_DESCRIPTION = s__( + 'Runners|No recent contact from this runner; last contact was %{timeAgo}', +); +export const I18N_NOT_CONNECTED_RUNNER_DESCRIPTION = s__( + 'Runners|This runner has never connected to this instance', +); + export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects'); export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs'); -export const RUNNER_TAG_BADGE_VARIANT = 'info'; +export const RUNNER_TAG_BADGE_VARIANT = 'neutral'; export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100'; // Filtered search parameter names diff --git a/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql new file mode 100644 index 00000000000..547cc43907c --- /dev/null +++ b/app/assets/javascripts/runner/graphql/runner_actions_update.mutation.graphql @@ -0,0 +1,14 @@ +#import "~/runner/graphql/runner_node.fragment.graphql" + +# Mutation for updates within the runners list via action +# buttons (play, pause, ...), loads attributes shown in the +# runner list. + +mutation runnerActionsUpdate($input: RunnerUpdateInput!) { + runnerUpdate(input: $input) { + runner { + ...RunnerNode + } + errors + } +} diff --git a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql index 68d6f02f799..98f2dab26ca 100644 --- a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql @@ -10,6 +10,5 @@ fragment RunnerNode on CiRunner { locked tagList contactedAt - jobCount - projectCount + status } diff --git a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql index dcc7fdf24f1..ea622fd4958 100644 --- a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql +++ b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql @@ -1,5 +1,8 @@ #import "ee_else_ce/runner/graphql/runner_details.fragment.graphql" +# Mutation for updates from the runner form, loads +# attributes shown in the runner details. + mutation runnerUpdate($input: RunnerUpdateInput!) { runnerUpdate(input: $input) { runner { diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue index 4bb28796dfa..c3dfa885f27 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -5,14 +5,14 @@ import { fetchPolicies } from '~/lib/graphql'; import { updateHistory } from '~/lib/utils/url_utility'; import { formatNumber, sprintf, s__ } from '~/locale'; +import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerList from '../components/runner_list.vue'; -import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue'; import RunnerName from '../components/runner_name.vue'; import RunnerPagination from '../components/runner_pagination.vue'; +import RunnerTypeTabs from '../components/runner_type_tabs.vue'; import { statusTokenConfig } from '../components/search_tokens/status_token_config'; -import { typeTokenConfig } from '../components/search_tokens/type_token_config'; import { I18N_FETCH_ERROR, GROUP_FILTERED_SEARCH_NAMESPACE, @@ -31,11 +31,12 @@ export default { name: 'GroupRunnersApp', components: { GlLink, + RegistrationDropdown, RunnerFilteredSearchBar, RunnerList, - RunnerManualSetupHelp, RunnerName, RunnerPagination, + RunnerTypeTabs, }, props: { registrationToken: { @@ -112,7 +113,7 @@ export default { }); }, searchTokens() { - return [statusTokenConfig, typeTokenConfig]; + return [statusTokenConfig]; }, filteredSearchNamespace() { return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`; @@ -144,7 +145,20 @@ export default { <template> <div> - <runner-manual-setup-help :registration-token="registrationToken" :type="$options.GROUP_TYPE" /> + <div class="gl-display-flex gl-align-items-center"> + <runner-type-tabs + v-model="search" + content-class="gl-display-none" + nav-class="gl-border-none!" + /> + + <registration-dropdown + class="gl-ml-auto" + :registration-token="registrationToken" + :type="$options.GROUP_TYPE" + right + /> + </div> <runner-filtered-search-bar v-model="search" diff --git a/app/assets/javascripts/runner/group_runners/index.js b/app/assets/javascripts/runner/group_runners/index.js index 9545764c68d..60b7a7ab541 100644 --- a/app/assets/javascripts/runner/group_runners/index.js +++ b/app/assets/javascripts/runner/group_runners/index.js @@ -1,8 +1,10 @@ +import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import GroupRunnersApp from './group_runners_app.vue'; +Vue.use(GlToast); Vue.use(VueApollo); export const initGroupRunners = (selector = '#js-group-runners') => { @@ -21,12 +23,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => { } = el.dataset; const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - assumeImmutableResults: true, - }, - ), + defaultClient: createDefaultClient(), }); return new Vue({ diff --git a/app/assets/javascripts/runner/runner_details/index.js b/app/assets/javascripts/runner/runner_details/index.js index 05e6f86869d..db8f239a3c3 100644 --- a/app/assets/javascripts/runner/runner_details/index.js +++ b/app/assets/javascripts/runner/runner_details/index.js @@ -15,12 +15,7 @@ export const initRunnerDetail = (selector = '#js-runner-details') => { const { runnerId } = el.dataset; const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - assumeImmutableResults: true, - }, - ), + defaultClient: createDefaultClient(), }); return new Vue({ diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js index 0a817ea0acf..b88023720e8 100644 --- a/app/assets/javascripts/runner/runner_search_utils.js +++ b/app/assets/javascripts/runner/runner_search_utils.js @@ -18,6 +18,50 @@ import { RUNNER_PAGE_SIZE, } from './constants'; +/** + * The filters and sorting of the runners are built around + * an object called "search" that contains the current state + * of search in the UI. For example: + * + * ``` + * const search = { + * // The current tab + * runnerType: 'INSTANCE_TYPE', + * + * // Filters in the search bar + * filters: [ + * { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + * { type: 'filtered-search-term', value: { data: '' } }, + * ], + * + * // Current sorting value + * sort: 'CREATED_DESC', + * + * // Pagination information + * pagination: { page: 1 }, + * }; + * ``` + * + * An object in this format can be used to generate URLs + * with the search parameters or by runner components + * a input using a v-model. + * + * @module runner_search_utils + */ + +/** + * Validates a search value + * @param {Object} search + * @returns {boolean} True if the value follows the search format. + */ +export const searchValidator = ({ runnerType, filters, sort }) => { + return ( + (runnerType === null || typeof runnerType === 'string') && + Array.isArray(filters) && + typeof sort === 'string' + ); +}; + const getPaginationFromParams = (params) => { const page = parseInt(params[PARAM_KEY_PAGE], 10); const after = params[PARAM_KEY_AFTER]; @@ -35,13 +79,20 @@ const getPaginationFromParams = (params) => { }; }; +/** + * Takes a URL query and transforms it into a "search" object + * @param {String?} query + * @returns {Object} A search object + */ export const fromUrlQueryToSearch = (query = window.location.search) => { const params = queryToObject(query, { gatherArrays: true }); + const runnerType = params[PARAM_KEY_RUNNER_TYPE]?.[0] || null; return { + runnerType, filters: prepareTokens( urlQueryToFilter(query, { - filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG], + filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_TAG], filteredSearchTermKey: PARAM_KEY_SEARCH, }), ), @@ -50,8 +101,15 @@ export const fromUrlQueryToSearch = (query = window.location.search) => { }; }; +/** + * Takes a "search" object and transforms it into a URL. + * + * @param {Object} search + * @param {String} url + * @returns {String} New URL for the page + */ export const fromSearchToUrl = ( - { filters = [], sort = null, pagination = {} }, + { runnerType = null, filters = [], sort = null, pagination = {} }, url = window.location.href, ) => { const filterParams = { @@ -65,6 +123,10 @@ export const fromSearchToUrl = ( }), }; + if (runnerType) { + filterParams[PARAM_KEY_RUNNER_TYPE] = [runnerType]; + } + if (!filterParams[PARAM_KEY_SEARCH]) { filterParams[PARAM_KEY_SEARCH] = null; } @@ -82,21 +144,31 @@ export const fromSearchToUrl = ( return setUrlParams({ ...filterParams, ...otherParams }, url, false, true, true); }; -export const fromSearchToVariables = ({ filters = [], sort = null, pagination = {} } = {}) => { +/** + * Takes a "search" object and transforms it into variables for runner a GraphQL query. + * + * @param {Object} search + * @returns {Object} Hash of filter values + */ +export const fromSearchToVariables = ({ + runnerType = null, + filters = [], + sort = null, + pagination = {}, +} = {}) => { const variables = {}; const queryObj = filterToQueryObject(processFilters(filters), { filteredSearchTermKey: PARAM_KEY_SEARCH, }); - variables.search = queryObj[PARAM_KEY_SEARCH]; - - // TODO Get more than one value when GraphQL API supports OR for "status" or "runner_type" [variables.status] = queryObj[PARAM_KEY_STATUS] || []; - [variables.type] = queryObj[PARAM_KEY_RUNNER_TYPE] || []; - + variables.search = queryObj[PARAM_KEY_SEARCH]; variables.tagList = queryObj[PARAM_KEY_TAG]; + if (runnerType) { + variables.type = runnerType; + } if (sort) { variables.sort = sort; } diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue index 99cf16c8350..5c7cbeac5b2 100644 --- a/app/assets/javascripts/search/sidebar/components/app.vue +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -13,9 +13,9 @@ export default { ConfidentialityFilter, }, computed: { - ...mapState(['query']), + ...mapState(['urlQuery', 'sidebarDirty']), showReset() { - return this.query.state || this.query.confidential; + return this.urlQuery.state || this.urlQuery.confidential; }, }, methods: { @@ -32,7 +32,7 @@ export default { <status-filter /> <confidentiality-filter /> <div class="gl-display-flex gl-align-items-center gl-mt-3"> - <gl-button category="primary" variant="confirm" size="small" type="submit"> + <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty"> {{ __('Apply') }} </gl-button> <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{ diff --git a/app/assets/javascripts/search/sidebar/components/radio_filter.vue b/app/assets/javascripts/search/sidebar/components/radio_filter.vue index 73911b9d319..aa7c26b8044 100644 --- a/app/assets/javascripts/search/sidebar/components/radio_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/radio_filter.vue @@ -1,7 +1,7 @@ <script> import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; -import { sprintf, s__ } from '~/locale'; +import { sprintf, __ } from '~/locale'; export default { name: 'RadioFilter', @@ -49,7 +49,7 @@ export default { ...mapActions(['setQuery']), radioLabel(filter) { return filter.value === this.ANY.value - ? sprintf(s__('Any %{header}'), { header: this.filterData.header.toLowerCase() }) + ? sprintf(__('Any %{header}'), { header: this.filterData.header.toLowerCase() }) : filter.label; }, }, diff --git a/app/assets/javascripts/search/sidebar/constants/state_filter_data.js b/app/assets/javascripts/search/sidebar/constants/state_filter_data.js index 7c9a029ffe4..2f9f8a7cb46 100644 --- a/app/assets/javascripts/search/sidebar/constants/state_filter_data.js +++ b/app/assets/javascripts/search/sidebar/constants/state_filter_data.js @@ -5,7 +5,7 @@ const header = __('Status'); const filters = { ANY: { label: __('Any'), - value: 'all', + value: null, }, OPEN: { label: __('Open'), diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index be64a9278e3..a6af5644681 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -2,9 +2,9 @@ import Api from '~/api'; import createFlash from '~/flash'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants'; +import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants'; import * as types from './mutation_types'; -import { loadDataFromLS, setFrequentItemToLS, mergeById } from './utils'; +import { loadDataFromLS, setFrequentItemToLS, mergeById, isSidebarDirty } from './utils'; export const fetchGroups = ({ commit }, search) => { commit(types.REQUEST_GROUPS); @@ -86,8 +86,12 @@ export const setFrequentProject = ({ state, commit }, item) => { commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data: frequentItems }); }; -export const setQuery = ({ commit }, { key, value }) => { +export const setQuery = ({ state, commit }, { key, value }) => { commit(types.SET_QUERY, { key, value }); + + if (SIDEBAR_PARAMS.includes(key)) { + commit(types.SET_SIDEBAR_DIRTY, isSidebarDirty(state.query, state.urlQuery)); + } }; export const applyQuery = ({ state }) => { diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js index 3abf7cac6ba..678bd82c7a6 100644 --- a/app/assets/javascripts/search/store/constants.js +++ b/app/assets/javascripts/search/store/constants.js @@ -1,3 +1,6 @@ +import { stateFilterData } from '~/search/sidebar/constants/state_filter_data'; +import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data'; + export const MAX_FREQUENT_ITEMS = 5; export const MAX_FREQUENCY = 5; @@ -5,3 +8,5 @@ export const MAX_FREQUENCY = 5; export const GROUPS_LOCAL_STORAGE_KEY = 'global-search-frequent-groups'; export const PROJECTS_LOCAL_STORAGE_KEY = 'global-search-frequent-projects'; + +export const SIDEBAR_PARAMS = [stateFilterData.filterParam, confidentialFilterData.filterParam]; diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js index 5c1c29dc738..bf1e3e79cba 100644 --- a/app/assets/javascripts/search/store/mutation_types.js +++ b/app/assets/javascripts/search/store/mutation_types.js @@ -7,5 +7,6 @@ export const RECEIVE_PROJECTS_SUCCESS = 'RECEIVE_PROJECTS_SUCCESS'; export const RECEIVE_PROJECTS_ERROR = 'RECEIVE_PROJECTS_ERROR'; export const SET_QUERY = 'SET_QUERY'; +export const SET_SIDEBAR_DIRTY = 'SET_SIDEBAR_DIRTY'; export const LOAD_FREQUENT_ITEMS = 'LOAD_FREQUENT_ITEMS'; diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js index 63156a89738..5d154fe3aa0 100644 --- a/app/assets/javascripts/search/store/mutations.js +++ b/app/assets/javascripts/search/store/mutations.js @@ -26,6 +26,9 @@ export default { [types.SET_QUERY](state, { key, value }) { state.query[key] = value; }, + [types.SET_SIDEBAR_DIRTY](state, value) { + state.sidebarDirty = value; + }, [types.LOAD_FREQUENT_ITEMS](state, { key, data }) { state.frequentItems[key] = data; }, diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js index 5b1429ccc97..d4005697f35 100644 --- a/app/assets/javascripts/search/store/state.js +++ b/app/assets/javascripts/search/store/state.js @@ -1,6 +1,8 @@ +import { cloneDeep } from 'lodash'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants'; const createState = ({ query }) => ({ + urlQuery: cloneDeep(query), query, groups: [], fetchingGroups: false, @@ -10,5 +12,6 @@ const createState = ({ query }) => ({ [GROUPS_LOCAL_STORAGE_KEY]: [], [PROJECTS_LOCAL_STORAGE_KEY]: [], }, + sidebarDirty: false, }); export default createState; diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js index b00b9bb0f2e..6b56ff0b5e5 100644 --- a/app/assets/javascripts/search/store/utils.js +++ b/app/assets/javascripts/search/store/utils.js @@ -1,5 +1,5 @@ import AccessorUtilities from '../../lib/utils/accessor'; -import { MAX_FREQUENT_ITEMS, MAX_FREQUENCY } from './constants'; +import { MAX_FREQUENT_ITEMS, MAX_FREQUENCY, SIDEBAR_PARAMS } from './constants'; function extractKeys(object, keyList) { return Object.fromEntries(keyList.map((key) => [key, object[key]])); @@ -80,3 +80,13 @@ export const mergeById = (inflatedData, storedData) => { return { ...stored, ...data }; }); }; + +export const isSidebarDirty = (currentQuery, urlQuery) => { + return SIDEBAR_PARAMS.some((param) => { + // userAddParam ensures we don't get a false dirty from null !== undefined + const userAddedParam = !urlQuery[param] && currentQuery[param]; + const userChangedExistingParam = urlQuery[param] && urlQuery[param] !== currentQuery[param]; + + return userAddedParam || userChangedExistingParam; + }); +}; diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index 6c70a8c33db..bc13150c99c 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -1,5 +1,5 @@ <script> -import { GlTab, GlTabs, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlTab, GlTabs, GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; @@ -31,6 +31,7 @@ export default { AutoDevOpsAlert, AutoDevOpsEnabledAlert, FeatureCard, + GlAlert, GlLink, GlSprintf, GlTab, @@ -79,6 +80,7 @@ export default { data() { return { autoDevopsEnabledAlertDismissedProjects: [], + errorMessage: '', }; }, computed: { @@ -106,6 +108,12 @@ export default { dismissedProjects.add(this.projectPath); this.autoDevopsEnabledAlertDismissedProjects = Array.from(dismissedProjects); }, + onError(message) { + this.errorMessage = message; + }, + dismissAlert() { + this.errorMessage = ''; + }, }, autoDevopsEnabledAlertStorageKey: AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, }; @@ -113,6 +121,16 @@ export default { <template> <article> + <gl-alert + v-if="errorMessage" + sticky + class="gl-top-8 gl-z-index-1" + data-testid="manage-via-mr-error-alert" + variant="danger" + @dismiss="dismissAlert" + > + {{ errorMessage }} + </gl-alert> <local-storage-sync v-model="autoDevopsEnabledAlertDismissedProjects" :storage-key="$options.autoDevopsEnabledAlertStorageKey" @@ -174,6 +192,7 @@ export default { data-testid="security-testing-card" :feature="feature" class="gl-mb-6" + @error="onError" /> </template> </section-layout> @@ -207,6 +226,7 @@ export default { :key="feature.type" :feature="feature" class="gl-mb-6" + @error="onError" /> </template> </section-layout> diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 6a282df99bf..9c80506549e 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -3,6 +3,7 @@ import { __, s__ } from '~/locale'; import { REPORT_TYPE_SAST, + REPORT_TYPE_SAST_IAC, REPORT_TYPE_DAST, REPORT_TYPE_DAST_PROFILES, REPORT_TYPE_SECRET_DETECTION, @@ -16,6 +17,7 @@ import { } from '~/vue_shared/security_reports/constants'; import configureSastMutation from '../graphql/configure_sast.mutation.graphql'; +import configureSastIacMutation from '../graphql/configure_iac.mutation.graphql'; import configureSecretDetectionMutation from '../graphql/configure_secret_detection.mutation.graphql'; /** @@ -30,6 +32,19 @@ export const SAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/sas anchor: 'configuration', }); +export const SAST_IAC_NAME = __('Infrastructure as Code (IaC) Scanning'); +export const SAST_IAC_SHORT_NAME = s__('ciReport|IaC Scanning'); +export const SAST_IAC_DESCRIPTION = __( + 'Analyze your infrastructure as code configuration files for known vulnerabilities.', +); +export const SAST_IAC_HELP_PATH = helpPagePath('user/application_security/iac_scanning/index'); +export const SAST_IAC_CONFIG_HELP_PATH = helpPagePath( + 'user/application_security/iac_scanning/index', + { + anchor: 'configuration', + }, +); + export const DAST_NAME = __('Dynamic Application Security Testing (DAST)'); export const DAST_SHORT_NAME = s__('ciReport|DAST'); export const DAST_DESCRIPTION = __('Analyze a review version of your web application.'); @@ -141,6 +156,27 @@ export const securityFeatures = [ // https://gitlab.com/gitlab-org/gitlab/-/issues/331621 canEnableByMergeRequest: true, }, + ...(gon?.features?.configureIacScanningViaMr + ? [ + { + name: SAST_IAC_NAME, + shortName: SAST_IAC_SHORT_NAME, + description: SAST_IAC_DESCRIPTION, + helpPath: SAST_IAC_HELP_PATH, + configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH, + type: REPORT_TYPE_SAST_IAC, + + // This field is currently hardcoded because SAST IaC is always available. + // It will eventually come from the Backend, the progress is tracked in + // https://gitlab.com/gitlab-org/gitlab/-/issues/331622 + available: true, + + // This field will eventually come from the backend, the progress is + // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621 + canEnableByMergeRequest: true, + }, + ] + : []), { name: DAST_NAME, shortName: DAST_SHORT_NAME, @@ -242,6 +278,21 @@ export const featureToMutationMap = { }, }), }, + ...(gon?.features?.configureIacScanningViaMr + ? { + [REPORT_TYPE_SAST_IAC]: { + mutationId: 'configureSastIac', + getMutationPayload: (projectPath) => ({ + mutation: configureSastIacMutation, + variables: { + input: { + projectPath, + }, + }, + }), + }, + } + : {}), [REPORT_TYPE_SECRET_DETECTION]: { mutationId: 'configureSecretDetection', getMutationPayload: (projectPath) => ({ diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue index 86afdbfeb8c..33d72b54f86 100644 --- a/app/assets/javascripts/security_configuration/components/feature_card.vue +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -66,6 +66,11 @@ export default { return Boolean(name && description && configurationText); }, }, + methods: { + onError(message) { + this.$emit('error', message); + }, + }, i18n: { enabled: s__('SecurityConfiguration|Enabled'), notEnabled: s__('SecurityConfiguration|Not enabled'), @@ -129,6 +134,7 @@ export default { category="primary" class="gl-mt-5" :data-qa-selector="`${feature.type}_mr_button`" + @error="onError" /> <gl-button diff --git a/app/assets/javascripts/security_configuration/graphql/configure_iac.mutation.graphql b/app/assets/javascripts/security_configuration/graphql/configure_iac.mutation.graphql new file mode 100644 index 00000000000..26b826ef722 --- /dev/null +++ b/app/assets/javascripts/security_configuration/graphql/configure_iac.mutation.graphql @@ -0,0 +1,6 @@ +mutation configureSastIac($input: ConfigureSastIacInput!) { + configureSastIac(input: $input) { + successPath + errors + } +} diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index 60d2c0d4e5a..a8623b468f2 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -14,7 +14,7 @@ export const initSecurityConfiguration = (el) => { Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), + defaultClient: createDefaultClient(), }); const { diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index b1c8f6ef22e..0021fe909e5 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -339,7 +339,7 @@ export default { </div> <div class="gl-display-flex"> <span class="gl-text-gray-600 gl-ml-5"> - {{ s__('SetStatusModal|A busy indicator is shown next to your name and avatar.') }} + {{ s__('SetStatusModal|An indicator appears next to your name and avatar') }} </span> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index e41bb41dc05..bdd014163a0 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -39,6 +39,9 @@ export default { assignSelf() { this.$emit('assign-self'); }, + toggleAttentionRequested(data) { + this.$emit('toggle-attention-requested', data); + }, }, }; </script> @@ -58,7 +61,12 @@ export default { </template> </span> - <uncollapsed-assignee-list v-else :users="sortedAssigness" :issuable-type="issuableType" /> + <uncollapsed-assignee-list + v-else + :users="sortedAssigness" + :issuable-type="issuableType" + @toggle-attention-requested="toggleAttentionRequested" + /> </div> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index 80caebad39d..a3379784bc1 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -32,6 +32,11 @@ export default { return this.users.length === 0; }, }, + methods: { + toggleAttentionRequested(data) { + this.$emit('toggle-attention-requested', data); + }, + }, }; </script> @@ -61,6 +66,7 @@ export default { :users="users" :issuable-type="issuableType" class="gl-text-gray-800 gl-mt-2 hide-collapsed" + @toggle-attention-requested="toggleAttentionRequested" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index c6877226b7d..453dd1b0580 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -125,6 +125,9 @@ export default { availability: this.assigneeAvailabilityStatus[username] || '', })); }, + toggleAttentionRequested(data) { + this.mediator.toggleAttentionRequested('assignee', data); + }, }, }; </script> @@ -152,6 +155,7 @@ export default { :editable="store.editable" :issuable-type="issuableType" @assign-self="assignSelf" + @toggle-attention-requested="toggleAttentionRequested" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue index c2ca87af9ce..8d5c3b2def3 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -1,6 +1,8 @@ <script> +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { IssuableType } from '~/issue_show/constants'; import { __, sprintf } from '~/locale'; +import AttentionRequestedToggle from '../attention_requested_toggle.vue'; import AssigneeAvatarLink from './assignee_avatar_link.vue'; import UserNameWithStatus from './user_name_with_status.vue'; @@ -8,9 +10,11 @@ const DEFAULT_RENDER_COUNT = 5; export default { components: { + AttentionRequestedToggle, AssigneeAvatarLink, UserNameWithStatus, }, + mixins: [glFeatureFlagsMixin()], props: { users: { type: Array, @@ -32,6 +36,10 @@ export default { return this.users[0]; }, hasOneUser() { + if (this.showVerticalList) { + return false; + } + return this.users.length === 1; }, hiddenAssigneesLabel() { @@ -45,6 +53,10 @@ export default { return this.users.length - DEFAULT_RENDER_COUNT; }, uncollapsedUsers() { + if (this.showVerticalList) { + return this.users; + } + const uncollapsedLength = this.showLess ? Math.min(this.users.length, DEFAULT_RENDER_COUNT) : this.users.length; @@ -53,6 +65,12 @@ export default { username() { return `@${this.firstUser.username}`; }, + showVerticalList() { + return this.glFeatures.mrAttentionRequests && this.isMergeRequest; + }, + isMergeRequest() { + return this.issuableType === IssuableType.MergeRequest; + }, }, methods: { toggleShowLess() { @@ -64,6 +82,9 @@ export default { } return u?.status?.availability || ''; }, + toggleAttentionRequested(data) { + this.$emit('toggle-attention-requested', data); + }, }, }; </script> @@ -84,11 +105,34 @@ export default { <div v-else> <div class="gl-display-flex gl-flex-wrap"> <div - v-for="user in uncollapsedUsers" + v-for="(user, index) in uncollapsedUsers" :key="user.id" - class="user-item gl-display-inline-block" + :class="{ + 'user-item': !showVerticalList, + 'gl-mb-3': index !== users.length - 1 && showVerticalList, + }" + class="gl-display-inline-block" > - <assignee-avatar-link :user="user" :issuable-type="issuableType" /> + <attention-requested-toggle + v-if="showVerticalList && user.can_update_merge_request" + :user="user" + type="assignee" + @toggle-attention-requested="toggleAttentionRequested" + /> + <assignee-avatar-link + :user="user" + :issuable-type="issuableType" + :tooltip-has-name="!showVerticalList" + > + <div + v-if="showVerticalList" + class="gl-ml-3 gl-line-height-normal gl-display-grid" + data-testid="username" + > + <user-name-with-status :name="user.name" :availability="userAvailability(user)" /> + <span>@{{ user.username }}</span> + </div> + </assignee-avatar-link> </div> </div> <div v-if="renderShowMoreSection" class="user-list-more gl-hover-text-blue-800"> diff --git a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue new file mode 100644 index 00000000000..38ba468d197 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue @@ -0,0 +1,74 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; + +export default { + i18n: { + attentionRequestedReviewer: __('Request attention to review'), + attentionRequestedAssignee: __('Request attention'), + removeAttentionRequested: __('Remove attention request'), + }, + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + type: { + type: String, + required: true, + }, + user: { + type: Object, + required: true, + }, + }, + data() { + return { + loading: false, + }; + }, + computed: { + tooltipTitle() { + if (this.user.attention_requested) { + return this.$options.i18n.removeAttentionRequested; + } + + return this.type === 'reviewer' + ? this.$options.i18n.attentionRequestedReviewer + : this.$options.i18n.attentionRequestedAssignee; + }, + }, + methods: { + toggleAttentionRequired() { + if (this.loading) return; + + this.$root.$emit(BV_HIDE_TOOLTIP); + this.loading = true; + this.$emit('toggle-attention-requested', { + user: this.user, + callback: this.toggleAttentionRequiredComplete, + }); + }, + toggleAttentionRequiredComplete() { + this.loading = false; + }, + }, +}; +</script> + +<template> + <span v-gl-tooltip.left.viewport="tooltipTitle"> + <gl-button + :loading="loading" + :variant="user.attention_requested ? 'warning' : 'default'" + :icon="user.attention_requested ? 'star' : 'star-o'" + :aria-label="tooltipTitle" + size="small" + category="tertiary" + @click="toggleAttentionRequired" + /> + </span> +</template> diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue index d5647619ea3..5cd4a1a5192 100644 --- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue +++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue @@ -11,6 +11,7 @@ import { toLabelGid } from '~/sidebar/utils'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; +import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; const mutationMap = { @@ -48,6 +49,7 @@ export default { return { isLabelsSelectInProgress: false, selectedLabels: this.initiallySelectedLabels, + LabelType, }; }, methods: { @@ -154,13 +156,12 @@ export default { :footer-manage-label-title="__('Manage project labels')" :labels-create-title="__('Create project label')" :labels-filter-base-path="projectIssuesPath" - :labels-select-in-progress="isLabelsSelectInProgress" - :selected-labels="selectedLabels" :variant="$options.variant" :issuable-type="issuableType" + workspace-type="project" + :attr-workspace-path="fullPath" + :label-create-type="LabelType.project" data-qa-selector="labels_block" - @onLabelRemove="handleLabelRemove" - @updateSelectedLabels="handleUpdateSelectedLabels" > {{ __('None') }} </labels-select-widget> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue index 5729b958b5d..b07fd944ff9 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue @@ -49,6 +49,9 @@ export default { requestReview(data) { this.$emit('request-review', data); }, + toggleAttentionRequested(data) { + this.$emit('toggle-attention-requested', data); + }, }, }; </script> @@ -70,6 +73,7 @@ export default { :root-path="rootPath" :issuable-type="issuableType" @request-review="requestReview" + @toggle-attention-requested="toggleAttentionRequested" /> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue index e414aaf719b..2ea63219e92 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue @@ -88,6 +88,9 @@ export default { requestReview(data) { this.mediator.requestReview(data); }, + toggleAttentionRequested(data) { + this.mediator.toggleAttentionRequested('reviewer', data); + }, }, }; </script> @@ -106,6 +109,7 @@ export default { :editable="store.editable" :issuable-type="issuableType" @request-review="requestReview" + @toggle-attention-requested="toggleAttentionRequested" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue index 2922008cfb2..adaf1b65f3f 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -1,6 +1,8 @@ <script> import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __, sprintf, s__ } from '~/locale'; +import AttentionRequestedToggle from '../attention_requested_toggle.vue'; import ReviewerAvatarLink from './reviewer_avatar_link.vue'; const LOADING_STATE = 'loading'; @@ -14,10 +16,12 @@ export default { GlButton, GlIcon, ReviewerAvatarLink, + AttentionRequestedToggle, }, directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], props: { users: { type: Array, @@ -76,6 +80,9 @@ export default { this.loadingStates[userId] = null; } }, + toggleAttentionRequested(data) { + this.$emit('toggle-attention-requested', data); + }, }, LOADING_STATE, SUCCESS_STATE, @@ -90,6 +97,12 @@ export default { :class="{ 'gl-mb-3': index !== users.length - 1 }" data-testid="reviewer" > + <attention-requested-toggle + v-if="glFeatures.mrAttentionRequests && user.can_update_merge_request" + :user="user" + type="reviewer" + @toggle-attention-requested="toggleAttentionRequested" + /> <reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType"> <div class="gl-ml-3 gl-line-height-normal gl-display-grid"> <span>{{ user.name }}</span> @@ -113,7 +126,9 @@ export default { data-testid="re-request-success" /> <gl-button - v-else-if="user.can_update_merge_request && user.reviewed" + v-else-if=" + user.can_update_merge_request && user.reviewed && !glFeatures.mrAttentionRequests + " v-gl-tooltip.left :title="$options.i18n.reRequestReview" :aria-label="$options.i18n.reRequestReview" diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 8f4d5406da8..0ba8c4f8907 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -370,6 +370,7 @@ export default { :loading="loading" class="gl-w-full" toggle-class="gl-max-w-100" + block @shown="setFocus" > <gl-search-box-by-type ref="search" v-model="searchTerm" /> diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index 22adbd79ef6..056b3e98a1c 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -134,7 +134,7 @@ export default { v-if="canUpdate && !initialLoading && canEdit" category="tertiary" size="small" - class="gl-text-gray-900! gl-ml-auto hide-collapsed gl-mr-n2" + class="gl-text-gray-900! gl-ml-auto hide-collapsed gl-mr-n2 shortcut-sidebar-dropdown-toggle" data-testid="edit-button" :data-track-action="tracking.event" :data-track-label="tracking.label" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue index d4a8abb81a8..5d4031ac68b 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { GlLoadingIcon, GlTableLite } from '@gitlab/ui'; import createFlash from '~/flash'; import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -12,7 +12,7 @@ const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)'; export default { components: { GlLoadingIcon, - GlTable, + GlTableLite, }, inject: ['issuableType'], props: { @@ -89,7 +89,7 @@ export default { <template> <div> <div v-if="isLoading"><gl-loading-icon size="md" /></div> - <gl-table v-else :items="report" :fields="$options.fields" foot-clone> + <gl-table-lite v-else :items="report" :fields="$options.fields" foot-clone> <template #cell(spentAt)="{ item: { spentAt } }"> <div>{{ formatDate(spentAt) }}</div> </template> @@ -111,6 +111,6 @@ export default { <div>{{ getSummary(summary, note) }}</div> </template> <template #foot(note)> </template> - </gl-table> + </gl-table-lite> </div> </template> diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index e593973da82..ac34a75ac5c 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,4 +1,5 @@ -import { IssuableType } from '~/issue_show/constants'; +import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; +import { IssuableType, WorkspaceType } from '~/issue_show/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql'; import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql'; @@ -29,11 +30,14 @@ import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_conf import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql'; import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subscription.mutation.graphql'; import mergeRequestMilestoneMutation from '~/sidebar/queries/update_merge_request_milestone.mutation.graphql'; +import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql'; import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql'; import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; import epicLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql'; +import updateEpicLabelsMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; import groupLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql'; import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql'; +import mergeRequestLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql'; import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; import getAlertAssignees from '~/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql'; import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; @@ -109,14 +113,30 @@ export const referenceQueries = { }, }; -export const labelsQueries = { +export const workspaceLabelsQueries = { + [WorkspaceType.project]: { + query: projectLabelsQuery, + }, + [WorkspaceType.group]: { + query: groupLabelsQuery, + }, +}; + +export const issuableLabelsQueries = { [IssuableType.Issue]: { issuableQuery: issueLabelsQuery, - workspaceQuery: projectLabelsQuery, + mutation: updateIssueLabelsMutation, + mutationName: 'updateIssue', + }, + [IssuableType.MergeRequest]: { + issuableQuery: mergeRequestLabelsQuery, + mutation: updateMergeRequestLabelsMutation, + mutationName: 'mergeRequestSetLabels', }, [IssuableType.Epic]: { issuableQuery: epicLabelsQuery, - workspaceQuery: groupLabelsQuery, + mutation: updateEpicLabelsMutation, + mutationName: 'updateEpic', }, }; diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js index 1a806a051b7..6a670db2d38 100644 --- a/app/assets/javascripts/sidebar/graphql.js +++ b/app/assets/javascripts/sidebar/graphql.js @@ -25,7 +25,6 @@ export const defaultClient = createDefaultClient(resolvers, { cacheConfig: { fragmentMatcher, }, - assumeImmutableResults: true, }); export const apolloProvider = new VueApollo({ diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 9f5a2f4ebb0..898be4a97ce 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -260,6 +260,10 @@ export function mountSidebarLabels() { variant: DropdownVariant.Sidebar, canUpdate: parseBoolean(el.dataset.canEdit), isClassicSidebar: true, + issuableType: + isInIssuePage() || isInIncidentPage() || isInDesignPage() + ? IssuableType.Issue + : IssuableType.MergeRequest, }, render: (createElement) => createElement(SidebarLabels), }); diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql index 2aff7da4605..dd85eb1631b 100644 --- a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql +++ b/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql @@ -1,4 +1,4 @@ -query($fullPath: ID!, $iid: String!) { +query sidebarDetails($fullPath: ID!, $iid: String!) { project(fullPath: $fullPath) { issue(iid: $iid) { iid diff --git a/app/assets/javascripts/sidebar/queries/toggle_attention_requested.mutation.graphql b/app/assets/javascripts/sidebar/queries/toggle_attention_requested.mutation.graphql new file mode 100644 index 00000000000..a9f4af6e1b9 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/toggle_attention_requested.mutation.graphql @@ -0,0 +1,7 @@ +mutation mergeRequestToggleAttentionRequested($projectPath: ID!, $iid: String!, $userId: ID!) { + mergeRequestToggleAttentionRequested( + input: { projectPath: $projectPath, iid: $iid, userId: $userId } + ) { + errors + } +} diff --git a/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql b/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql index 28a47735143..2c6f379744e 100644 --- a/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql @@ -1,4 +1,4 @@ -mutation($projectPath: ID!, $iid: String!, $healthStatus: HealthStatus) { +mutation updateIssueHealthStatus($projectPath: ID!, $iid: String!, $healthStatus: HealthStatus) { updateIssue(input: { projectPath: $projectPath, iid: $iid, healthStatus: $healthStatus }) { issuable: issue { id diff --git a/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql index 69927ddd205..2d58843140f 100644 --- a/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql @@ -1,4 +1,4 @@ -mutation updateEpic($input: UpdateEpicInput!) { +mutation updateIssuableConfidential($input: UpdateEpicInput!) { issuableSetConfidential: updateEpic(input: $input) { issuable: epic { id diff --git a/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql index af43766aed5..4a3090f3836 100644 --- a/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql @@ -1,4 +1,4 @@ -mutation epicSetSubscription($fullPath: ID!, $iid: ID!, $subscribedState: Boolean!) { +mutation boardEpicSetSubscription($fullPath: ID!, $iid: ID!, $subscribedState: Boolean!) { updateIssuableSubscription: epicSetSubscription( input: { groupPath: $fullPath, iid: $iid, subscribedState: $subscribedState } ) { diff --git a/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql index 317b48c142d..2e6bc8c36ba 100644 --- a/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql @@ -1,4 +1,4 @@ -mutation updateEpic($input: UpdateEpicInput!) { +mutation updateEpicTitle($input: UpdateEpicInput!) { updateIssuableTitle: updateEpic(input: $input) { epic { title diff --git a/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql index 81891fb601f..e1a3927e7e1 100644 --- a/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql @@ -1,4 +1,4 @@ -mutation issueSetSubscription($fullPath: ID!, $iid: String!, $subscribedState: Boolean!) { +mutation projectIssueSetSubscription($fullPath: ID!, $iid: String!, $subscribedState: Boolean!) { updateIssuableSubscription: issueSetSubscription( input: { projectPath: $fullPath, iid: $iid, subscribedState: $subscribedState } ) { diff --git a/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql index 3c09daad793..016c31ea096 100644 --- a/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql @@ -2,6 +2,7 @@ mutation mergeRequestSetLabels($input: MergeRequestSetLabelsInput!) { mergeRequestSetLabels(input: $input) { errors mergeRequest { + id labels { nodes { color diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index cea26acd101..d8ab8f1c65b 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -5,6 +5,7 @@ import createGqClient, { fetchPolicies } from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; import reviewerRereviewMutation from '../queries/reviewer_rereview.mutation.graphql'; import sidebarDetailsMRQuery from '../queries/sidebarDetailsMR.query.graphql'; +import toggleAttentionRequestedMutation from '../queries/toggle_attention_requested.mutation.graphql'; const queries = { merge_request: sidebarDetailsMRQuery, @@ -90,4 +91,15 @@ export default class SidebarService { }, }); } + + toggleAttentionRequested(userId) { + return gqClient.mutate({ + mutation: toggleAttentionRequestedMutation, + variables: { + userId: convertToGraphQLId(TYPE_USER, `${userId}`), + projectPath: this.fullPath, + iid: this.iid.toString(), + }, + }); + } } diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 9144e3b08db..86580744ccc 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -1,6 +1,6 @@ import Store from 'ee_else_ce/sidebar/stores/sidebar_store'; import createFlash from '~/flash'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; import { visitUrl } from '../lib/utils/url_utility'; import Service from './services/sidebar_service'; @@ -56,13 +56,52 @@ export default class SidebarMediator { return this.service .requestReview(userId) .then(() => { - this.store.updateReviewer(userId); + this.store.updateReviewer(userId, 'reviewed'); toast(__('Requested review')); callback(userId, true); }) .catch(() => callback(userId, false)); } + async toggleAttentionRequested(type, { user, callback }) { + try { + const isReviewer = type === 'reviewer'; + const reviewerOrAssignee = isReviewer + ? this.store.findReviewer(user) + : this.store.findAssignee(user); + + await this.service.toggleAttentionRequested(user.id); + + if (reviewerOrAssignee.attention_requested) { + toast( + sprintf(__('Removed attention request from @%{username}'), { + username: user.username, + }), + ); + } else { + toast(sprintf(__('Requested attention from @%{username}'), { username: user.username })); + } + + this.store.updateReviewer(user.id, 'attention_requested'); + this.store.updateAssignee(user.id, 'attention_requested'); + + callback(); + } catch (error) { + callback(); + createFlash({ + message: sprintf(__('Updating the attention request for %{username} failed.'), { + username: user.username, + }), + error, + captureError: true, + actionConfig: { + title: __('Try again'), + clickHandler: () => this.toggleAttentionRequired(type, { user, callback }), + }, + }); + } + } + setMoveToProjectId(projectId) { this.store.setMoveToProjectId(projectId); } diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index 94c54fc0980..5376791469e 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -82,11 +82,19 @@ export default class SidebarStore { } } - updateReviewer(id) { + updateAssignee(id, stateKey) { + const assignee = this.findAssignee({ id }); + + if (assignee) { + assignee[stateKey] = !assignee[stateKey]; + } + } + + updateReviewer(id, stateKey) { const reviewer = this.findReviewer({ id }); if (reviewer) { - reviewer.reviewed = false; + reviewer[stateKey] = !reviewer[stateKey]; } } diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js index 8e7368ef804..21f38c4d8c9 100644 --- a/app/assets/javascripts/snippets/index.js +++ b/app/assets/javascripts/snippets/index.js @@ -18,7 +18,6 @@ export default function appFactory(el, Component) { {}, { batchMax: 1, - assumeImmutableResults: true, }, ), }); diff --git a/app/assets/javascripts/static_site_editor/graphql/index.js b/app/assets/javascripts/static_site_editor/graphql/index.js index 2ae2baddbcc..53572e680e5 100644 --- a/app/assets/javascripts/static_site_editor/graphql/index.js +++ b/app/assets/javascripts/static_site_editor/graphql/index.js @@ -22,7 +22,6 @@ const createApolloProvider = (appData) => { }, { typeDefs, - assumeImmutableResults: true, }, ); diff --git a/app/assets/javascripts/static_site_editor/pages/success.vue b/app/assets/javascripts/static_site_editor/pages/success.vue index 70e692a0c86..eb03aa3cca3 100644 --- a/app/assets/javascripts/static_site_editor/pages/success.vue +++ b/app/assets/javascripts/static_site_editor/pages/success.vue @@ -30,7 +30,7 @@ export default { updatedFileDescription() { const { sourcePath } = this.appData; - return sprintf(s__('Update %{sourcePath} file'), { sourcePath }); + return sprintf(__('Update %{sourcePath} file'), { sourcePath }); }, }, created() { diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index 93353b400e5..79a30340856 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -12,6 +12,7 @@ export default class TaskList { this.lockVersion = options.lockVersion; this.taskListContainerSelector = `${this.selector} .js-task-list-container`; this.updateHandler = this.update.bind(this); + this.onUpdate = options.onUpdate || (() => {}); this.onSuccess = options.onSuccess || (() => {}); this.onError = options.onError || @@ -96,6 +97,7 @@ export default class TaskList { }, }; + this.onUpdate(); this.disableTaskListItems(e); return axios diff --git a/app/assets/javascripts/terms/components/app.vue b/app/assets/javascripts/terms/components/app.vue new file mode 100644 index 00000000000..aedf5b6acfe --- /dev/null +++ b/app/assets/javascripts/terms/components/app.vue @@ -0,0 +1,117 @@ +<script> +import $ from 'jquery'; +import { GlButton, GlIntersectionObserver, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; + +import { FLASH_TYPES, FLASH_CLOSED_EVENT } from '~/flash'; +import { isLoggedIn } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import csrf from '~/lib/utils/csrf'; +import '~/behaviors/markdown/render_gfm'; + +export default { + name: 'TermsApp', + i18n: { + accept: __('Accept terms'), + continue: __('Continue'), + decline: __('Decline and sign out'), + }, + flashElements: [], + csrf, + directives: { + SafeHtml, + }, + components: { GlButton, GlIntersectionObserver }, + inject: ['terms', 'permissions', 'paths'], + data() { + return { + acceptDisabled: true, + }; + }, + computed: { + isLoggedIn, + }, + mounted() { + this.renderGFM(); + this.setScrollableViewportHeight(); + + this.$options.flashElements = [ + ...document.querySelectorAll( + Object.values(FLASH_TYPES) + .map((flashType) => `.flash-${flashType}`) + .join(','), + ), + ]; + + this.$options.flashElements.forEach((flashElement) => { + flashElement.addEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose); + }); + }, + beforeDestroy() { + this.$options.flashElements.forEach((flashElement) => { + flashElement.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose); + }); + }, + methods: { + renderGFM() { + $(this.$refs.gfmContainer).renderGFM(); + }, + handleBottomReached() { + this.acceptDisabled = false; + }, + setScrollableViewportHeight() { + // Reset `max-height` inline style + this.$refs.scrollableViewport.style.maxHeight = ''; + + const { scrollHeight, clientHeight } = document.documentElement; + + // Set `max-height` to 100vh minus all elements that are NOT the scrollable viewport (header, footer, alerts, etc) + this.$refs.scrollableViewport.style.maxHeight = `calc(100vh - ${ + scrollHeight - clientHeight + }px)`; + }, + handleFlashClose(event) { + this.setScrollableViewportHeight(); + event.target.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose); + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-card-body gl-relative gl-pb-0 gl-px-0" data-qa-selector="terms_content"> + <div + class="terms-fade gl-absolute gl-left-5 gl-right-5 gl-bottom-0 gl-h-11 gl-pointer-events-none" + ></div> + <div + ref="scrollableViewport" + data-testid="scrollable-viewport" + class="gl-h-100vh gl-overflow-y-auto gl-pb-11 gl-px-5" + > + <div ref="gfmContainer" v-safe-html="terms"></div> + <gl-intersection-observer @appear="handleBottomReached"> + <div></div> + </gl-intersection-observer> + </div> + </div> + <div v-if="isLoggedIn" class="gl-card-footer gl-display-flex gl-justify-content-end"> + <form v-if="permissions.canDecline" method="post" :action="paths.decline"> + <gl-button type="submit">{{ $options.i18n.decline }}</gl-button> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + </form> + <form v-if="permissions.canAccept" class="gl-ml-3" method="post" :action="paths.accept"> + <gl-button + type="submit" + variant="confirm" + :disabled="acceptDisabled" + data-qa-selector="accept_terms_button" + >{{ $options.i18n.accept }}</gl-button + > + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + </form> + <gl-button v-else class="gl-ml-3" :href="paths.root" variant="confirm">{{ + $options.i18n.continue + }}</gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/terms/index.js b/app/assets/javascripts/terms/index.js new file mode 100644 index 00000000000..9d60fdfb50a --- /dev/null +++ b/app/assets/javascripts/terms/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; + +import TermsApp from 'jh_else_ce/terms/components/app.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +export const initTermsApp = () => { + const el = document.getElementById('js-terms-of-service'); + + if (!el) return false; + + const { terms, permissions, paths } = convertObjectPropsToCamelCase( + JSON.parse(el.dataset.termsData), + { deep: true }, + ); + + return new Vue({ + el, + provide: { terms, permissions, paths }, + render(createElement) { + return createElement(TermsApp); + }, + }); +}; diff --git a/app/assets/javascripts/token_access/index.js b/app/assets/javascripts/token_access/index.js index 8d29a65d705..6a29883290a 100644 --- a/app/assets/javascripts/token_access/index.js +++ b/app/assets/javascripts/token_access/index.js @@ -6,7 +6,7 @@ import TokenAccess from './components/token_access.vue'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), + defaultClient: createDefaultClient(), }); export const initTokenAccess = (containerId = 'js-ci-token-access-app') => { diff --git a/app/assets/javascripts/user_lists/components/user_lists_table.vue b/app/assets/javascripts/user_lists/components/user_lists_table.vue index 765f59228a6..ccc2bfabb56 100644 --- a/app/assets/javascripts/user_lists/components/user_lists_table.vue +++ b/app/assets/javascripts/user_lists/components/user_lists_table.vue @@ -23,7 +23,7 @@ export default { translations: { createdTimeagoLabel: s__('UserList|created %{timeago}'), deleteListTitle: s__('UserList|Delete %{name}?'), - deleteListMessage: s__('User list %{name} will be removed. Are you sure?'), + deleteListMessage: __('User list %{name} will be removed. Are you sure?'), editUserListLabel: s__('FeatureFlags|Edit User List'), }, modal: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue new file mode 100644 index 00000000000..492e68b636f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue @@ -0,0 +1,77 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { escape } from 'lodash'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { n__, s__ } from '~/locale'; + +const mergeCommitCount = s__('mrWidgetCommitsAdded|1 merge commit'); + +export default { + components: { + GlSprintf, + }, + mixins: [glFeatureFlagMixin()], + props: { + isSquashEnabled: { + type: Boolean, + required: false, + default: false, + }, + isFastForwardEnabled: { + type: Boolean, + required: true, + }, + commitsCount: { + type: Number, + required: false, + default: 0, + }, + targetBranch: { + type: String, + required: true, + }, + }, + computed: { + targetBranchEscaped() { + return escape(this.targetBranch); + }, + commitsCountMessage() { + return n__('%d commit', '%d commits', this.isSquashEnabled ? 1 : this.commitsCount); + }, + message() { + return this.isFastForwardEnabled + ? s__('mrWidgetCommitsAdded|Adds %{commitCount} to %{targetBranch}.') + : s__( + 'mrWidgetCommitsAdded|Adds %{commitCount} and %{mergeCommitCount} to %{targetBranch}%{squashedCommits}.', + ); + }, + textDecorativeComponent() { + return this.glFeatures.restructuredMrWidget ? 'span' : 'strong'; + }, + }, + mergeCommitCount, +}; +</script> + +<template> + <span> + <gl-sprintf :message="message"> + <template #commitCount> + <component :is="textDecorativeComponent" class="commits-count-message">{{ + commitsCountMessage + }}</component> + </template> + <template #mergeCommitCount> + <component :is="textDecorativeComponent">{{ $options.mergeCommitCount }}</component> + </template> + <template #targetBranch> + <span class="label-branch">{{ targetBranchEscaped }}</span> + </template> + <template #squashedCommits> + <template v-if="glFeatures.restructuredMrWidget && isSquashEnabled"> + {{ n__('(squashes %d commit)', '(squashes %d commits)', commitsCount) }}</template + ></template + > + </gl-sprintf> + </span> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue index 0c4a5ee35d9..25dbb614c1d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue @@ -1,7 +1,11 @@ <script> import { toNounSeriesText } from '~/lib/utils/grammar'; import { n__, sprintf } from '~/locale'; -import { APPROVED_MESSAGE } from '~/vue_merge_request_widget/components/approvals/messages'; +import { + APPROVED_BY_YOU_AND_OTHERS, + APPROVED_BY_YOU, + APPROVED_BY_OTHERS, +} from '~/vue_merge_request_widget/components/approvals/messages'; import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; export default { @@ -29,12 +33,23 @@ export default { }, }, computed: { - message() { - if (this.approved) { - return APPROVED_MESSAGE; + approvalLeftMessage() { + if (this.rulesLeft.length) { + return sprintf( + n__( + 'Requires %{count} approval from %{names}.', + 'Requires %{count} approvals from %{names}.', + this.approvalsLeft, + ), + { + names: toNounSeriesText(this.rulesLeft), + count: this.approvalsLeft, + }, + false, + ); } - if (!this.rulesLeft.length) { + if (!this.approved) { return n__( 'Requires %d approval from eligible users.', 'Requires %d approvals from eligible users.', @@ -42,32 +57,51 @@ export default { ); } - return sprintf( - n__( - 'Requires %{count} approval from %{names}.', - 'Requires %{count} approvals from %{names}.', - this.approvalsLeft, - ), - { - names: toNounSeriesText(this.rulesLeft), - count: this.approvalsLeft, - }, - false, - ); + return ''; + }, + message() { + if (this.approvedByMe && this.approvedByOthers) { + return APPROVED_BY_YOU_AND_OTHERS; + } + + if (this.approvedByMe) { + return APPROVED_BY_YOU; + } + + if (this.approved) { + return APPROVED_BY_OTHERS; + } + + return ''; }, hasApprovers() { return Boolean(this.approvers.length); }, + approvedByMe() { + if (!this.currentUserId) { + return false; + } + return this.approvers.some((approver) => approver.id === this.currentUserId); + }, + approvedByOthers() { + if (!this.currentUserId) { + return false; + } + return this.approvers.some((approver) => approver.id !== this.currentUserId); + }, + currentUserId() { + return gon.current_user_id; + }, }, - APPROVED_MESSAGE, }; </script> <template> <div data-qa-selector="approvals_summary_content"> - <strong>{{ message }}</strong> + <strong>{{ approvalLeftMessage }}</strong> <template v-if="hasApprovers"> - <span>{{ s__('mrWidget|Approved by') }}</span> + <span v-if="approvalLeftMessage">{{ message }}</span> + <strong v-else>{{ message }}</strong> <user-avatar-list class="d-inline-block align-middle" :items="approvers" /> </template> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js b/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js index 0538c38307b..fbdefa95630 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js @@ -6,4 +6,6 @@ export const FETCH_ERROR = s__( ); export const APPROVE_ERROR = s__('mrWidget|An error occurred while submitting your approval.'); export const UNAPPROVE_ERROR = s__('mrWidget|An error occurred while removing your approval.'); -export const APPROVED_MESSAGE = s__('mrWidget|Merge request approved.'); +export const APPROVED_BY_YOU_AND_OTHERS = s__('mrWidget|Approved by you and others'); +export const APPROVED_BY_YOU = s__('mrWidget|Approved by you'); +export const APPROVED_BY_OTHERS = s__('mrWidget|Approved by'); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue index cbace1ad57c..f4f611dfd1b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue @@ -12,13 +12,12 @@ import { CANCELED, SKIPPED, } from './constants'; -import MemoryUsage from './memory_usage.vue'; export default { name: 'DeploymentInfo', components: { GlLink, - MemoryUsage, + MemoryUsage: () => import('./memory_usage.vue'), TooltipOnTruncate, }, directives: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue index d3384903cce..655acf28253 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue @@ -2,10 +2,11 @@ import { GlSprintf } from '@gitlab/ui'; import { n__ } from '~/locale'; import MrCollapsibleExtension from '../mr_collapsible_extension.vue'; +import Deployment from './deployment.vue'; export default { components: { - Deployment: () => import('./deployment.vue'), + Deployment, GlSprintf, MrCollapsibleExtension, }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue index 023367a794e..33a83aef057 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue @@ -24,13 +24,20 @@ export default { return sprintf(__('%{widget} options'), { widget: this.widget }); }, }, + methods: { + onClickAction(action) { + if (action.onClick) { + action.onClick(); + } + }, + }, }; </script> <template> <div> <gl-dropdown - v-if="tertiaryButtons" + v-if="tertiaryButtons.length" :text="dropdownLabel" icon="ellipsis_v" no-caret @@ -47,6 +54,7 @@ export default { :key="index" :href="btn.href" :target="btn.target" + @click="onClickAction(btn)" > {{ btn.text }} </gl-dropdown-item> @@ -57,11 +65,12 @@ export default { :key="index" :href="btn.href" :target="btn.target" - :class="{ 'gl-mr-3': index > 1 }" + :class="{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }" category="tertiary" variant="confirm" size="small" - class="gl-display-none gl-md-display-block" + class="gl-display-none gl-md-display-block gl-float-left" + @click="onClickAction(btn)" > {{ btn.text }} </gl-button> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index 298f7c7ad8c..6f10f788952 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -8,6 +8,8 @@ import { GlTooltipDirective, GlIntersectionObserver, } from '@gitlab/ui'; +import { once } from 'lodash'; +import api from '~/api'; import { sprintf, s__, __ } from '~/locale'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; import { EXTENSION_ICON_CLASS } from '../../constants'; @@ -102,8 +104,15 @@ export default { }); }, methods: { + triggerRedisTracking: once(function triggerRedisTracking() { + if (this.$options.expandEvent) { + api.trackRedisHllUserEvent(this.$options.expandEvent); + } + }), toggleCollapsed() { this.isCollapsed = !this.isCollapsed; + + this.triggerRedisTracking(); }, loadAllData() { if (this.fullData) return; @@ -143,7 +152,10 @@ export default { :is-loading="isLoadingSummary" :icon-name="statusIconName" /> - <div class="media-body gl-display-flex gl-flex-direction-row!"> + <div + class="media-body gl-display-flex gl-flex-direction-row!" + data-testid="widget-extension-top-level" + > <div class="gl-flex-grow-1"> <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template> <div v-else v-safe-html="summary(collapsedData)"></div> @@ -194,20 +206,28 @@ export default { class="gl-display-flex gl-align-items-center gl-py-3 gl-pl-7" data-testid="extension-list-item" > - <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" /> + <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" class="gl-pl-0" /> <gl-intersection-observer :options="{ rootMargin: '100px', thresholds: 0.1 }" - class="gl-flex-wrap gl-align-self-center gl-display-flex" + class="gl-flex-wrap gl-display-flex gl-w-full" @appear="appear(index)" @disappear="disappear(index)" > - <div v-safe-html="data.text" class="gl-mr-4"></div> + <div + v-safe-html="data.text" + class="gl-mr-4 gl-display-flex gl-align-items-center" + ></div> <div v-if="data.link"> <gl-link :href="data.link.href">{{ data.link.text }}</gl-link> </div> <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> {{ data.badge.text }} </gl-badge> + <actions + :widget="$options.label || $options.name" + :tertiary-buttons="data.actions" + class="gl-ml-auto" + /> </gl-intersection-observer> </li> </smart-virtual-list> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js index 4ca0b660696..ec6e6ed2620 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js @@ -12,6 +12,7 @@ export const registerExtension = (extension) => { name: extension.name, props: extension.props, i18n: extension.i18n, + expandEvent: extension.expandEvent, computed: { ...Object.keys(extension.computed).reduce( (acc, computedKey) => ({ diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 5c67b9c7ab5..9070cb1fe65 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -151,7 +151,7 @@ export default { right data-qa-selector="download_dropdown" > - <gl-dropdown-section-header>{{ s__('Download as') }}</gl-dropdown-section-header> + <gl-dropdown-section-header>{{ __('Download as') }}</gl-dropdown-section-header> <gl-dropdown-item :href="mr.emailPatchesPath" class="js-download-email-patches" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 9bb955c534f..f7c952f9ef6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -101,6 +101,9 @@ export default { ? this.pipeline.details.status : {}; }, + artifacts() { + return this.pipeline?.details?.artifacts; + }, hasStages() { return this.pipeline?.details?.stages?.length > 0; }, @@ -285,7 +288,7 @@ export default { /> </span> <linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" /> - <pipeline-artifacts :pipeline-id="pipeline.id" class="gl-ml-3" /> + <pipeline-artifacts :pipeline-id="pipeline.id" :artifacts="artifacts" class="gl-ml-3" /> </span> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue index 306026072a3..c314261d3f5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue @@ -1,8 +1,10 @@ <script> import { s__, n__ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'MRWidgetRelatedLinks', + mixins: [glFeatureFlagMixin()], props: { relatedLinks: { type: Object, @@ -14,6 +16,11 @@ export default { required: false, default: '', }, + showAssignToMe: { + type: Boolean, + required: false, + default: true, + }, }, computed: { closesText() { @@ -30,16 +37,25 @@ export default { }; </script> <template> - <section class="mr-info-list gl-ml-7 gl-pb-5"> - <p v-if="relatedLinks.closing"> + <section> + <p + v-if="relatedLinks.closing" + :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }" + > {{ closesText }} <span v-html="relatedLinks.closing /* eslint-disable-line vue/no-v-html */"></span> </p> - <p v-if="relatedLinks.mentioned"> + <p + v-if="relatedLinks.mentioned" + :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }" + > {{ n__('mrWidget|Mentions issue', 'mrWidget|Mentions issues', relatedLinks.mentionedCount) }} <span v-html="relatedLinks.mentioned /* eslint-disable-line vue/no-v-html */"></span> </p> - <p v-if="relatedLinks.assignToMe"> + <p + v-if="relatedLinks.assignToMe && showAssignToMe" + :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }" + > <span v-html="relatedLinks.assignToMe /* eslint-disable-line vue/no-v-html */"></span> </p> </section> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue index f3673005c45..cd5b7c3110d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue @@ -4,9 +4,7 @@ import Tracking from '~/tracking'; import DismissibleContainer from '~/vue_shared/components/dismissible_container.vue'; import { SP_TRACK_LABEL, - SP_LINK_TRACK_EVENT, SP_SHOW_TRACK_EVENT, - SP_LINK_TRACK_VALUE, SP_SHOW_TRACK_VALUE, SP_HELP_CONTENT, SP_HELP_URL, @@ -20,9 +18,7 @@ export default { name: 'MRWidgetSuggestPipeline', SP_ICON_NAME, SP_TRACK_LABEL, - SP_LINK_TRACK_EVENT, SP_SHOW_TRACK_EVENT, - SP_LINK_TRACK_VALUE, SP_SHOW_TRACK_VALUE, SP_HELP_CONTENT, SP_HELP_URL, @@ -81,29 +77,14 @@ export default { <div> <gl-sprintf :message=" - s__(`mrWidget|%{prefixToLinkStart}No pipeline%{prefixToLinkEnd} - %{addPipelineLinkStart}Add the .gitlab-ci.yml file%{addPipelineLinkEnd} - to create one.`) + s__(`mrWidget|%{boldHeaderStart}Looks like there's no pipeline here.%{boldHeaderEnd}`) " > - <template #prefixToLink="{ content }"> + <template #boldHeader="{ content }"> <strong> {{ content }} </strong> </template> - <template #addPipelineLink="{ content }"> - <gl-link - :href="pipelinePath" - class="gl-ml-1" - data-testid="add-pipeline-link" - :data-track-property="humanAccess" - :data-track-value="$options.SP_LINK_TRACK_VALUE" - :data-track-action="$options.SP_LINK_TRACK_EVENT" - :data-track-label="$options.SP_TRACK_LABEL" - > - {{ content }} - </gl-link> - </template> </gl-sprintf> </div> </template> @@ -115,9 +96,6 @@ export default { </div> <div class="col-md-7 order-md-first col-12"> <div class="ml-6 gl-pt-5"> - <strong> - {{ s__('mrWidget|Are you adding technical debt or code vulnerabilities?') }} - </strong> <p class="gl-mt-2"> <gl-sprintf :message="$options.SP_HELP_CONTENT"> <template #link="{ content }"> @@ -142,7 +120,7 @@ export default { :data-track-action="$options.SP_SHOW_TRACK_EVENT" :data-track-label="$options.SP_TRACK_LABEL" > - {{ __('Show me how to add a pipeline') }} + {{ __('Try out GitLab Pipelines') }} </gl-button> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue index 9268e426954..caafd6b995e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue @@ -4,7 +4,7 @@ import { __ } from '../../locale'; export default { i18n: { - removesBranchText: __('The source branch will be deleted'), + removesBranchText: __('Deletes the source branch'), tooltipTitle: __('A user with write access to the source branch selected this option'), }, components: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue index 44bdc4a3be8..3eda2828e97 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue @@ -1,5 +1,8 @@ <script> +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + export default { + mixins: [glFeatureFlagMixin()], props: { value: { type: String, @@ -20,7 +23,10 @@ export default { <template> <li> <div class="commit-message-editor"> - <div class="d-flex flex-wrap align-items-center justify-content-between"> + <div + :class="{ 'gl-mb-3': glFeatures.restructuredMrWidget }" + class="d-flex flex-wrap align-items-center justify-content-between" + > <label class="col-form-label" :for="inputId"> <strong>{{ label }}</strong> </label> @@ -35,7 +41,7 @@ export default { rows="7" @input="$emit('input', $event.target.value)" ></textarea> - <slot name="checkbox"></slot> + <slot name="text-muted"></slot> </div> </li> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue index 3ca193514f1..5c4a526bcc3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue @@ -1,15 +1,12 @@ <script> -import { GlButton, GlSprintf } from '@gitlab/ui'; -import { escape } from 'lodash'; -import { __, n__, s__ } from '~/locale'; - -const mergeCommitCount = s__('mrWidgetCommitsAdded|1 merge commit'); +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; +import AddedCommitMessage from '../added_commit_message.vue'; export default { - mergeCommitCount, components: { GlButton, - GlSprintf, + AddedCommitMessage, }, props: { isSquashEnabled: { @@ -39,9 +36,6 @@ export default { collapseIcon() { return this.expanded ? 'chevron-down' : 'chevron-right'; }, - commitsCountMessage() { - return n__('%d commit', '%d commits', this.isSquashEnabled ? 1 : this.commitsCount); - }, modifyLinkMessage() { if (this.isFastForwardEnabled) return __('Modify commit message'); else if (this.isSquashEnabled) return __('Modify commit messages'); @@ -50,16 +44,6 @@ export default { ariaLabel() { return this.expanded ? __('Collapse') : __('Expand'); }, - targetBranchEscaped() { - return escape(this.targetBranch); - }, - message() { - return this.isFastForwardEnabled - ? s__('mrWidgetCommitsAdded|%{commitCount} will be added to %{targetBranch}.') - : s__( - 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}.', - ); - }, }, methods: { toggle() { @@ -86,17 +70,12 @@ export default { <span v-if="expanded">{{ __('Collapse') }}</span> <span v-else> <span class="vertical-align-middle"> - <gl-sprintf :message="message"> - <template #commitCount> - <strong class="commits-count-message">{{ commitsCountMessage }}</strong> - </template> - <template #mergeCommitCount> - <strong>{{ $options.mergeCommitCount }}</strong> - </template> - <template #targetBranch> - <span class="label-branch">{{ targetBranchEscaped }}</span> - </template> - </gl-sprintf> + <added-commit-message + :is-squash-enabled="isSquashEnabled" + :is-fast-forward-enabled="isFastForwardEnabled" + :commits-count="commitsCount" + :target-branch="targetBranch" + /> </span> <gl-button variant="link" class="modify-message-button"> {{ modifyLinkMessage }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index 0eb173edbcb..a44caf886a4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -177,10 +177,10 @@ export default { </h4> <section class="mr-info-list"> <p v-if="shouldRemoveSourceBranch"> - {{ s__('mrWidget|The source branch will be deleted') }} + {{ s__('mrWidget|Deletes the source branch') }} </p> <p v-else class="gl-display-flex"> - <span class="gl-mr-3">{{ s__('mrWidget|The source branch will not be deleted') }}</span> + <span class="gl-mr-3">{{ s__('mrWidget|Does not delete the source branch') }}</span> <gl-button v-if="canRemoveSourceBranch" :loading="isRemovingSourceBranch" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue index e02be6dc2f7..10b93d7849f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue @@ -1,4 +1,5 @@ <script> +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import statusIcon from '../mr_widget_status_icon.vue'; export default { @@ -6,11 +7,12 @@ export default { components: { statusIcon, }, + mixins: [glFeatureFlagMixin()], }; </script> <template> <div class="mr-widget-body media"> - <status-icon :show-disabled-button="true" status="loading" /> + <status-icon :show-disabled-button="!glFeatures.restructuredMrWidget" status="loading" /> <div class="media-body space-children"> <span class="bold"> {{ s__('mrWidget|Checking if merge request can be merged…') }} </span> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index a1759b1a815..84dac95ce74 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -1,6 +1,7 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ import { GlLoadingIcon, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import api from '~/api'; import createFlash from '~/flash'; import { s__, __ } from '~/locale'; import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '~/projects/commit/constants'; @@ -83,6 +84,8 @@ export default { removeSourceBranch() { this.isMakingRequest = true; + api.trackRedisHllUserEvent('i_code_review_post_merge_delete_branch'); + this.service .removeSourceBranch() .then((res) => res.data) @@ -103,9 +106,13 @@ export default { }); }, openRevertModal() { + api.trackRedisHllUserEvent('i_code_review_post_merge_click_revert'); + modalEventHub.$emit(OPEN_REVERT_MODAL); }, openCherryPickModal() { + api.trackRedisHllUserEvent('i_code_review_post_merge_click_cherry_pick'); + modalEventHub.$emit(OPEN_CHERRY_PICK_MODAL); }, }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue index 1c245b584ea..247877a8235 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue @@ -32,7 +32,7 @@ export default { </h4> <section class="mr-info-list"> <p> - {{ s__('mrWidget|The changes will be merged into') }} + {{ s__('mrWidget|Merges changes into') }} <span class="label-branch"> <a :href="mr.targetBranchPath">{{ mr.targetBranch }}</a> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 1976d3639a6..9f2870d8d69 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -1,8 +1,7 @@ <script> import { GlButton, GlSkeletonLoader } from '@gitlab/ui'; -import { escape } from 'lodash'; import createFlash from '~/flash'; -import { __, sprintf } from '~/locale'; +import { __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import simplePoll from '../../../lib/utils/simple_poll'; import eventHub from '../../event_hub'; @@ -85,13 +84,7 @@ export default { return ['failed', 'loading'].includes(this.status); }, fastForwardMergeText() { - return sprintf( - __('Merge blocked: the source branch must be rebased onto the target branch.'), - { - targetBranch: `<span class="label-branch">${escape(this.targetBranch)}</span>`, - }, - false, - ); + return __('Merge blocked: the source branch must be rebased onto the target branch.'); }, }, methods: { @@ -170,8 +163,8 @@ export default { v-if="!rebaseInProgress && !canPushToSourceBranch" class="gl-font-weight-bold gl-ml-0!" data-testid="rebase-message" - v-html="fastForwardMergeText /* eslint-disable-line vue/no-v-html */" - ></span> + >{{ fastForwardMergeText }}</span + > <div v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest" class="accept-merge-holder clearfix js-toggle-container accept-action media space-children" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue index 9a7743348ff..0b6aa104181 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue @@ -37,7 +37,7 @@ export default { <template> <div class="mr-widget-body media"> <status-icon status="success" /> - <p class="media-body gl-m-0! gl-font-weight-bold"> + <p class="media-body gl-m-0! gl-font-weight-bold gl-text-gray-900!"> <template v-if="canMerge"> {{ __('Ready to merge!') }} </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue index 7827c79cd31..2d704d3b07a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlSprintf, GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; import emptyStateSVG from 'icons/_mr_widget_empty_state.svg'; +import api from '~/api'; import { helpPagePath } from '~/helpers/help_page_helper'; export default { @@ -22,6 +23,11 @@ export default { data() { return { emptyStateSVG }; }, + methods: { + onClickNewFile() { + api.trackRedisHllUserEvent('i_code_review_widget_nothing_merge_click_new_file'); + }, + }, ciHelpPage: helpPagePath('/ci/quick_start/index.html'), safeHtmlConfig: { ADD_TAGS: ['use'] }, }; @@ -59,6 +65,7 @@ export default { category="secondary" variant="success" data-testid="createFileButton" + @click="onClickNewFile" > {{ __('Create file') }} </gl-button> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 7d4bd4cf1bf..d2cc99302a9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -18,9 +18,10 @@ import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests import createFlash from '~/flash'; import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import simplePoll from '~/lib/utils/simple_poll'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import SmartInterval from '~/smart_interval'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { helpPagePath } from '~/helpers/help_page_helper'; import MergeRequest from '../../../merge_request'; import { AUTO_MERGE_STRATEGIES, @@ -35,6 +36,8 @@ import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import MergeRequestStore from '../../stores/mr_widget_store'; import statusIcon from '../mr_widget_status_icon.vue'; +import AddedCommitMessage from '../added_commit_message.vue'; +import RelatedLinks from '../mr_widget_related_links.vue'; import CommitEdit from './commit_edit.vue'; import CommitMessageDropdown from './commit_message_dropdown.vue'; import CommitsHeader from './commits_header.vue'; @@ -113,6 +116,8 @@ export default { import( 'ee_component/vue_merge_request_widget/components/merge_train_failed_pipeline_confirmation_dialog.vue' ), + AddedCommitMessage, + RelatedLinks, }, directives: { GlTooltip: GlTooltipDirective, @@ -134,6 +139,7 @@ export default { isSquashReadOnly: this.mr.squashIsReadonly, squashCommitMessage: this.mr.squashCommitMessage, isPipelineFailedModalVisible: false, + editCommitMessage: false, }; }, computed: { @@ -162,7 +168,7 @@ export default { }, isMergeAllowed() { if (this.glFeatures.mergeRequestWidgetGraphql) { - return this.state.mergeable || false; + return this.state.mergeable; } return this.mr.isMergeAllowed; @@ -174,6 +180,11 @@ export default { return this.mr.canRemoveSourceBranch; }, + commitTemplateHelpPage() { + return helpPagePath('user/project/merge_requests/commit_templates.md', { + anchor: 'merge-commit-message-template', + }); + }, commits() { if (this.glFeatures.mergeRequestWidgetGraphql) { return this.state.commitsWithoutMergeCommits.nodes; @@ -279,6 +290,10 @@ export default { return enableSquashBeforeMerge && this.commitsCount > 1; }, shouldShowMergeControls() { + if (this.glFeatures.restructuredMrWidget) { + return this.restructuredWidgetShowMergeButtons; + } + return this.isMergeAllowed || this.isAutoMergeAvailable; }, shouldShowSquashEdit() { @@ -297,15 +312,26 @@ export default { showDangerMessageForMergeTrain() { return this.preferredAutoMergeStrategy === MT_MERGE_STRATEGY && this.isPipelineFailed; }, + restructuredWidgetShowMergeButtons() { + if (this.glFeatures.restructuredMrWidget) { + return this.isMergeAllowed && this.state.userPermissions.canMerge; + } + + return true; + }, }, mounted() { if (this.glFeatures.mergeRequestWidgetGraphql) { eventHub.$on('ApprovalUpdated', this.updateGraphqlState); + eventHub.$on('MRWidgetUpdateRequested', this.updateGraphqlState); + eventHub.$on('mr.discussion.updated', this.updateGraphqlState); } }, beforeDestroy() { if (this.glFeatures.mergeRequestWidgetGraphql) { eventHub.$off('ApprovalUpdated', this.updateGraphqlState); + eventHub.$off('MRWidgetUpdateRequested', this.updateGraphqlState); + eventHub.$off('mr.discussion.updated', this.updateGraphqlState); } if (this.pollingInterval) { @@ -327,15 +353,6 @@ export default { updateGraphqlState() { return this.$apollo.queries.state.refetch(); }, - updateMergeCommitMessage(includeDescription) { - const commitMessage = this.glFeatures.mergeRequestWidgetGraphql - ? this.state.defaultMergeCommitMessage - : this.mr.commitMessage; - const commitMessageWithDescription = this.glFeatures.mergeRequestWidgetGraphql - ? this.state.defaultMergeCommitMessageWithDescription - : this.mr.commitMessageWithDescription; - this.commitMessage = includeDescription ? commitMessageWithDescription : commitMessage; - }, handleMergeButtonClick(useAutoMerge, mergeImmediately = false, confirmationClicked = false) { if (this.showFailedPipelineModal && !confirmationClicked) { this.isPipelineFailedModalVisible = true; @@ -488,11 +505,21 @@ export default { }); }, }, + i18n: { + mergeCommitTemplateHintText: s__( + 'mrWidget|To change this default message, edit the template for merge commit messages. %{linkStart}Learn more.%{linkEnd}', + ), + }, }; </script> <template> - <div> + <div + :class="{ + 'gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7': + glFeatures.restructuredMrWidget, + }" + > <div v-if="loading" class="mr-widget-body"> <div class="gl-w-full mr-ready-to-merge-loader"> <gl-skeleton-loader :width="418" :height="30"> @@ -504,11 +531,16 @@ export default { </div> </div> <template v-else> - <div class="mr-widget-body media"> - <status-icon :status="iconClass" /> + <div + class="mr-widget-body media" + :class="{ + 'mr-widget-body-line-height-1': glFeatures.restructuredMrWidget, + }" + > + <status-icon v-if="!glFeatures.restructuredMrWidget" :status="iconClass" /> <div class="media-body"> - <div class="mr-widget-body-controls gl-display-flex gl-align-items-center"> - <gl-button-group class="gl-align-self-start"> + <div class="mr-widget-body-controls gl-display-flex gl-align-items-center gl-flex-wrap"> + <gl-button-group v-if="restructuredWidgetShowMergeButtons" class="gl-align-self-start"> <gl-button size="medium" category="primary" @@ -555,14 +587,27 @@ export default { </gl-button-group> <div v-if="shouldShowMergeControls" + :class="{ 'gl-w-full gl-order-n1 gl-mb-5': glFeatures.restructuredMrWidget }" class="gl-display-flex gl-align-items-center gl-flex-wrap" > + <merge-train-helper-icon + v-if="shouldRenderMergeTrainHelperIcon" + :merge-train-when-pipeline-succeeds-docs-path=" + mr.mergeTrainWhenPipelineSucceedsDocsPath + " + class="gl-mx-3" + /> + <gl-form-checkbox v-if="canRemoveSourceBranch" id="remove-source-branch-input" v-model="removeSourceBranch" :disabled="isRemoveSourceBranchButtonDisabled" - class="js-remove-source-branch-checkbox gl-mx-3 gl-display-flex gl-align-items-center" + :class="{ + 'gl-mx-3': !glFeatures.restructuredMrWidget, + 'gl-mr-5': glFeatures.restructuredMrWidget, + }" + class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center" > {{ __('Delete source branch') }} </gl-form-checkbox> @@ -573,38 +618,146 @@ export default { v-model="squashBeforeMerge" :help-path="mr.squashBeforeMergeHelpPath" :is-disabled="isSquashReadOnly" - class="gl-mx-3" + :class="{ + 'gl-mx-3': !glFeatures.restructuredMrWidget, + 'gl-mr-5': glFeatures.restructuredMrWidget, + }" /> - <merge-train-helper-icon - v-if="shouldRenderMergeTrainHelperIcon" - :merge-train-when-pipeline-succeeds-docs-path=" - mr.mergeTrainWhenPipelineSucceedsDocsPath + <gl-form-checkbox + v-if=" + glFeatures.restructuredMrWidget && (shouldShowSquashEdit || shouldShowMergeEdit) " - /> + v-model="editCommitMessage" + class="gl-display-flex gl-align-items-center" + > + {{ __('Edit commit message') }} + </gl-form-checkbox> + </div> + <div + v-else-if="!glFeatures.restructuredMrWidget" + class="bold js-resolve-mr-widget-items-message gl-ml-3" + > + <div + v-if="hasPipelineMustSucceedConflict" + class="gl-display-flex gl-align-items-center" + data-testid="pipeline-succeed-conflict" + > + <gl-sprintf :message="pipelineMustSucceedConflictText" /> + <gl-link + :href="mr.pipelineMustSucceedDocsPath" + target="_blank" + class="gl-display-flex gl-ml-2" + > + <gl-icon name="question" /> + </gl-link> + </div> + <gl-sprintf v-else :message="mergeDisabledText" /> </div> - <template v-else> - <div class="bold js-resolve-mr-widget-items-message gl-ml-3"> - <div - v-if="hasPipelineMustSucceedConflict" - class="gl-display-flex gl-align-items-center" - data-testid="pipeline-succeed-conflict" + <template v-if="glFeatures.restructuredMrWidget"> + <div v-show="editCommitMessage" class="gl-w-full gl-order-n1"> + <ul + :class="{ + 'content-list': !glFeatures.restructuredMrWidget, + 'gl-list-style-none gl-p-0 gl-pt-4': glFeatures.restructuredMrWidget, + }" + class="border-top commits-list flex-list" > - <gl-sprintf :message="pipelineMustSucceedConflictText" /> - <gl-link - :href="mr.pipelineMustSucceedDocsPath" - target="_blank" - class="gl-display-flex gl-ml-2" + <commit-edit + v-if="shouldShowSquashEdit" + v-model="squashCommitMessage" + :label="__('Squash commit message')" + input-id="squash-message-edit" + class="gl-m-0! gl-p-0!" + > + <template #header> + <commit-message-dropdown v-model="squashCommitMessage" :commits="commits" /> + </template> + </commit-edit> + <commit-edit + v-if="shouldShowMergeEdit" + v-model="commitMessage" + :label="__('Merge commit message')" + input-id="merge-message-edit" + class="gl-m-0! gl-p-0!" > - <gl-icon name="question" /> - </gl-link> - </div> - <gl-sprintf v-else :message="mergeDisabledText" /> + <template #text-muted> + <p class="form-text text-muted"> + <gl-sprintf :message="$options.i18n.mergeCommitTemplateHintText"> + <template #link="{ content }"> + <gl-link + :href="commitTemplateHelpPage" + class="inline-link" + target="_blank" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </template> + </commit-edit> + </ul> + </div> + <div + v-if="!restructuredWidgetShowMergeButtons" + class="gl-w-full gl-order-n1 gl-text-gray-500" + > + <strong> + {{ __('Merge details') }} + </strong> + <ul class="gl-pl-4 gl-m-0"> + <li class="gl-line-height-normal"> + <added-commit-message + :is-squash-enabled="squashBeforeMerge" + :is-fast-forward-enabled="!shouldShowMergeEdit" + :commits-count="commitsCount" + :target-branch="stateData.targetBranch" + /> + </li> + <li class="gl-line-height-normal"> + <template v-if="removeSourceBranch"> + {{ __('Deletes the source branch.') }} + </template> + <template v-else> + {{ __('Does not delete the source branch.') }} + </template> + </li> + <li v-if="mr.relatedLinks" class="gl-line-height-normal"> + <related-links + :state="mr.state" + :related-links="mr.relatedLinks" + :show-assign-to-me="false" + class="mr-ready-merge-related-links gl-display-inline" + /> + </li> + </ul> + </div> + <div + v-else + :class="{ 'gl-mb-5': restructuredWidgetShowMergeButtons }" + class="gl-w-full gl-order-n1 gl-text-gray-500" + > + <added-commit-message + :is-squash-enabled="squashBeforeMerge" + :is-fast-forward-enabled="!shouldShowMergeEdit" + :commits-count="commitsCount" + :target-branch="stateData.targetBranch" + /> + <template v-if="mr.relatedLinks"> + · + <related-links + :state="mr.state" + :related-links="mr.relatedLinks" + :show-assign-to-me="false" + class="mr-ready-merge-related-links gl-display-inline" + /> + </template> </div> </template> </div> <div - v-if="showDangerMessageForMergeTrain" + v-if="showDangerMessageForMergeTrain && !glFeatures.restructuredMrWidget" class="gl-mt-5 gl-text-gray-500" data-testid="failed-pipeline-merge-train-text" > @@ -612,7 +765,7 @@ export default { </div> </div> </div> - <template v-if="shouldShowMergeControls"> + <template v-if="shouldShowMergeControls && !glFeatures.restructuredMrWidget"> <div v-if="!shouldShowMergeEdit" class="mr-fast-forward-message" @@ -621,7 +774,7 @@ export default { {{ __('Fast-forward merge without a merge commit') }} </div> <commits-header - v-if="shouldShowSquashEdit || shouldShowMergeEdit" + v-if="!glFeatures.restructuredMrWidget && (shouldShowSquashEdit || shouldShowMergeEdit)" :is-squash-enabled="squashBeforeMerge" :commits-count="commitsCount" :target-branch="stateData.targetBranch" @@ -646,15 +799,16 @@ export default { :label="__('Merge commit message')" input-id="merge-message-edit" > - <template #checkbox> - <label> - <input - id="include-description" - type="checkbox" - @change="updateMergeCommitMessage($event.target.checked)" - /> - {{ __('Include merge request description') }} - </label> + <template #text-muted> + <p class="form-text text-muted"> + <gl-sprintf :message="$options.i18n.mergeCommitTemplateHintText"> + <template #link="{ content }"> + <gl-link :href="commitTemplateHelpPage" class="inline-link" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> </template> </commit-edit> </ul> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue index 41b5983ae0c..c6227c4394d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue @@ -1,15 +1,18 @@ <script> -import { GlIcon, GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective, GlFormCheckbox, GlLink } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { SQUASH_BEFORE_MERGE } from '../../i18n'; export default { components: { GlIcon, GlFormCheckbox, + GlLink, }, directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], i18n: { ...SQUASH_BEFORE_MERGE, }, @@ -33,6 +36,9 @@ export default { tooltipTitle() { return this.isDisabled ? this.$options.i18n.tooltipTitle : null; }, + helpIconName() { + return this.glFeatures.restructuredMrWidget ? 'question-o' : 'question'; + }, }, }; </script> @@ -51,18 +57,18 @@ export default { > {{ $options.i18n.checkboxLabel }} </gl-form-checkbox> - <a + <gl-link v-if="helpPath" v-gl-tooltip :href="helpPath" :title="$options.i18n.helpLabel" + :class="{ 'gl-text-blue-600': glFeatures.restructuredMrWidget }" target="_blank" - rel="noopener noreferrer nofollow" > - <gl-icon name="question" /> + <gl-icon :name="helpIconName" /> <span class="sr-only"> {{ $options.i18n.helpLabel }} </span> - </a> + </gl-link> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index 790870ee4c6..fa4f8b76cb9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -10,8 +10,8 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import getStateQuery from '../../queries/get_state.query.graphql'; -import workInProgressQuery from '../../queries/states/work_in_progress.query.graphql'; -import removeWipMutation from '../../queries/toggle_wip.mutation.graphql'; +import draftQuery from '../../queries/states/draft.query.graphql'; +import removeDraftMutation from '../../queries/toggle_draft.mutation.graphql'; import StatusIcon from '../mr_widget_status_icon.vue'; export default { @@ -23,7 +23,7 @@ export default { mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], apollo: { userPermissions: { - query: workInProgressQuery, + query: draftQuery, skip() { return !this.glFeatures.mergeRequestWidgetGraphql; }, @@ -53,25 +53,25 @@ export default { }, }, methods: { - removeWipMutation() { + removeDraftMutation() { const { mergeRequestQueryVariables } = this; this.isMakingRequest = true; this.$apollo .mutate({ - mutation: removeWipMutation, + mutation: removeDraftMutation, variables: { ...mergeRequestQueryVariables, - wip: false, + draft: false, }, update( store, { data: { - mergeRequestSetWip: { + mergeRequestSetDraft: { errors, - mergeRequest: { mergeableDiscussionsState, workInProgress, title }, + mergeRequest: { mergeableDiscussionsState, draft, title }, }, }, }, @@ -91,7 +91,7 @@ export default { const data = produce(sourceData, (draftState) => { draftState.project.mergeRequest.mergeableDiscussionsState = mergeableDiscussionsState; - draftState.project.mergeRequest.workInProgress = workInProgress; + draftState.project.mergeRequest.draft = draft; draftState.project.mergeRequest.title = title; }); @@ -104,14 +104,14 @@ export default { optimisticResponse: { // eslint-disable-next-line @gitlab/require-i18n-strings __typename: 'Mutation', - mergeRequestSetWip: { + mergeRequestSetDraft: { __typename: 'MergeRequestSetWipPayload', errors: [], mergeRequest: { __typename: 'MergeRequest', mergeableDiscussionsState: true, title: this.mr.title, - workInProgress: false, + draft: false, }, }, }, @@ -119,7 +119,7 @@ export default { .then( ({ data: { - mergeRequestSetWip: { + mergeRequestSetDraft: { mergeRequest: { title }, }, }, @@ -137,9 +137,9 @@ export default { this.isMakingRequest = false; }); }, - handleRemoveWIP() { + handleRemoveDraft() { if (this.glFeatures.mergeRequestWidgetGraphql) { - this.removeWipMutation(); + this.removeDraftMutation(); } else { this.isMakingRequest = true; this.service @@ -178,8 +178,8 @@ export default { size="small" :disabled="isMakingRequest" :loading="isMakingRequest" - class="js-remove-wip gl-ml-3" - @click="handleRemoveWIP" + class="js-remove-draft gl-ml-3" + @click="handleRemoveDraft" > {{ s__('mrWidget|Mark as ready') }} </gl-button> diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index b88e83ccb0f..d0c6cf12e25 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -17,14 +17,12 @@ export const AUTO_MERGE_STRATEGIES = [MWPS_MERGE_STRATEGY, MTWPS_MERGE_STRATEGY, // SP - "Suggest Pipelines" export const SP_TRACK_LABEL = 'no_pipeline_noticed'; -export const SP_LINK_TRACK_EVENT = 'click_link'; export const SP_SHOW_TRACK_EVENT = 'click_button'; -export const SP_LINK_TRACK_VALUE = 30; export const SP_SHOW_TRACK_VALUE = 10; export const SP_HELP_CONTENT = s__( - `mrWidget|Use %{linkStart}CI pipelines to test your code%{linkEnd} by simply adding a GitLab CI configuration file to your project. It only takes a minute to make your code more secure and robust.`, + `mrWidget|GitLab %{linkStart}CI/CD can automatically build, test, and deploy your application.%{linkEnd} It only takes a few minutes to get started, and we can help you create a pipeline configuration file.`, ); -export const SP_HELP_URL = 'https://about.gitlab.com/blog/2019/07/12/guide-to-ci-cd-pipelines/'; +export const SP_HELP_URL = 'https://docs.gitlab.com/ee/ci/quick_start/'; export const SP_ICON_NAME = 'status_notfound'; export const MERGE_ACTIVE_STATUS_PHRASES = [ diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js index 349e9d29355..9cbc0b0e5d1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -11,6 +11,7 @@ export default { label: 'Issues', loading: 'Loading issues...', }, + expandEvent: 'i_testing_load_performance_widget_total', // Add an array of props // These then get mapped to values stored in the MR Widget store props: ['targetProjectFullPath', 'conflictsDocsPath'], @@ -29,7 +30,15 @@ export default { // Tertiary action buttons that will take the user elsewhere // in the GitLab app tertiaryButtons() { - return [{ text: 'Full report', href: this.conflictsDocsPath, target: '_blank' }]; + return [ + { + text: 'Click me', + onClick() { + console.log('Hello world'); + }, + }, + { text: 'Full report', href: this.conflictsDocsPath, target: '_blank' }, + ]; }, }, methods: { @@ -66,6 +75,7 @@ export default { // href: 'https://google.com', // Required: href for the link // text: 'Link text', // Required: Text to be used inside the link // }, + actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }], })); }); }, diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql b/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql index 389a81e0a61..da1cace4598 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql @@ -1,4 +1,4 @@ -query getIssues($projectPath: ID!) { +query getProjectIssues($projectPath: ID!) { project(fullPath: $projectPath) { issues { count diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index f5dbcec7dbe..8d596465970 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -13,12 +13,7 @@ Vue.use(Translate); Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - assumeImmutableResults: true, - }, - ), + defaultClient: createDefaultClient(), }); export default () => { diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index cf6472f2c8c..83789f10285 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -1,9 +1,13 @@ import { __ } from '~/locale'; export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.'); +export const MERGE_DISABLED_SKIPPED_PIPELINE_TEXT = __( + "Merge blocked: pipeline must succeed. It's waiting for a manual job to continue.", +); export const PIPELINE_MUST_SUCCEED_CONFLICT_TEXT = __( 'A CI/CD pipeline must run and be successful before merge.', ); +export const PIPELINE_SKIPPED_STATUS = 'SKIPPED'; export default { computed: { @@ -17,6 +21,10 @@ export default { ); }, mergeDisabledText() { + if (this.pipeline?.status === PIPELINE_SKIPPED_STATUS) { + return MERGE_DISABLED_SKIPPED_PIPELINE_TEXT; + } + return MERGE_DISABLED_TEXT; }, pipelineMustSucceedConflictText() { diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 3ac1e881658..c98dc426224 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -91,6 +91,7 @@ export default { MrWidgetApprovals, SecurityReportsApp: () => import('~/vue_shared/security_reports/security_reports_app.vue'), MergeChecksFailed: () => import('./components/states/merge_checks_failed.vue'), + ReadyToMerge: ReadyToMergeState, }, apollo: { state: { @@ -213,6 +214,9 @@ export default { window.gon?.features?.refactorMrWidgetsExtensionsUser ); }, + isRestructuredMrWidgetEnabled() { + return window.gon?.features?.restructuredMrWidget; + }, }, watch: { 'mr.machineValue': { @@ -547,12 +551,17 @@ export default { <div class="mr-widget-section"> <component :is="componentName" :mr="mr" :service="service" /> - - <div class="mr-widget-info"> + <ready-to-merge + v-if="isRestructuredMrWidgetEnabled && mr.commitsCount" + :mr="mr" + :service="service" + /> + <div v-else class="mr-widget-info"> <mr-widget-related-links v-if="shouldRenderRelatedLinks" :state="mr.state" :related-links="mr.relatedLinks" + class="mr-info-list gl-ml-7 gl-pb-5" /> <source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql index 871aa880b36..bfb1517be81 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql @@ -23,7 +23,7 @@ query getState($projectPath: ID!, $iid: String!) { userPermissions { canMerge } - workInProgress + draft } } } diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql index daf21e75b3b..e0215fbd969 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql @@ -1,6 +1,6 @@ #import "./auto_merge_enabled.fragment.graphql" -query autoMergeEnabledQuery($projectPath: ID!, $iid: String!) { +query autoMergeEnabled($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { mergeRequest(iid: $iid) { ...autoMergeEnabled diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql index 186c0e64561..e66ac01ab12 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql @@ -1,4 +1,4 @@ -query workInProgressQuery($projectPath: ID!, $iid: String!) { +query workInProgress($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { mergeRequest(iid: $iid) { shouldBeRebased diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql new file mode 100644 index 00000000000..0983c28448e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql @@ -0,0 +1,9 @@ +query mrUserPermission($projectPath: ID!, $iid: String!) { + project(fullPath: $projectPath) { + mergeRequest(iid: $iid) { + userPermissions { + updateMergeRequest + } + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql index 3b34be73c15..21c3ffd8321 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql @@ -1,4 +1,4 @@ -query readyToMergeQuery($projectPath: ID!, $iid: String!) { +query getReadyToMergeStatus($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { mergeRequest(iid: $iid) { userPermissions { diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql index 367b9ad1cdf..b2a1be5c5a9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql @@ -18,7 +18,9 @@ fragment ReadyToMerge on Project { commitCount diffHeadSha userPermissions { + canMerge removeSourceBranch + updateMergeRequest } targetBranch mergeError diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql index 78259e1f553..f713739f65a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql @@ -1,6 +1,6 @@ #import "./ready_to_merge.fragment.graphql" -query readyToMergeQuery($projectPath: ID!, $iid: String!) { +query readyToMerge($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { ...ReadyToMerge } diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/work_in_progress.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/work_in_progress.query.graphql deleted file mode 100644 index 73e205ebf2b..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/work_in_progress.query.graphql +++ /dev/null @@ -1,9 +0,0 @@ -query workInProgressQuery($projectPath: ID!, $iid: String!) { - project(fullPath: $projectPath) { - mergeRequest(iid: $iid) { - userPermissions { - updateMergeRequest - } - } - } -} diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql new file mode 100644 index 00000000000..200fb1b7ca5 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql @@ -0,0 +1,10 @@ +mutation toggleDraftStatus($projectPath: ID!, $iid: String!, $draft: Boolean!) { + mergeRequestSetDraft(input: { projectPath: $projectPath, iid: $iid, draft: $draft }) { + mergeRequest { + mergeableDiscussionsState + title + draft + } + errors + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql deleted file mode 100644 index cfaa198d516..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql +++ /dev/null @@ -1,10 +0,0 @@ -mutation toggleWIPStatus($projectPath: ID!, $iid: String!, $wip: Boolean!) { - mergeRequestSetWip(input: { projectPath: $projectPath, iid: $iid, wip: $wip }) { - mergeRequest { - mergeableDiscussionsState - title - workInProgress - } - errors - } -} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index 65d78fc283c..2ae4f4da2f3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -1,14 +1,14 @@ import { stateKey } from './state_maps'; export default function deviseState() { - if (this.hasMergeChecksFailed) { + if (!this.commitsCount) { + return stateKey.nothingToMerge; + } else if (this.hasMergeChecksFailed && !this.autoMergeEnabled) { return stateKey.mergeChecksFailed; } else if (this.projectArchived) { return stateKey.archived; } else if (this.branchMissing) { return stateKey.missingBranch; - } else if (!this.commitsCount) { - return stateKey.nothingToMerge; } else if (this.mergeStatus === 'unchecked' || this.mergeStatus === 'checking') { return stateKey.checking; } else if (this.hasConflicts) { @@ -17,8 +17,8 @@ export default function deviseState() { return stateKey.rebase; } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { return stateKey.pipelineFailed; - } else if (this.workInProgress) { - return stateKey.workInProgress; + } else if (this.draft) { + return stateKey.draft; } else if (this.hasMergeableDiscussionsState && !this.autoMergeEnabled) { return stateKey.unresolvedDiscussions; } else if (this.isPipelineBlocked) { diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 6628225cd46..10a2907c81a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -164,7 +164,7 @@ export default class MergeRequestStore { this.projectArchived = data.project_archived; this.isSHAMismatch = this.sha !== data.diff_head_sha; this.shouldBeRebased = Boolean(data.should_be_rebased); - this.workInProgress = data.work_in_progress; + this.draft = data.draft; } const currentUser = data.current_user; @@ -207,7 +207,7 @@ export default class MergeRequestStore { this.isPipelineFailed = this.ciStatus === 'failed' || this.ciStatus === 'canceled'; this.isSHAMismatch = this.sha !== mergeRequest.diffHeadSha; this.shouldBeRebased = mergeRequest.shouldBeRebased; - this.workInProgress = mergeRequest.workInProgress; + this.draft = mergeRequest.draft; this.mergeRequestState = mergeRequest.state; this.setState(); diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js index 4cb23407a74..9dfeaee905c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -4,7 +4,7 @@ export const stateToComponentMap = { merging: 'mr-widget-merging', conflicts: 'mr-widget-conflicts', missingBranch: 'mr-widget-missing-branch', - workInProgress: 'mr-widget-wip', + draft: 'mr-widget-wip', readyToMerge: 'mr-widget-ready-to-merge', nothingToMerge: 'mr-widget-nothing-to-merge', notAllowedToMerge: 'mr-widget-not-allowed', @@ -24,7 +24,7 @@ export const stateToComponentMap = { export const statesToShowHelpWidget = [ 'merging', 'conflicts', - 'workInProgress', + 'draft', 'readyToMerge', 'checking', 'unresolvedDiscussions', @@ -40,7 +40,7 @@ export const stateKey = { nothingToMerge: 'nothingToMerge', checking: 'checking', conflicts: 'conflicts', - workInProgress: 'workInProgress', + draft: 'draft', pipelineFailed: 'pipelineFailed', unresolvedDiscussions: 'unresolvedDiscussions', pipelineBlocked: 'pipelineBlocked', diff --git a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue index f8f1613879f..6b774b2a734 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; import NoteHeader from '~/notes/components/note_header.vue'; export default { @@ -7,6 +7,9 @@ export default { NoteHeader, GlIcon, }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, props: { note: { type: Object, @@ -39,7 +42,7 @@ export default { <div class="note-header"> <note-header :author="noteAuthor" :created-at="note.createdAt" :note-id="note.id"> - <span v-html="note.bodyHtml /* eslint-disable-line vue/no-v-html */"></span> + <span v-safe-html="note.bodyHtml"></span> </note-header> </div> </div> diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql index da5f1a00e11..0c26fcc0ab2 100644 --- a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql @@ -1,7 +1,7 @@ #import "~/graphql_shared/fragments/alert_detail_item.fragment.graphql" #import "~/graphql_shared/fragments/user.fragment.graphql" -query alertDetails($fullPath: ID!, $alertId: String) { +query alertDetailsAssignees($fullPath: ID!, $alertId: String) { project(fullPath: $fullPath) { alertManagementAlerts(iid: $alertId) { nodes { diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js index fda405c0fa5..9f1da9ae173 100644 --- a/app/assets/javascripts/vue_shared/alert_details/index.js +++ b/app/assets/javascripts/vue_shared/alert_details/index.js @@ -38,7 +38,6 @@ export default (selector) => { return defaultDataIdFromObject(object); }, }, - assumeImmutableResults: true, }), }); diff --git a/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue b/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue deleted file mode 100644 index 16ca2df02c0..00000000000 --- a/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue +++ /dev/null @@ -1,46 +0,0 @@ -<script> -import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import { s__ } from '~/locale'; - -export default { - components: { - GlAlert, - GlLink, - GlSprintf, - }, - inject: ['hasManagedPrometheus'], - i18n: { - alertsDeprecationText: s__( - 'Metrics|GitLab-managed Prometheus is deprecated and %{linkStart}scheduled for removal%{linkEnd}. Following this removal, your existing alerts will continue to function as part of the new cluster integration. However, you will no longer be able to add new alerts or edit existing alerts from the metrics dashboard.', - ), - }, - methods: { - helpPagePath, - }, -}; -</script> - -<template> - <gl-alert - v-if="hasManagedPrometheus" - variant="warning" - class="my-2" - data-testid="alerts-deprecation-warning" - > - <gl-sprintf :message="$options.i18n.alertsDeprecationText"> - <template #link="{ content }"> - <gl-link - :href=" - helpPagePath('operations/metrics/alerts.html', { - anchor: 'managed-prometheus-instances', - }) - " - target="_blank" - > - <span>{{ content }}</span> - </gl-link> - </template> - </gl-sprintf> - </gl-alert> -</template> diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue index 40044e518c3..2c74d56f617 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { HIGHLIGHT_CLASS_NAME } from './constants'; import ViewerMixin from './mixins'; @@ -9,6 +9,9 @@ export default { components: { GlIcon, }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, mixins: [ViewerMixin, glFeatureFlagsMixin()], inject: ['blobHash'], data() { @@ -65,7 +68,7 @@ export default { <div class="blob-content"> <pre class="code highlight" - ><code :data-blob-hash="blobHash" v-html="content /* eslint-disable-line vue/no-v-html */"></code></pre> + ><code v-safe-html="content" :data-blob-hash="blobHash"></code></pre> </div> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index f388a468fd2..5de71c35be9 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -21,6 +21,7 @@ import CiIcon from './ci_icon.vue'; * - Job show view - header * - MR widget * - Terraform table + * - On-demand scans list */ export default { diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue new file mode 100644 index 00000000000..4c07cf44fed --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue @@ -0,0 +1,54 @@ +<script> +import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { CONFIRM_DANGER_MODAL_ID } from './constants'; +import ConfirmDangerModal from './confirm_danger_modal.vue'; + +export default { + name: 'ConfirmDanger', + components: { + GlButton, + ConfirmDangerModal, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + disabled: { + type: Boolean, + required: false, + default: false, + }, + phrase: { + type: String, + required: true, + }, + buttonText: { + type: String, + required: true, + }, + buttonTestid: { + type: String, + required: false, + default: 'confirm-danger-button', + }, + }, + modalId: CONFIRM_DANGER_MODAL_ID, +}; +</script> +<template> + <div> + <gl-button + v-gl-modal="$options.modalId" + class="gl-button" + variant="danger" + :disabled="disabled" + :data-testid="buttonTestid" + >{{ buttonText }}</gl-button + > + <confirm-danger-modal + :modal-id="$options.modalId" + :phrase="phrase" + @confirm="$emit('confirm')" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js new file mode 100644 index 00000000000..18fa297da87 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js @@ -0,0 +1,28 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +import ConfirmDanger from './confirm_danger.vue'; + +export default { + component: ConfirmDanger, + title: 'vue_shared/components/modals/confirm_danger_modal', +}; + +const Template = (args, { argTypes }) => ({ + components: { ConfirmDanger }, + props: Object.keys(argTypes), + template: '<confirm-danger v-bind="$props" />', + provide: { + confirmDangerMessage: 'You require more Vespene Gas', + }, +}); + +export const Default = Template.bind({}); +Default.args = { + phrase: 'You must construct additional pylons', + buttonText: 'Confirm button text', +}; + +export const Disabled = Template.bind({}); +Disabled.args = { + ...Default.args, + disabled: true, +}; diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue new file mode 100644 index 00000000000..30c96daf7e3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue @@ -0,0 +1,104 @@ +<script> +import { GlAlert, GlModal, GlFormGroup, GlFormInput, GlSprintf } from '@gitlab/ui'; +import { + CONFIRM_DANGER_MODAL_BUTTON, + CONFIRM_DANGER_MODAL_TITLE, + CONFIRM_DANGER_PHRASE_TEXT, + CONFIRM_DANGER_WARNING, + CONFIRM_DANGER_MODAL_ERROR, +} from './constants'; + +export default { + name: 'ConfirmDangerModal', + components: { + GlAlert, + GlModal, + GlFormGroup, + GlFormInput, + GlSprintf, + }, + inject: { + confirmDangerMessage: { + default: '', + }, + confirmButtonText: { + default: CONFIRM_DANGER_MODAL_BUTTON, + }, + }, + props: { + modalId: { + type: String, + required: true, + }, + phrase: { + type: String, + required: true, + }, + }, + data() { + return { confirmationPhrase: '' }; + }, + computed: { + isValid() { + return Boolean( + this.confirmationPhrase.length && this.equalString(this.confirmationPhrase, this.phrase), + ); + }, + actionPrimary() { + return { + text: this.confirmButtonText, + attributes: [{ variant: 'danger', disabled: !this.isValid }], + }; + }, + }, + methods: { + equalString(a, b) { + return a.trim().toLowerCase() === b.trim().toLowerCase(); + }, + }, + i18n: { + CONFIRM_DANGER_MODAL_BUTTON, + CONFIRM_DANGER_MODAL_TITLE, + CONFIRM_DANGER_WARNING, + CONFIRM_DANGER_PHRASE_TEXT, + CONFIRM_DANGER_MODAL_ERROR, + }, +}; +</script> +<template> + <gl-modal + ref="modal" + :modal-id="modalId" + :data-testid="modalId" + :title="$options.i18n.CONFIRM_DANGER_MODAL_TITLE" + :action-primary="actionPrimary" + @primary="$emit('confirm')" + > + <gl-alert + v-if="confirmDangerMessage" + variant="danger" + data-testid="confirm-danger-message" + :dismissible="false" + class="gl-mb-4" + > + {{ confirmDangerMessage }} + </gl-alert> + <p data-testid="confirm-danger-warning">{{ $options.i18n.CONFIRM_DANGER_WARNING }}</p> + <p data-testid="confirm-danger-phrase"> + <gl-sprintf :message="$options.i18n.CONFIRM_DANGER_PHRASE_TEXT"> + <template #phrase_code> + <code>{{ phrase }}</code> + </template> + </gl-sprintf> + </p> + <gl-form-group :state="isValid" :invalid-feedback="$options.i18n.CONFIRM_DANGER_MODAL_ERROR"> + <gl-form-input + id="confirm_name_input" + v-model="confirmationPhrase" + class="form-control" + data-testid="confirm-danger-input" + type="text" + /> + </gl-form-group> + </gl-modal> +</template> diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js b/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js new file mode 100644 index 00000000000..fa44a9be411 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js @@ -0,0 +1,12 @@ +import { __ } from '~/locale'; + +export const CONFIRM_DANGER_MODAL_ID = 'confirm-danger-modal'; +export const CONFIRM_DANGER_MODAL_TITLE = __('Confirmation required'); +export const CONFIRM_DANGER_MODAL_ERROR = __('Confirmation required'); +export const CONFIRM_DANGER_MODAL_BUTTON = __('Confirm'); +export const CONFIRM_DANGER_WARNING = __( + 'This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention.', +); +export const CONFIRM_DANGER_PHRASE_TEXT = __( + 'Please type %{phrase_code} to proceed or close this modal to cancel.', +); diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index ea507017caa..9cf8638f3cb 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -1,5 +1,8 @@ <script> -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; import { forEach, escape } from 'lodash'; @@ -13,6 +16,9 @@ export default { components: { GlSkeletonLoading, }, + directives: { + SafeHtml, + }, props: { content: { type: String, @@ -103,6 +109,7 @@ export default { } }, }, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'use'] }, }; </script> @@ -111,8 +118,8 @@ export default { <gl-skeleton-loading v-if="isLoading" /> <div v-else + v-safe-html:[$options.safeHtmlConfig]="previewContent" class="md gl-ml-auto gl-mr-auto" - v-html="previewContent /* eslint-disable-line vue/no-v-html */" ></div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue index 7859ef85dd8..153b0981813 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue @@ -45,7 +45,7 @@ export default { default: false, }, selected: { - type: Object, + type: [Object, Array], required: false, default: () => {}, }, @@ -54,6 +54,11 @@ export default { required: false, default: '', }, + allowMultiselect: { + type: Boolean, + required: false, + default: false, + }, }, computed: { isSearchEmpty() { @@ -66,8 +71,14 @@ export default { methods: { selectOption(option) { this.$emit('set-option', option || null); + if (!this.allowMultiselect) { + this.$refs.dropdown.hide(); + } }, isSelected(option) { + if (Array.isArray(this.selected)) { + return this.selected.some((label) => label.title === option.title); + } return ( this.selected && ((option.name && this.selected.name === option.name) || @@ -78,7 +89,7 @@ export default { this.$refs.dropdown.show(); }, setFocus() { - this.$refs.search.focusInput(); + this.$refs.search?.focusInput(); }, setSearchTerm(search) { this.$emit('set-search', search); @@ -108,56 +119,60 @@ export default { @shown="setFocus" > <template #header> - <gl-search-box-by-type - ref="search" - :value="searchTerm" - :placeholder="searchText" - class="js-dropdown-input-field" - @input="setSearchTerm" - /> + <slot name="header"> + <gl-search-box-by-type + ref="search" + :value="searchTerm" + :placeholder="searchText" + class="js-dropdown-input-field" + @input="setSearchTerm" + /> + </slot> </template> - <gl-dropdown-form class="gl-relative gl-min-h-7"> - <gl-loading-icon - v-if="isLoading" - size="md" - class="gl-absolute gl-left-0 gl-top-0 gl-right-0" - /> - <template v-else> - <template v-if="isSearchEmpty && presetOptions.length > 0"> + <slot name="default"> + <gl-dropdown-form class="gl-relative gl-min-h-7" data-qa-selector="labels_dropdown_content"> + <gl-loading-icon + v-if="isLoading" + size="md" + class="gl-absolute gl-left-0 gl-top-0 gl-right-0" + /> + <template v-else> + <template v-if="isSearchEmpty && presetOptions.length > 0"> + <gl-dropdown-item + v-for="option in presetOptions" + :key="option.id" + :is-checked="isSelected(option)" + :is-check-centered="true" + :is-check-item="true" + @click.native.capture.stop="selectOption(option)" + > + <slot name="preset-item" :item="option"> + {{ option.title }} + </slot> + </gl-dropdown-item> + <gl-dropdown-divider /> + </template> <gl-dropdown-item - v-for="option in presetOptions" + v-for="option in options" :key="option.id" :is-checked="isSelected(option)" :is-check-centered="true" :is-check-item="true" - @click="selectOption(option)" + :avatar-url="avatarUrl(option)" + :secondary-text="secondaryText(option)" + data-testid="unselected-option" + @click.native.capture.stop="selectOption(option)" > - <slot name="preset-item" :item="option"> + <slot name="item" :item="option"> {{ option.title }} </slot> </gl-dropdown-item> - <gl-dropdown-divider /> + <gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!"> + {{ $options.i18n.noMatchingResults }} + </gl-dropdown-item> </template> - <gl-dropdown-item - v-for="option in options" - :key="option.id" - :is-checked="isSelected(option)" - :is-check-centered="true" - :is-check-item="true" - :avatar-url="avatarUrl(option)" - :secondary-text="secondaryText(option)" - data-testid="unselected-option" - @click="selectOption(option)" - > - <slot name="item" :item="option"> - {{ option.title }} - </slot> - </gl-dropdown-item> - <gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!"> - {{ $options.i18n.noMatchingResults }} - </gl-dropdown-item> - </template> - </gl-dropdown-form> + </gl-dropdown-form> + </slot> <template #footer> <slot name="footer"></slot> </template> diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue index 276fb35b51f..adf34f822ed 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/file_icon.vue @@ -86,7 +86,7 @@ export default { <template> <span> <gl-loading-icon v-if="loading" size="sm" :inline="true" /> - <gl-icon v-else-if="isSymlink" name="symlink" :size="size" use-deprecated-sizes /> + <gl-icon v-else-if="isSymlink" name="symlink" :size="size" /> <svg v-else-if="!folder" :key="spriteHref" :class="[iconSizeClass, cssClasses]"> <use v-bind="{ 'xlink:href': spriteHref }" /> </svg> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index 1df65d0a666..d9290e86bca 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -53,6 +53,7 @@ export const TOKEN_TITLE_ASSIGNEE = __('Assignee'); export const TOKEN_TITLE_MILESTONE = __('Milestone'); export const TOKEN_TITLE_LABEL = __('Label'); export const TOKEN_TITLE_TYPE = __('Type'); +export const TOKEN_TITLE_RELEASE = __('Release'); export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential'); export const TOKEN_TITLE_ITERATION = __('Iteration'); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 9dc5c5db276..7c1828f2294 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -171,15 +171,17 @@ export default { * This watcher listens for updates to `filterValue` on * such instances. :( */ - filterValue(value) { - const [firstVal] = value; + filterValue(newValue, oldValue) { + const [firstVal] = newValue; if ( !this.initialRender && - value.length === 1 && + newValue.length === 1 && firstVal.type === 'filtered-search-term' && !firstVal.value.data ) { - this.$emit('onFilter', []); + const filtersCleared = + oldValue[0].type !== 'filtered-search-term' || oldValue[0].value.data !== ''; + this.$emit('onFilter', [], filtersCleared); } // Set initial render flag to false diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index ae5d3965de1..b3b3d5c88c6 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -1,6 +1,6 @@ <script> import { GlAvatar, GlFilteredSearchSuggestion } from '@gitlab/ui'; - +import { compact } from 'lodash'; import createFlash from '~/flash'; import { __ } from '~/locale'; @@ -59,8 +59,10 @@ export default { .then((res) => { // We'd want to avoid doing this check but // users.json and /groups/:id/members & /projects/:id/users - // return response differently. - this.authors = Array.isArray(res) ? res : res.data; + // return response differently + + // TODO: rm when completed https://gitlab.com/gitlab-org/gitlab/-/issues/345756 + this.authors = Array.isArray(res) ? compact(res) : compact(res.data); }) .catch(() => createFlash({ diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue index c1d1bc7da91..aff93ebc9c0 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue @@ -1,16 +1,21 @@ <script> -import { GlFilteredSearchSuggestion } from '@gitlab/ui'; +import { GlDropdownDivider, GlDropdownSectionHeader, GlFilteredSearchSuggestion } from '@gitlab/ui'; import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DEFAULT_ITERATIONS } from '../constants'; export default { components: { BaseToken, + GlDropdownDivider, + GlDropdownSectionHeader, GlFilteredSearchSuggestion, }, + mixins: [glFeatureFlagMixin()], props: { active: { type: Boolean, @@ -40,6 +45,27 @@ export default { getActiveIteration(iterations, data) { return iterations.find((iteration) => this.getValue(iteration) === data); }, + groupIterationsByCadence(iterations) { + const cadences = []; + iterations.forEach((iteration) => { + if (!iteration.iterationCadence) { + return; + } + const { title } = iteration.iterationCadence; + const cadenceIteration = { + id: iteration.id, + title: iteration.title, + period: this.getIterationPeriod(iteration), + }; + const cadence = cadences.find((cad) => cad.title === title); + if (cadence) { + cadence.iterations.push(cadenceIteration); + } else { + cadences.push({ title, iterations: [cadenceIteration] }); + } + }); + return cadences; + }, fetchIterations(searchTerm) { this.loading = true; this.config @@ -57,6 +83,16 @@ export default { getValue(iteration) { return String(getIdFromGraphQLId(iteration.id)); }, + /** + * TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/344619 + * This method also exists as a utility function in ee/../iterations/utils.js + * Remove the duplication when iteration token is moved to EE. + */ + getIterationPeriod({ startDate, dueDate }) { + const start = formatDate(startDate, 'mmm d, yyyy', true); + const due = formatDate(dueDate, 'mmm d, yyyy', true); + return `${start} - ${due}`; + }, }, }; </script> @@ -77,13 +113,26 @@ export default { {{ activeTokenValue ? activeTokenValue.title : inputValue }} </template> <template #suggestions-list="{ suggestions }"> - <gl-filtered-search-suggestion - v-for="iteration in suggestions" - :key="iteration.id" - :value="getValue(iteration)" - > - {{ iteration.title }} - </gl-filtered-search-suggestion> + <template v-for="(cadence, index) in groupIterationsByCadence(suggestions)"> + <gl-dropdown-divider v-if="index !== 0" :key="index" /> + <gl-dropdown-section-header + :key="cadence.title" + class="gl-overflow-hidden" + :title="cadence.title" + > + {{ cadence.title }} + </gl-dropdown-section-header> + <gl-filtered-search-suggestion + v-for="iteration in cadence.iterations" + :key="iteration.id" + :value="getValue(iteration)" + > + {{ iteration.title }} + <div v-if="glFeatures.iterationCadences" class="gl-text-gray-400"> + {{ iteration.period }} + </div> + </gl-filtered-search-suggestion> + </template> </template> </base-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue new file mode 100644 index 00000000000..f353cc3a765 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue @@ -0,0 +1,85 @@ +<script> +import { GlFilteredSearchSuggestion } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { DEFAULT_NONE_ANY } from '../constants'; + +export default { + components: { + BaseToken, + GlFilteredSearchSuggestion, + }, + props: { + active: { + type: Boolean, + required: true, + }, + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + releases: this.config.initialReleases || [], + loading: false, + }; + }, + computed: { + defaultReleases() { + return this.config.defaultReleases || DEFAULT_NONE_ANY; + }, + }, + methods: { + getActiveRelease(releases, data) { + return releases.find((release) => release.tag.toLowerCase() === data.toLowerCase()); + }, + fetchReleases(searchTerm) { + this.loading = true; + this.config + .fetchReleases(searchTerm) + .then((response) => { + this.releases = response; + }) + .catch(() => { + createFlash({ message: __('There was a problem fetching releases.') }); + }) + .finally(() => { + this.loading = false; + }); + }, + }, +}; +</script> + +<template> + <base-token + :active="active" + :config="config" + :value="value" + :default-suggestions="defaultReleases" + :suggestions="releases" + :suggestions-loading="loading" + :get-active-token-value="getActiveRelease" + @fetch-suggestions="fetchReleases" + v-on="$listeners" + > + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> + {{ activeTokenValue ? activeTokenValue.tag : inputValue }} + </template> + <template #suggestions-list="{ suggestions }"> + <gl-filtered-search-suggestion + v-for="release in suggestions" + :key="release.id" + :value="release.tag" + > + {{ release.tag }} + </gl-filtered-search-suggestion> + </template> + </base-token> +</template> diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 41613bb3307..6ace0bd88f8 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -1,10 +1,16 @@ <script> -import { GlTooltipDirective, GlLink, GlButton, GlTooltip, GlSafeHtmlDirective } from '@gitlab/ui'; +import { + GlTooltipDirective, + GlButton, + GlSafeHtmlDirective, + GlAvatarLink, + GlAvatarLabeled, +} from '@gitlab/ui'; +import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { glEmojiTag } from '../../emoji'; import { __, sprintf } from '../../locale'; import CiIconBadge from './ci_badge_link.vue'; import TimeagoTooltip from './time_ago_tooltip.vue'; -import UserAvatarImage from './user_avatar/user_avatar_image.vue'; /** * Renders header component for job and pipeline page based on UI mockups @@ -17,10 +23,9 @@ export default { components: { CiIconBadge, TimeagoTooltip, - UserAvatarImage, - GlLink, GlButton, - GlTooltip, + GlAvatarLink, + GlAvatarLabeled, }, directives: { GlTooltip: GlTooltipDirective, @@ -94,6 +99,9 @@ export default { return this.itemName; }, + userId() { + return isGid(this.user?.id) ? getIdFromGraphQLId(this.user?.id) : this.user?.id; + }, }, methods: { @@ -124,24 +132,32 @@ export default { {{ __('by') }} <template v-if="user"> - <gl-link - v-gl-tooltip - :href="userPath" - :title="user.email" - class="js-user-link commit-committer-link" + <gl-avatar-link + :data-user-id="userId" + :data-username="user.username" + :data-name="user.name" + :href="user.webUrl" + target="_blank" + class="js-user-link gl-vertical-align-middle gl-mx-2 gl-align-items-center" > - <user-avatar-image :img-src="avatarUrl" :img-alt="userAvatarAltText" :size="24" /> - {{ user.name }} - </gl-link> - <gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]"> - {{ message }} - </gl-tooltip> - <span - v-if="statusTooltipHTML" - :ref="$options.EMOJI_REF" - v-safe-html:[$options.safeHtmlConfig]="statusTooltipHTML" - :data-testid="message" - ></span> + <gl-avatar-labeled + :size="24" + :src="avatarUrl" + :label="user.name" + class="gl-display-none gl-sm-display-inline-flex gl-mx-1" + /> + <strong class="author gl-display-inline gl-sm-display-none!">@{{ user.username }}</strong> + <gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]"> + {{ message }} + </gl-tooltip> + <span + v-if="statusTooltipHTML" + :ref="$options.EMOJI_REF" + v-safe-html:[$options.safeHtmlConfig]="statusTooltipHTML" + class="gl-ml-2" + :data-testid="message" + ></span> + </gl-avatar-link> </template> </section> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index f9ae59567b2..648e9c9462f 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -1,11 +1,11 @@ <script> -import { GlButton, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlBadge, GlButton, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import ApplySuggestion from './apply_suggestion.vue'; export default { - components: { GlIcon, GlButton, GlLoadingIcon, ApplySuggestion }, + components: { GlBadge, GlIcon, GlButton, GlLoadingIcon, ApplySuggestion }, directives: { 'gl-tooltip': GlTooltipDirective }, props: { batchSuggestionsCount: { @@ -134,8 +134,14 @@ export default { <gl-icon name="question-o" css-classes="link-highlight" /> </a> </div> - <div v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</div> - <div v-else-if="isApplying" class="d-flex align-items-center text-secondary"> + <gl-badge v-if="isApplied" variant="success" data-qa-selector="applied_badge"> + {{ __('Applied') }} + </gl-badge> + <div + v-else-if="isApplying" + class="d-flex align-items-center text-secondary" + data-qa-selector="applying_badge" + > <gl-loading-icon size="sm" class="d-flex-center mr-2" /> <span>{{ applyingSuggestionsMessage }}</span> </div> diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index 755e6f1f224..8877cfa39fb 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -26,6 +26,7 @@ import { import $ from 'jquery'; import { mapGetters, mapActions, mapState } from 'vuex'; import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; +import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import initMRPopovers from '~/mr_popover/'; import noteHeader from '~/notes/components/note_header.vue'; @@ -61,6 +62,9 @@ export default { data() { return { expanded: false, + lines: [], + showLines: false, + loadingDiff: false, }; }, computed: { @@ -94,10 +98,25 @@ export default { }, methods: { ...mapActions(['fetchDescriptionVersion', 'softDeleteDescriptionVersion']), + async toggleDiff() { + this.showLines = !this.showLines; + + if (!this.lines.length) { + this.loadingDiff = true; + const { data } = await axios.get(this.note.outdated_line_change_path); + + this.lines = data.map((l) => ({ + ...l, + rich_text: l.rich_text.replace(/^[+ -]/, ''), + })); + this.loadingDiff = false; + } + }, }, safeHtmlConfig: { ADD_TAGS: ['use'], // to support icon SVGs }, + userColorSchemeClass: window.gon.user_color_scheme, }; </script> @@ -112,15 +131,28 @@ export default { <div class="note-header"> <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id"> <span v-safe-html="actionTextHtml"></span> - <template v-if="canSeeDescriptionVersion" #extra-controls> + <template + v-if="canSeeDescriptionVersion || note.outdated_line_change_path" + #extra-controls + > · <gl-button + v-if="canSeeDescriptionVersion" variant="link" :icon="descriptionVersionToggleIcon" data-testid="compare-btn" @click="toggleDescriptionVersion" >{{ __('Compare with previous version') }}</gl-button > + <gl-button + v-if="note.outdated_line_change_path" + :icon="showLines ? 'chevron-up' : 'chevron-down'" + variant="link" + data-testid="outdated-lines-change-btn" + @click="toggleDiff" + > + {{ __('Compare changes') }} + </gl-button> </template> </note-header> </div> @@ -154,6 +186,37 @@ export default { @click="deleteDescriptionVersion" /> </div> + <div + v-if="lines.length && showLines" + class="diff-content gl-border-solid gl-border-1 gl-border-gray-200 gl-mt-4 gl-rounded-small gl-overflow-hidden" + > + <table + :class="$options.userColorSchemeClass" + class="code js-syntax-highlight" + data-testid="outdated-lines" + > + <tr v-for="line in lines" v-once :key="line.line_code" class="line_holder"> + <td + :class="line.type" + class="diff-line-num old_line gl-border-bottom-0! gl-border-top-0!" + > + {{ line.old_line }} + </td> + <td + :class="line.type" + class="diff-line-num new_line gl-border-bottom-0! gl-border-top-0!" + > + {{ line.new_line }} + </td> + <td + :class="line.type" + class="line_content gl-display-table-cell!" + v-html="line.rich_text /* eslint-disable-line vue/no-v-html */" + ></td> + </tr> + </table> + </div> + <gl-skeleton-loading v-else-if="showLines" class="gl-mt-4" /> </div> </div> </timeline-entry-item> diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue index 4b21ec0330a..d108d8d689d 100644 --- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue +++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue @@ -69,20 +69,23 @@ export default { /> <div class="gl-display-flex gl-flex-direction-column"> - <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-2" data-testid="title"> + <h2 class="gl-font-size-h1 gl-mt-3 gl-mb-0" data-testid="title"> <slot name="title">{{ title }}</slot> - </h1> + </h2> <div v-if="$slots['sub-header']" - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3" > <slot name="sub-header"></slot> </div> </div> </div> - <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"> + <div + v-if="metadataSlots.length > 0" + class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3" + > <template v-if="!metadataLoading"> <div v-for="(row, metadataIndex) in metadataSlots" diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js index facace0d809..34845e3d9e4 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js @@ -2,6 +2,8 @@ import { s__ } from '~/locale'; export const PLATFORMS_WITHOUT_ARCHITECTURES = ['docker', 'kubernetes']; +export const REGISTRATION_TOKEN_PLACEHOLDER = '$REGISTRATION_TOKEN'; + export const INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES = { docker: { instructions: s__( diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue index d55c93fd146..d5493aa5a66 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue @@ -16,8 +16,9 @@ import { isEmpty } from 'lodash'; import { __, s__ } from '~/locale'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import { - PLATFORMS_WITHOUT_ARCHITECTURES, INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES, + PLATFORMS_WITHOUT_ARCHITECTURES, + REGISTRATION_TOKEN_PLACEHOLDER, } from './constants'; import getRunnerPlatformsQuery from './graphql/queries/get_runner_platforms.query.graphql'; import getRunnerSetupInstructionsQuery from './graphql/queries/get_runner_setup.query.graphql'; @@ -41,7 +42,13 @@ export default { props: { modalId: { type: String, - required: true, + required: false, + default: 'runner-instructions-modal', + }, + registrationToken: { + type: String, + required: false, + default: null, }, }, apollo: { @@ -117,8 +124,20 @@ export default { runnerInstallationLink() { return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatformName]?.link; }, + registerInstructionsWithToken() { + const { registerInstructions } = this.instructions || {}; + + if (this.registrationToken) { + return registerInstructions.replace(REGISTRATION_TOKEN_PLACEHOLDER, this.registrationToken); + } + + return registerInstructions; + }, }, methods: { + show() { + this.$refs.modal.show(); + }, selectPlatform(platform) { this.selectedPlatform = platform; @@ -158,9 +177,11 @@ export default { </script> <template> <gl-modal + ref="modal" :modal-id="modalId" :title="$options.i18n.installARunner" :action-secondary="$options.closeButton" + v-bind="$attrs" > <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)"> {{ $options.i18n.fetchError }} @@ -243,11 +264,11 @@ export default { <pre class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line" data-testid="register-command" - >{{ instructions.registerInstructions }}</pre + >{{ registerInstructionsWithToken }}</pre > <modal-copy-button :title="$options.i18n.copyInstructions" - :text="instructions.registerInstructions" + :text="registerInstructionsWithToken" :modal-id="$options.modalId" css-classes="gl-align-self-start gl-ml-2 gl-mt-2" category="tertiary" diff --git a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue index e75fedbb1d7..e68f0f31c13 100644 --- a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue +++ b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue @@ -24,10 +24,13 @@ export default { }, }, data() { + const forceOpen = !this.collapsible || this.defaultExpanded; return { // Non-collapsible sections should always be expanded. // For collapsible sections, fall back to defaultExpanded. - sectionExpanded: !this.collapsible || this.defaultExpanded, + sectionExpanded: forceOpen, + initialised: forceOpen, + animating: false, }; }, computed: { @@ -53,7 +56,12 @@ export default { toggleSectionExpanded() { this.sectionExpanded = !this.sectionExpanded; + if (!this.initialised) { + this.initialised = true; + } + if (this.sectionExpanded) { + this.animating = true; this.$refs.settingsContent.focus(); } }, @@ -68,7 +76,10 @@ export default { </script> <template> - <section class="settings" :class="{ 'no-animate': !slideAnimated, expanded: sectionExpanded }"> + <section + class="settings" + :class="{ 'no-animate': !slideAnimated, expanded: sectionExpanded, animating }" + > <div class="settings-header"> <h4> <span @@ -103,12 +114,14 @@ export default { </p> </div> <div + v-show="initialised" :id="settingsContentId" ref="settingsContent" :aria-labelledby="settingsLabelId" tabindex="-1" role="region" class="settings-content" + @animationend="animating = false" > <slot></slot> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue index 6511c8d8c31..460a10e08ed 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue @@ -40,7 +40,7 @@ export default { </script> <template> - <div v-gl-tooltip.left.viewport :class="containerClass" :title="tooltipText" @click="click"> + <div v-gl-tooltip.left.viewport="tooltipText" :class="containerClass" @click="click"> <gl-icon v-if="showIcon" name="calendar" /> <slot> <span> {{ text }} </span> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue index d80b66fd9be..399db978b60 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue @@ -36,6 +36,7 @@ export default { <template> <div class="labels-select-dropdown-contents gl-w-full gl-my-2 gl-py-3 gl-rounded-base gl-absolute" + data-testid="labels-select-dropdown-contents" data-qa-selector="labels_dropdown_content" :style="directionStyle" > diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue index 122250d1ce7..8a26c4a6618 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue @@ -43,12 +43,7 @@ export default { </script> <template> - <div - v-gl-tooltip.left.viewport - :title="labelsList" - class="sidebar-collapsed-icon" - @click="handleClick" - > + <div v-gl-tooltip.left.viewport="labelsList" class="sidebar-collapsed-icon" @click="handleClick"> <gl-icon name="labels" /> <span>{{ labels.length }}</span> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js index 0ea22eb7aea..9e64f03fe84 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js @@ -1,5 +1,4 @@ import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils'; -import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import { DropdownVariant } from '../constants'; import * as types from './mutation_types'; @@ -67,9 +66,11 @@ export default { } if (isScopedLabel(candidateLabel)) { - const scopedKeyWithDelimiter = `${scopedLabelKey(candidateLabel)}${SCOPED_LABEL_DELIMITER}`; const currentActiveScopedLabel = state.labels.find( - ({ title }) => title.startsWith(scopedKeyWithDelimiter) && title !== candidateLabel.title, + ({ set, title }) => + set && + title !== candidateLabel.title && + scopedLabelKey({ title }) === scopedLabelKey(candidateLabel), ); if (currentActiveScopedLabel) { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js index 389eb174c0e..cd671b4d8f5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js @@ -1,7 +1,13 @@ export const SCOPED_LABEL_DELIMITER = '::'; +export const DEBOUNCE_DROPDOWN_DELAY = 200; export const DropdownVariant = { Sidebar: 'sidebar', Standalone: 'standalone', Embedded: 'embedded', }; + +export const LabelType = { + group: 'group', + project: 'project', +}; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue index 3ee0baf8812..f7485de0342 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue @@ -1,20 +1,25 @@ <script> import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; import DropdownContentsCreateView from './dropdown_contents_create_view.vue'; import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue'; +import DropdownFooter from './dropdown_footer.vue'; +import DropdownHeader from './dropdown_header.vue'; import { isDropdownVariantStandalone, isDropdownVariantSidebar } from './utils'; export default { components: { DropdownContentsLabelsView, DropdownContentsCreateView, + DropdownHeader, + DropdownFooter, GlButton, GlDropdown, GlDropdownItem, GlLink, }, - inject: ['allowLabelCreate', 'labelsManagePath'], props: { labelsCreateTitle: { type: String, @@ -48,10 +53,6 @@ export default { type: String, required: true, }, - issuableType: { - type: String, - required: true, - }, isVisible: { type: Boolean, required: false, @@ -61,10 +62,17 @@ export default { type: String, required: true, }, + workspaceType: { + type: String, + required: true, + }, attrWorkspacePath: { type: String, - required: false, - default: undefined, + required: true, + }, + labelCreateType: { + type: String, + required: true, }, }, data() { @@ -72,6 +80,7 @@ export default { showDropdownContentsCreateView: false, localSelectedLabels: [...this.selectedLabels], isDirty: false, + searchKey: '', }; }, computed: { @@ -113,15 +122,24 @@ export default { if (newVal) { this.$refs.dropdown.show(); this.isDirty = false; + this.localSelectedLabels = this.selectedLabels; } else { this.$refs.dropdown.hide(); this.setLabels(); } }, selectedLabels(newVal) { - this.localSelectedLabels = newVal; + if (!this.isDirty) { + this.localSelectedLabels = newVal; + } }, }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + beforeDestroy() { + this.debouncedSearchKeyUpdate.cancel(); + }, methods: { toggleDropdownContentsCreateView() { this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView; @@ -140,10 +158,20 @@ export default { this.$emit('setLabels', this.localSelectedLabels); }, handleDropdownHide() { + this.$emit('closeDropdown'); if (!isDropdownVariantSidebar(this.variant)) { this.setLabels(); } }, + setSearchKey(value) { + this.searchKey = value; + }, + setFocus() { + this.$refs.header.focusInput(); + }, + showDropdown() { + this.$refs.dropdown.show(); + }, }, }; </script> @@ -153,62 +181,44 @@ export default { ref="dropdown" :text="buttonText" class="gl-w-full gl-mt-2" + data-testid="labels-select-dropdown-contents" data-qa-selector="labels_dropdown_content" @hide="handleDropdownHide" + @shown="setFocus" > <template #header> - <div + <dropdown-header v-if="!isStandalone" - class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" - data-testid="dropdown-header" - > - <gl-button - v-if="showDropdownContentsCreateView" - :aria-label="__('Go back')" - variant="link" - size="small" - class="js-btn-back dropdown-header-button gl-p-0" - icon="arrow-left" - data-testid="go-back-button" - @click.stop="toggleDropdownContent" - /> - <span class="gl-flex-grow-1">{{ dropdownTitle }}</span> - <gl-button - :aria-label="__('Close')" - variant="link" - size="small" - class="dropdown-header-button gl-p-0!" - icon="close" - data-testid="close-button" - @click="$emit('closeDropdown')" - /> - </div> + ref="header" + v-model="searchKey" + :labels-create-title="labelsCreateTitle" + :labels-list-title="labelsListTitle" + :show-dropdown-contents-create-view="showDropdownContentsCreateView" + @toggleDropdownContentsCreateView="toggleDropdownContent" + @closeDropdown="$emit('closeDropdown')" + @input="debouncedSearchKeyUpdate" + /> </template> <template #default> <component :is="dropdownContentsView" v-model="localSelectedLabels" - :selected-labels="selectedLabels" + :search-key="searchKey" :allow-multiselect="allowMultiselect" - :issuable-type="issuableType" :full-path="fullPath" + :workspace-type="workspaceType" :attr-workspace-path="attrWorkspacePath" - @hideCreateView="toggleDropdownContentsCreateView" + :label-create-type="labelCreateType" + @hideCreateView="toggleDropdownContent" /> </template> <template #footer> - <div v-if="showDropdownFooter" data-testid="dropdown-footer"> - <gl-dropdown-item - v-if="allowLabelCreate" - data-testid="create-label-button" - @click.capture.native.stop="toggleDropdownContent" - > - {{ footerCreateLabelTitle }} - </gl-dropdown-item> - <gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop> - {{ footerManageLabelTitle }} - </gl-dropdown-item> - </div> + <dropdown-footer + v-if="showDropdownFooter" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" + @toggleDropdownContentsCreateView="toggleDropdownContent" + /> </template> </gl-dropdown> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue index a2ed08e6b28..da626a21b14 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue @@ -2,10 +2,10 @@ import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; import produce from 'immer'; import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; import { __ } from '~/locale'; -import { labelsQueries } from '~/sidebar/constants'; +import { workspaceLabelsQueries } from '~/sidebar/constants'; import createLabelMutation from './graphql/create_label.mutation.graphql'; +import { LabelType } from './constants'; const errorMessage = __('Error creating label.'); @@ -20,18 +20,21 @@ export default { GlTooltip: GlTooltipDirective, }, props: { - issuableType: { + fullPath: { type: String, required: true, }, - fullPath: { + attrWorkspacePath: { type: String, required: true, }, - attrWorkspacePath: { + labelCreateType: { + type: String, + required: true, + }, + workspaceType: { type: String, - required: false, - default: undefined, + required: true, }, }, data() { @@ -50,25 +53,13 @@ export default { return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] })); }, mutationVariables() { - if (this.issuableType === IssuableType.Epic) { - return { - title: this.labelTitle, - color: this.selectedColor, - groupPath: this.fullPath, - }; - } + const attributePath = this.labelCreateType === LabelType.group ? 'groupPath' : 'projectPath'; - return this.attrWorkspacePath !== undefined - ? { - title: this.labelTitle, - color: this.selectedColor, - groupPath: this.attrWorkspacePath, - } - : { - title: this.labelTitle, - color: this.selectedColor, - projectPath: this.fullPath, - }; + return { + title: this.labelTitle, + color: this.selectedColor, + [attributePath]: this.attrWorkspacePath, + }; }, }, methods: { @@ -82,8 +73,10 @@ export default { this.selectedColor = this.getColorCode(color); }, updateLabelsInCache(store, label) { + const { query } = workspaceLabelsQueries[this.workspaceType]; + const sourceData = store.readQuery({ - query: labelsQueries[this.issuableType].workspaceQuery, + query, variables: { fullPath: this.fullPath, searchTerm: '' }, }); @@ -95,7 +88,7 @@ export default { }); store.writeQuery({ - query: labelsQueries[this.issuableType].workspaceQuery, + query, variables: { fullPath: this.fullPath, searchTerm: '' }, data, }); @@ -180,7 +173,7 @@ export default { <gl-button class="js-btn-cancel-create" data-testid="cancel-button" - @click="$emit('hideCreateView')" + @click.stop="$emit('hideCreateView')" > {{ __('Cancel') }} </gl-button> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue index e6a25362ff0..e9a2d7747e2 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue @@ -1,18 +1,10 @@ <script> -import { - GlDropdownForm, - GlDropdownItem, - GlLoadingIcon, - GlSearchBoxByType, - GlIntersectionObserver, -} from '@gitlab/ui'; +import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import { debounce } from 'lodash'; import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __ } from '~/locale'; -import { labelsQueries } from '~/sidebar/constants'; +import { workspaceLabelsQueries } from '~/sidebar/constants'; import LabelItem from './label_item.vue'; export default { @@ -20,7 +12,6 @@ export default { GlDropdownForm, GlDropdownItem, GlLoadingIcon, - GlSearchBoxByType, GlIntersectionObserver, LabelItem, }, @@ -28,18 +19,10 @@ export default { prop: 'localSelectedLabels', }, props: { - selectedLabels: { - type: Array, - required: true, - }, allowMultiselect: { type: Boolean, required: true, }, - issuableType: { - type: String, - required: true, - }, localSelectedLabels: { type: Array, required: true, @@ -48,10 +31,17 @@ export default { type: String, required: true, }, + searchKey: { + type: String, + required: true, + }, + workspaceType: { + type: String, + required: true, + }, }, data() { return { - searchKey: '', labels: [], isVisible: false, }; @@ -59,7 +49,7 @@ export default { apollo: { labels: { query() { - return labelsQueries[this.issuableType].workspaceQuery; + return workspaceLabelsQueries[this.workspaceType].query; }, variables() { return { @@ -71,12 +61,6 @@ export default { return this.searchKey.length === 1 || !this.isVisible; }, update: (data) => data.workspace?.labels?.nodes || [], - async result() { - if (this.$refs.searchInput) { - await this.$nextTick; - this.$refs.searchInput.focusInput(); - } - }, error() { createFlash({ message: __('Error fetching labels.') }); }, @@ -101,12 +85,6 @@ export default { return Boolean(this.searchKey) && this.visibleLabels.length === 0; }, }, - created() { - this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); - }, - beforeDestroy() { - this.debouncedSearchKeyUpdate.cancel(); - }, methods: { isLabelSelected(label) { return this.localSelectedLabelsIds.includes(getIdFromGraphQLId(label.id)); @@ -137,13 +115,7 @@ export default { ({ id }) => id !== getIdFromGraphQLId(label.id) && id !== label.id, ); } else { - labels = [ - ...this.localSelectedLabels, - { - ...label, - id: getIdFromGraphQLId(label.id), - }, - ]; + labels = [...this.localSelectedLabels, label]; } this.$emit('input', labels); }, @@ -153,12 +125,8 @@ export default { this.$emit('closeDropdown', this.localSelectedLabels); } }, - setSearchKey(value) { - this.searchKey = value; - }, onDropdownAppear() { this.isVisible = true; - this.$refs.searchInput.focusInput(); }, }, }; @@ -167,14 +135,6 @@ export default { <template> <gl-intersection-observer @appear="onDropdownAppear"> <gl-dropdown-form class="labels-select-contents-list js-labels-list"> - <gl-search-box-by-type - ref="searchInput" - :value="searchKey" - :disabled="labelsFetchInProgress" - data-qa-selector="dropdown_input_field" - data-testid="dropdown-input-field" - @input="debouncedSearchKeyUpdate" - /> <div ref="labelsListContainer" data-testid="dropdown-content"> <gl-loading-icon v-if="labelsFetchInProgress" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue new file mode 100644 index 00000000000..e67e704ffb8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue @@ -0,0 +1,35 @@ +<script> +import { GlDropdownItem } from '@gitlab/ui'; + +export default { + components: { + GlDropdownItem, + }, + inject: ['allowLabelCreate', 'labelsManagePath'], + props: { + footerCreateLabelTitle: { + type: String, + required: true, + }, + footerManageLabelTitle: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div data-testid="dropdown-footer"> + <gl-dropdown-item + v-if="allowLabelCreate" + data-testid="create-label-button" + @click.capture.native.stop="$emit('toggleDropdownContentsCreateView')" + > + {{ footerCreateLabelTitle }} + </gl-dropdown-item> + <gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop> + {{ footerManageLabelTitle }} + </gl-dropdown-item> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue new file mode 100644 index 00000000000..10064b01648 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue @@ -0,0 +1,82 @@ +<script> +import { GlButton, GlSearchBoxByType } from '@gitlab/ui'; + +export default { + components: { + GlButton, + GlSearchBoxByType, + }, + model: { + prop: 'searchKey', + }, + props: { + labelsCreateTitle: { + type: String, + required: true, + }, + labelsListTitle: { + type: String, + required: true, + }, + showDropdownContentsCreateView: { + type: Boolean, + required: true, + }, + labelsFetchInProgress: { + type: Boolean, + required: false, + default: false, + }, + searchKey: { + type: String, + required: true, + }, + }, + computed: { + dropdownTitle() { + return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle; + }, + }, + methods: { + focusInput() { + this.$refs.searchInput?.focusInput(); + }, + }, +}; +</script> + +<template> + <div data-testid="dropdown-header"> + <div class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"> + <gl-button + v-if="showDropdownContentsCreateView" + :aria-label="__('Go back')" + variant="link" + size="small" + class="js-btn-back dropdown-header-button gl-p-0" + icon="arrow-left" + data-testid="go-back-button" + @click.stop="$emit('toggleDropdownContentsCreateView')" + /> + <span class="gl-flex-grow-1">{{ dropdownTitle }}</span> + <gl-button + :aria-label="__('Close')" + variant="link" + size="small" + class="dropdown-header-button gl-p-0!" + icon="close" + data-testid="close-button" + @click="$emit('closeDropdown')" + /> + </div> + <gl-search-box-by-type + v-if="!showDropdownContentsCreateView" + ref="searchInput" + :value="searchKey" + :disabled="labelsFetchInProgress" + data-qa-selector="dropdown_input_field" + data-testid="dropdown-input-field" + @input="$emit('input', $event)" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue index 71d3d87cce5..aed5bc303ee 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue @@ -1,7 +1,6 @@ <script> import { GlLabel } from '@gitlab/ui'; import { sortBy } from 'lodash'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isScopedLabel } from '~/lib/utils/common_utils'; export default { @@ -47,7 +46,7 @@ export default { return this.allowScopedLabels && isScopedLabel(label); }, removeLabel(labelId) { - this.$emit('onLabelRemove', getIdFromGraphQLId(labelId)); + this.$emit('onLabelRemove', labelId); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql index eb478645a03..a9c791091fc 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql @@ -1,12 +1,11 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + mutation createLabel($title: String!, $color: String, $projectPath: ID, $groupPath: ID) { labelCreate( input: { title: $title, color: $color, projectPath: $projectPath, groupPath: $groupPath } ) { label { - id - color - description - title + ...Label } errors } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql index a2e8579486f..c130cc426dc 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql @@ -1,13 +1,12 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + query epicLabels($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { issuable: epic(iid: $iid) { id labels { nodes { - id - title - color - description + ...Label } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql new file mode 100644 index 00000000000..45fcb50732e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql @@ -0,0 +1,15 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + +mutation updateEpicLabels($input: UpdateEpicInput!) { + updateEpic(input: $input) { + epic { + id + labels { + nodes { + ...Label + } + } + } + errors + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql index acc9bcd2015..ce1a69f84c0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql @@ -1,11 +1,11 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + query groupLabels($fullPath: ID!, $searchTerm: String) { workspace: group(fullPath: $fullPath) { - labels(searchTerm: $searchTerm, onlyGroupLabels: true) { + id + labels(searchTerm: $searchTerm, onlyGroupLabels: true, includeAncestorGroups: true) { nodes { - id - title - color - description + ...Label } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql index 1c2fd3bb7c0..e471d279b24 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql @@ -1,13 +1,12 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + query issueLabels($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { issuable: issue(iid: $iid) { id labels { nodes { - id - title - color - description + ...Label } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql new file mode 100644 index 00000000000..dd80e89c8a7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql @@ -0,0 +1,14 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + +query mergeRequestLabels($fullPath: ID!, $iid: String!) { + workspace: project(fullPath: $fullPath) { + issuable: mergeRequest(iid: $iid) { + id + labels { + nodes { + ...Label + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql index dc39220487d..a7c24620aad 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql @@ -1,11 +1,11 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + query projectLabels($fullPath: ID!, $searchTerm: String) { workspace: project(fullPath: $fullPath) { + id labels(searchTerm: $searchTerm, includeAncestorGroups: true) { nodes { - id - title - color - description + ...Label } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue index 6bd43da2203..97a65c13933 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue @@ -1,9 +1,12 @@ <script> +import { debounce } from 'lodash'; +import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils'; import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; import { __ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { labelsQueries } from '~/sidebar/constants'; -import { DropdownVariant } from './constants'; +import { issuableLabelsQueries } from '~/sidebar/constants'; +import { DEBOUNCE_DROPDOWN_DELAY, DropdownVariant } from './constants'; import DropdownContents from './dropdown_contents.vue'; import DropdownValue from './dropdown_value.vue'; import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; @@ -50,16 +53,6 @@ export default { required: false, default: DropdownVariant.Sidebar, }, - selectedLabels: { - type: Array, - required: false, - default: () => [], - }, - labelsSelectInProgress: { - type: Boolean, - required: false, - default: false, - }, labelsFilterBasePath: { type: String, required: false, @@ -95,36 +88,44 @@ export default { required: false, default: __('Manage group labels'), }, - isEditing: { - type: Boolean, - required: false, - default: false, - }, issuableType: { type: String, required: true, }, + workspaceType: { + type: String, + required: true, + }, attrWorkspacePath: { type: String, - required: false, - default: undefined, + required: true, + }, + labelCreateType: { + type: String, + required: true, }, }, data() { return { contentIsOnViewport: true, issuableLabels: [], + labelsSelectInProgress: false, + oldIid: null, + sidebarExpandedOnClick: false, }; }, computed: { isLoading() { return this.labelsSelectInProgress || this.$apollo.queries.issuableLabels.loading; }, + issuableLabelIds() { + return this.issuableLabels.map((label) => label.id); + }, }, apollo: { issuableLabels: { query() { - return labelsQueries[this.issuableType].issuableQuery; + return issuableLabelsQueries[this.issuableType].issuableQuery; }, skip() { return !isDropdownVariantSidebar(this.variant); @@ -143,16 +144,140 @@ export default { }, }, }, + watch: { + iid(_, oldVal) { + this.oldIid = oldVal; + }, + }, + mounted() { + document.addEventListener('toggleSidebarRevealLabelsDropdown', this.handleCollapsedValueClick); + }, + beforeDestroy() { + document.removeEventListener( + 'toggleSidebarRevealLabelsDropdown', + this.handleCollapsedValueClick, + ); + }, methods: { handleDropdownClose(labels) { - this.$emit('updateSelectedLabels', labels); + if (this.iid !== '') { + this.updateSelectedLabels(this.getUpdateVariables(labels)); + } else { + this.$emit('updateSelectedLabels', { labels }); + } + this.collapseEditableItem(); }, collapseEditableItem() { this.$refs.editable?.collapse(); + if (this.sidebarExpandedOnClick) { + this.sidebarExpandedOnClick = false; + this.$emit('toggleCollapse'); + } }, handleCollapsedValueClick() { + this.sidebarExpandedOnClick = true; this.$emit('toggleCollapse'); + debounce(() => { + this.$refs.editable.toggle(); + this.$refs.dropdownContents.showDropdown(); + }, DEBOUNCE_DROPDOWN_DELAY)(); + }, + getUpdateVariables(labels) { + let labelIds = []; + + labelIds = labels.map(({ id }) => id); + const currentIid = this.oldIid || this.iid; + + const updateVariables = { + iid: currentIid, + projectPath: this.fullPath, + labelIds, + }; + + switch (this.issuableType) { + case IssuableType.Issue: + return updateVariables; + case IssuableType.MergeRequest: + return { + ...updateVariables, + operationMode: MutationOperationMode.Replace, + }; + case IssuableType.Epic: + return { + iid: currentIid, + groupPath: this.fullPath, + addLabelIds: labelIds.map((id) => getIdFromGraphQLId(id)), + removeLabelIds: this.issuableLabelIds + .filter((id) => !labelIds.includes(id)) + .map((id) => getIdFromGraphQLId(id)), + }; + default: + return {}; + } + }, + updateSelectedLabels(inputVariables) { + this.labelsSelectInProgress = true; + + this.$apollo + .mutate({ + mutation: issuableLabelsQueries[this.issuableType].mutation, + variables: { input: inputVariables }, + }) + .then(({ data }) => { + const { mutationName } = issuableLabelsQueries[this.issuableType]; + + if (data[mutationName]?.errors?.length) { + throw new Error(); + } + + this.$emit('updateSelectedLabels', { + id: data[mutationName]?.[this.issuableType]?.id, + labels: data[mutationName]?.[this.issuableType]?.labels?.nodes, + }); + }) + .catch((error) => + createFlash({ + message: __('An error occurred while updating labels.'), + captureError: true, + error, + }), + ) + .finally(() => { + this.labelsSelectInProgress = false; + }); + }, + getRemoveVariables(labelId) { + const removeVariables = { + iid: this.iid, + projectPath: this.fullPath, + }; + + switch (this.issuableType) { + case IssuableType.Issue: + return { + ...removeVariables, + removeLabelIds: [labelId], + }; + case IssuableType.MergeRequest: + return { + ...removeVariables, + labelIds: [labelId], + operationMode: MutationOperationMode.Remove, + }; + case IssuableType.Epic: + return { + iid: this.iid, + removeLabelIds: [getIdFromGraphQLId(labelId)], + groupPath: this.fullPath, + }; + default: + return {}; + } + }, + handleLabelRemove(labelId) { + this.updateSelectedLabels(this.getRemoveVariables(labelId)); + this.$emit('onLabelRemove', labelId); }, isDropdownVariantSidebar, isDropdownVariantStandalone, @@ -180,6 +305,7 @@ export default { :title="__('Labels')" :loading="isLoading" :can-edit="allowLabelEdit" + @open="oldIid = null" > <template #collapsed> <dropdown-value @@ -188,7 +314,7 @@ export default { :allow-label-remove="allowLabelRemove" :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" - @onLabelRemove="$emit('onLabelRemove', $event)" + @onLabelRemove="handleLabelRemove" > <slot></slot> </dropdown-value> @@ -201,23 +327,25 @@ export default { :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" class="gl-mb-2" - @onLabelRemove="$emit('onLabelRemove', $event)" + @onLabelRemove="handleLabelRemove" > <slot></slot> </dropdown-value> <dropdown-contents + ref="dropdownContents" :dropdown-button-text="dropdownButtonText" :allow-multiselect="allowMultiselect" :labels-list-title="labelsListTitle" :footer-create-label-title="footerCreateLabelTitle" :footer-manage-label-title="footerManageLabelTitle" :labels-create-title="labelsCreateTitle" - :selected-labels="selectedLabels" + :selected-labels="issuableLabels" :variant="variant" - :issuable-type="issuableType" :is-visible="edit" :full-path="fullPath" + :workspace-type="workspaceType" :attr-workspace-path="attrWorkspacePath" + :label-create-type="labelCreateType" @setLabels="handleDropdownClose" @closeDropdown="collapseEditableItem" /> @@ -233,10 +361,12 @@ export default { :footer-create-label-title="footerCreateLabelTitle" :footer-manage-label-title="footerManageLabelTitle" :labels-create-title="labelsCreateTitle" - :selected-labels="selectedLabels" + :selected-labels="issuableLabels" :variant="variant" - :issuable-type="issuableType" :full-path="fullPath" + :workspace-type="workspaceType" + :attr-workspace-path="attrWorkspacePath" + :label-create-type="labelCreateType" @setLabels="handleDropdownClose" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql index a2990d7171b..dffcc053fac 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql @@ -1,6 +1,6 @@ #import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql" -query timeTrackingReport($id: IssueID!) { +query issueTimeTrackingReport($id: IssueID!) { issuable: issue(id: $id) { __typename id diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql index 753f1b345e3..ede9b75d765 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql @@ -1,6 +1,6 @@ #import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql" -query timeTrackingReport($id: MergeRequestID!) { +query mrTimeTrackingReport($id: MergeRequestID!) { issuable: mergeRequest(id: $id) { __typename id diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue index 1eea660d527..a16dcb6d893 100644 --- a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue +++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue @@ -5,10 +5,10 @@ import { OBSTACLE_TYPES } from './constants'; const OBSTACLE_TEXT = { [OBSTACLE_TYPES.oncallSchedules]: s__( - 'OnCallSchedules|On-call schedule %{obstacle} in Project %{project}', + 'OnCallSchedules|On-call schedule %{obstacle} in project %{project}', ), [OBSTACLE_TYPES.escalationPolicies]: s__( - 'EscalationPolicies|Escalation policy %{obstacle} in Project %{project}', + 'EscalationPolicies|Escalation policy %{obstacle} in project %{project}', ), }; diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index 33fac5ebdbb..9cb66f6e65f 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -63,3 +63,6 @@ export const timeRanges = [ export const defaultTimeRange = timeRanges.find((tr) => tr.default); export const getTimeWindow = (timeWindowName) => timeRanges.find((tr) => tr.name === timeWindowName); + +export const AVATAR_SHAPE_OPTION_CIRCLE = 'circle'; +export const AVATAR_SHAPE_OPTION_RECT = 'rect'; diff --git a/app/assets/javascripts/vue_shared/directives/validation.js b/app/assets/javascripts/vue_shared/directives/validation.js index 779b04dc2bd..fc0ff78e7b4 100644 --- a/app/assets/javascripts/vue_shared/directives/validation.js +++ b/app/assets/javascripts/vue_shared/directives/validation.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { __ } from '~/locale'; /** * Validation messages will take priority based on the property order. @@ -12,11 +12,11 @@ import { s__ } from '~/locale'; const defaultFeedbackMap = { valueMissing: { isInvalid: (el) => el.validity?.valueMissing, - message: s__('Please fill out this field.'), + message: __('Please fill out this field.'), }, urlTypeMismatch: { isInvalid: (el) => el.type === 'url' && el.validity?.typeMismatch, - message: s__('Please enter a valid URL format, ex: http://www.example.com/home'), + message: __('Please enter a valid URL format, ex: http://www.example.com/home'), }, }; diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue index c1e8376d656..114f60c96ee 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue @@ -82,7 +82,7 @@ export default { }); this.$root.$on('clicked::link', (e) => { - window.location = e.target.href; + window.location = e.currentTarget.href; }); }, diff --git a/app/assets/javascripts/vue_shared/security_configuration/provider.js b/app/assets/javascripts/vue_shared/security_configuration/provider.js index fa23669b615..ef96b443da8 100644 --- a/app/assets/javascripts/vue_shared/security_configuration/provider.js +++ b/app/assets/javascripts/vue_shared/security_configuration/provider.js @@ -5,5 +5,5 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); export default new VueApollo({ - defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), + defaultClient: createDefaultClient(), }); diff --git a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue index 3a4453bc7ae..e0669b3ed27 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue +++ b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue @@ -26,6 +26,11 @@ export default { type: Number, required: true, }, + injectedArtifacts: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -56,6 +61,9 @@ export default { isLoadingReportArtifacts() { return this.$apollo.queries.reportArtifacts.loading; }, + mergedReportArtifacts() { + return [...this.reportArtifacts, ...this.injectedArtifacts]; + }, }, methods: { showError(error) { @@ -77,7 +85,7 @@ export default { <template> <security-report-download-dropdown :title="s__('SecurityReports|Download results')" - :artifacts="reportArtifacts" + :artifacts="mergedReportArtifacts" :loading="isLoadingReportArtifacts" /> </template> diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js index b024e92bd0e..fafbd02634f 100644 --- a/app/assets/javascripts/vue_shared/security_reports/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/constants.js @@ -17,6 +17,7 @@ export const REPORT_FILE_TYPES = { * Security scan report types, as provided by the backend. */ export const REPORT_TYPE_SAST = 'sast'; +export const REPORT_TYPE_SAST_IAC = 'sast_iac'; export const REPORT_TYPE_DAST = 'dast'; export const REPORT_TYPE_DAST_PROFILES = 'dast_profiles'; export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection'; diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql index b5858ab012b..e1f3c55a886 100644 --- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql @@ -1,6 +1,6 @@ #import "../fragments/job_artifacts.fragment.graphql" -query getCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityReportTypeEnum!]) { +query getPipelineCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityReportTypeEnum!]) { project(fullPath: $projectPath) { pipeline(iid: $iid) { id diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js index 62a51abe038..8aefc13a5fa 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js @@ -18,6 +18,7 @@ export const fetchDiff = ({ state, rootState, dispatch }) => { return fetchDiffData(rootState, state.paths.diffEndpoint, REPORT_TYPE_SAST) .then((data) => { dispatch('receiveDiffSuccess', data); + return data; }) .catch(() => { dispatch('receiveDiffError'); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js index 722dcce3075..13ca154bfa7 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js @@ -18,6 +18,7 @@ export const fetchDiff = ({ state, rootState, dispatch }) => { return fetchDiffData(rootState, state.paths.diffEndpoint, REPORT_TYPE_SECRET_DETECTION) .then((data) => { dispatch('receiveDiffSuccess', data); + return data; }) .catch(() => { dispatch('receiveDiffError'); diff --git a/app/assets/javascripts/work_items/components/app.vue b/app/assets/javascripts/work_items/components/app.vue index 93de17d1e43..a14d0c32cbe 100644 --- a/app/assets/javascripts/work_items/components/app.vue +++ b/app/assets/javascripts/work_items/components/app.vue @@ -1,9 +1,5 @@ -<script> -export default { - name: 'WorkItemRoot', -}; -</script> - <template> - <div></div> + <div> + <router-view /> + </div> </template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js new file mode 100644 index 00000000000..b39f68abf74 --- /dev/null +++ b/app/assets/javascripts/work_items/constants.js @@ -0,0 +1,3 @@ +export const widgetTypes = { + title: 'TITLE', +}; diff --git a/app/assets/javascripts/work_items/graphql/fragmentTypes.json b/app/assets/javascripts/work_items/graphql/fragmentTypes.json new file mode 100644 index 00000000000..c048ac34ac0 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/fragmentTypes.json @@ -0,0 +1 @@ +{"__schema":{"types":[{"kind":"INTERFACE","name":"WorkItemWidget","possibleTypes":[{"name":"TitleWidget"}]}]}} diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js new file mode 100644 index 00000000000..083735336ce --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/provider.js @@ -0,0 +1,55 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; +import createDefaultClient from '~/lib/graphql'; +import workItemQuery from './work_item.query.graphql'; +import introspectionQueryResultData from './fragmentTypes.json'; +import typeDefs from './typedefs.graphql'; + +const fragmentMatcher = new IntrospectionFragmentMatcher({ + introspectionQueryResultData, +}); + +export function createApolloProvider() { + Vue.use(VueApollo); + + const defaultClient = createDefaultClient( + {}, + { + cacheConfig: { + fragmentMatcher, + }, + typeDefs, + }, + ); + + defaultClient.cache.writeQuery({ + query: workItemQuery, + variables: { + id: '1', + }, + data: { + workItem: { + __typename: 'WorkItem', + id: '1', + type: 'FEATURE', + widgets: { + __typename: 'WorkItemWidgetConnection', + nodes: [ + { + __typename: 'TitleWidget', + type: 'TITLE', + enabled: true, + // eslint-disable-next-line @gitlab/require-i18n-strings + contentText: 'Test Work Item Title', + }, + ], + }, + }, + }, + }); + + return new VueApollo({ + defaultClient, + }); +} diff --git a/app/assets/javascripts/work_items/graphql/resolvers.js b/app/assets/javascripts/work_items/graphql/resolvers.js new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/resolvers.js diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index e69de29bb2d..4a6e4aeed60 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -0,0 +1,38 @@ +enum WorkItemType { + FEATURE +} + +enum WidgetType { + TITLE +} + +interface WorkItemWidget { + type: WidgetType! +} + +# Replicating Relay connection type for client schema +type WorkItemWidgetEdge { + cursor: String! + node: WorkItemWidget +} + +type WorkItemWidgetConnection { + edges: [WorkItemWidgetEdge] + nodes: [WorkItemWidget] + pageInfo: PageInfo! +} + +type TitleWidget implements WorkItemWidget { + type: WidgetType! + contentText: String! +} + +type WorkItem { + id: ID! + type: WorkItemType! + widgets: [WorkItemWidgetConnection] +} + +extend type Query { + workItem(id: ID!): WorkItem! +} diff --git a/app/assets/javascripts/work_items/graphql/widget.fragment.graphql b/app/assets/javascripts/work_items/graphql/widget.fragment.graphql new file mode 100644 index 00000000000..d7608c26052 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/widget.fragment.graphql @@ -0,0 +1,3 @@ +fragment WidgetBase on WorkItemWidget { + type +} diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql new file mode 100644 index 00000000000..549e4f8c65a --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -0,0 +1,16 @@ +#import './widget.fragment.graphql' + +query WorkItem($id: ID!) { + workItem(id: $id) @client { + id + type + widgets { + nodes { + ...WidgetBase + ... on TitleWidget { + contentText + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index a635d43776d..7cc8a23b7b1 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -1,11 +1,15 @@ import Vue from 'vue'; import App from './components/app.vue'; +import { createRouter } from './router'; +import { createApolloProvider } from './graphql/provider'; export const initWorkItemsRoot = () => { const el = document.querySelector('#js-work-items'); return new Vue({ el, + router: createRouter(el.dataset.fullPath), + apolloProvider: createApolloProvider(), render(createElement) { return createElement(App); }, diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue new file mode 100644 index 00000000000..493ee0aba01 --- /dev/null +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -0,0 +1,48 @@ +<script> +import workItemQuery from '../graphql/work_item.query.graphql'; +import { widgetTypes } from '../constants'; + +export default { + props: { + id: { + type: String, + required: true, + }, + }, + data() { + return { + workItem: null, + }; + }, + apollo: { + workItem: { + query: workItemQuery, + variables() { + return { + id: this.id, + }; + }, + }, + }, + computed: { + titleWidgetData() { + return this.workItem?.widgets?.nodes?.find((widget) => widget.type === widgetTypes.title); + }, + }, +}; +</script> + +<template> + <section> + <!-- Title widget placeholder --> + <div> + <h2 + v-if="titleWidgetData" + class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5" + data-testid="title" + > + {{ titleWidgetData.contentText }} + </h2> + </div> + </section> +</template> diff --git a/app/assets/javascripts/work_items/router/index.js b/app/assets/javascripts/work_items/router/index.js new file mode 100644 index 00000000000..142fab8cfa6 --- /dev/null +++ b/app/assets/javascripts/work_items/router/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { routes } from './routes'; + +Vue.use(VueRouter); + +export function createRouter(fullPath) { + return new VueRouter({ + routes, + mode: 'history', + base: joinPaths(fullPath, '-', 'work_items'), + }); +} diff --git a/app/assets/javascripts/work_items/router/routes.js b/app/assets/javascripts/work_items/router/routes.js new file mode 100644 index 00000000000..a3cf44ad4ca --- /dev/null +++ b/app/assets/javascripts/work_items/router/routes.js @@ -0,0 +1,8 @@ +export const routes = [ + { + path: '/:id', + name: 'work_item', + component: () => import('../pages/work_item_root.vue'), + props: true, + }, +]; diff --git a/app/assets/stylesheets/emoji_sprites.scss b/app/assets/stylesheets/emoji_sprites.scss index 01d13b30d2b..5a5f39a4b77 100644 --- a/app/assets/stylesheets/emoji_sprites.scss +++ b/app/assets/stylesheets/emoji_sprites.scss @@ -7,111 +7,111 @@ background-position: -20px 0; } -.emoji-1F627 { +.emoji-8ball { background-position: 0 -20px; } -.emoji-8ball { +.emoji-a { background-position: -20px -20px; } -.emoji-a { +.emoji-ab { background-position: -40px 0; } -.emoji-ab { +.emoji-abc { background-position: -40px -20px; } -.emoji-abc { +.emoji-abcd { background-position: 0 -40px; } -.emoji-abcd { +.emoji-accept { background-position: -20px -40px; } -.emoji-accept { +.emoji-aerial_tramway { background-position: -40px -40px; } -.emoji-aerial_tramway { +.emoji-airplane { background-position: -60px 0; } -.emoji-airplane { +.emoji-airplane_arriving { background-position: -60px -20px; } -.emoji-airplane_arriving { +.emoji-airplane_departure { background-position: -60px -40px; } -.emoji-airplane_departure { +.emoji-airplane_small { background-position: 0 -60px; } -.emoji-airplane_small { +.emoji-alarm_clock { background-position: -20px -60px; } -.emoji-alarm_clock { +.emoji-alembic { background-position: -40px -60px; } -.emoji-alembic { +.emoji-alien { background-position: -60px -60px; } -.emoji-alien { +.emoji-ambulance { background-position: -80px 0; } -.emoji-ambulance { +.emoji-amphora { background-position: -80px -20px; } -.emoji-amphora { +.emoji-anchor { background-position: -80px -40px; } -.emoji-anchor { +.emoji-angel { background-position: -80px -60px; } -.emoji-angel { +.emoji-angel_tone1 { background-position: 0 -80px; } -.emoji-angel_tone1 { +.emoji-angel_tone2 { background-position: -20px -80px; } -.emoji-angel_tone2 { +.emoji-angel_tone3 { background-position: -40px -80px; } -.emoji-angel_tone3 { +.emoji-angel_tone4 { background-position: -60px -80px; } -.emoji-angel_tone4 { +.emoji-angel_tone5 { background-position: -80px -80px; } -.emoji-angel_tone5 { +.emoji-anger { background-position: -100px 0; } -.emoji-anger { +.emoji-anger_right { background-position: -100px -20px; } -.emoji-anger_right { +.emoji-angry { background-position: -100px -40px; } -.emoji-angry { +.emoji-anguished { background-position: -100px -60px; } diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 06a8694eb3d..c1c8bfffff7 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -62,7 +62,6 @@ @import 'framework/sortable'; @import 'framework/ci_variable_list'; @import 'framework/feature_highlight'; -@import 'framework/terms'; @import 'framework/read_more'; @import 'framework/flex_grid'; @import 'framework/system_messages'; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index df78543f96d..8f65f349cf9 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -146,13 +146,6 @@ * Blame file */ &.blame { - // - // IMPORTANT PERFORMANCE OPTIMIZATION - // - // When viewinng a blame with many commits a lot of content is rendered on the page. - // The line below ensures that we only render what is visible to the user, thus reducing TBT in the browser. - content-visibility: auto; - table { border: 0; margin: 0; @@ -167,12 +160,6 @@ } td { - // - // IMPORTANT PERFORMANCE OPTIMIZATION - // - // When viewinng a blame with many commits a lot of content is rendered on the page. - // The line below ensures that we only render what is visible to the user, thus reducing TBT in the browser. - content-visibility: auto; border-top: 0; border-bottom: 0; @@ -235,6 +222,25 @@ color: $gray-900; } } + + // + // IMPORTANT PERFORMANCE OPTIMIZATION + // + // When viewinng a blame with many commits a lot of content is rendered on the page. + // content-visibility rules below ensure that we only render what is visible to the user, thus reducing TBT in the browser. + .commit { + content-visibility: auto; + contain-intrinsic-size: 1px 3em; + } + + code .line { + content-visibility: auto; + contain-intrinsic-size: 1px 1.1875rem; + } + + .line-numbers { + content-visibility: auto; + } } &.logs { diff --git a/app/assets/stylesheets/framework/kbd.scss b/app/assets/stylesheets/framework/kbd.scss index 05991bc16fd..7dd0ae47834 100644 --- a/app/assets/stylesheets/framework/kbd.scss +++ b/app/assets/stylesheets/framework/kbd.scss @@ -12,4 +12,20 @@ kbd { border-image: none; border-radius: 3px; box-shadow: 0 -1px 0 var(--gray-200, $gray-200) inset; + + &.flat { + color: $code-color; + background-color: $gray-100; + border-color: var(--gray-10, $gray-10) var(--gray-10, $gray-10) var(--gray-50, $gray-50); + box-shadow: none; + border-radius: $border-radius-default; + font-family: $monospace-font; + font-size: $gl-font-size-small; + line-height: 1; + white-space: pre-wrap; + // Safari + word-wrap: break-word; + overflow-wrap: break-word; + word-break: keep-all; + } } diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index ef294635641..9b04b9a2612 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -82,11 +82,11 @@ .nav-links { li.md-header-toolbar { margin-left: auto; - display: none; + visibility: hidden; padding-bottom: $gl-padding-8; &.active { - display: block; + visibility: visible; @include media-breakpoint-down(xs) { flex: none; @@ -116,7 +116,7 @@ } .md-preview-holder { - min-height: 167px; + min-height: 172px; padding: 10px 0; overflow-x: auto; } diff --git a/app/assets/stylesheets/framework/terms.scss b/app/assets/stylesheets/framework/terms.scss deleted file mode 100644 index b07d6023127..00000000000 --- a/app/assets/stylesheets/framework/terms.scss +++ /dev/null @@ -1,60 +0,0 @@ -.terms { - .with-performance-bar & { - margin-top: 0; - } - - .alert-wrapper { - min-height: $header-height + $gl-padding; - } - - .content { - padding-top: $gl-padding; - } - - .card { - .card-header { - display: flex; - align-items: center; - justify-content: space-between; - line-height: $line-height-base; - - .logo-text { - width: 55px; - height: 24px; - display: flex; - flex-direction: column; - justify-content: center; - } - - .navbar-collapse { - padding-right: 0; - flex-grow: 0; - flex-basis: auto; - - .navbar-nav { - margin: 0; - } - } - - .nav li { - float: none; - } - } - - .panel-content { - padding: $gl-padding; - - *:first-child { - margin-top: 0; - } - - *:last-child { - margin-bottom: 0; - } - } - - .footer-block { - margin: 0; - } - } -} diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss index 8270db9966e..fb4266a2f41 100644 --- a/app/assets/stylesheets/highlight/common.scss +++ b/app/assets/stylesheets/highlight/common.scss @@ -96,9 +96,10 @@ } @mixin line-number-link($color) { + min-width: $gl-spacing-scale-9; + &::before { - @include gl-visibility-hidden; - @include gl-display-inline-block; + @include gl-display-none; @include gl-align-self-center; @include gl-mt-2; @include gl-mr-2; @@ -114,10 +115,10 @@ } &:hover::before { - @include gl-visibility-visible; + @include gl-display-inline-block; } &:focus::before { - @include gl-visibility-visible; + @include gl-display-inline-block; } } diff --git a/app/assets/stylesheets/mailer.scss b/app/assets/stylesheets/mailer.scss index 3220510775c..5f50489555b 100644 --- a/app/assets/stylesheets/mailer.scss +++ b/app/assets/stylesheets/mailer.scss @@ -143,6 +143,10 @@ table.content { line-height: 1.4; padding: 15px 5px; text-align: center; + + ul.list-style-position-inside { + list-style-position: inside; + } } td.mailer-align-left { diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss index a3ec2167b13..d4c59a6ab0c 100644 --- a/app/assets/stylesheets/page_bundles/boards.scss +++ b/app/assets/stylesheets/page_bundles/boards.scss @@ -470,6 +470,10 @@ .labels-select-wrapper.is-embedded .labels-select-wrapper.is-embedded { width: auto; } + + .show.dropdown .dropdown-menu { + @include gl-w-full; + } } .board-header-collapsed-info-icon:hover { diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss index 4beb5edbe7b..9fe0490571e 100644 --- a/app/assets/stylesheets/page_bundles/jira_connect.scss +++ b/app/assets/stylesheets/page_bundles/jira_connect.scss @@ -42,8 +42,6 @@ $header-height: 40px; .jira-connect-app-body { max-width: 768px; - margin-left: auto; - margin-right: auto; } // needed for external_link diff --git a/app/assets/stylesheets/page_bundles/terms.scss b/app/assets/stylesheets/page_bundles/terms.scss new file mode 100644 index 00000000000..8eb66e58aed --- /dev/null +++ b/app/assets/stylesheets/page_bundles/terms.scss @@ -0,0 +1,64 @@ +@import 'mixins_and_variables_and_functions'; + +.terms { + .with-system-header &, + .with-system-header.with-performance-bar &, + .with-performance-bar & { + margin-top: 0; + } + + .terms-fade { + background: linear-gradient(0deg, $white 0%, rgba($white, 0.5) 100%); + } + + .content { + padding-top: $gl-padding; + } + + .gl-card { + .gl-card-header { + display: flex; + align-items: center; + justify-content: space-between; + line-height: $line-height-base; + + .logo-text { + width: 55px; + height: 24px; + display: flex; + flex-direction: column; + justify-content: center; + } + + .navbar-collapse { + padding-right: 0; + flex-grow: 0; + flex-basis: auto; + + .navbar-nav { + margin: 0; + } + } + + .nav li { + float: none; + } + } + + .panel-content { + padding: $gl-padding; + + *:first-child { + margin-top: 0; + } + + *:last-child { + margin-bottom: 0; + } + } + + .footer-block { + margin: 0; + } + } +} diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index de27ca2e5e8..b450bca4f41 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -1,9 +1,13 @@ .clusters-container { - .empty-state .svg-content img { - width: 145px; + .empty-state .svg-content { + @include gl-pb-0; + + img { + width: 100px; + } } - .empty-state--agent { + .agents-empty-state { .text-content { @include gl-max-w-full; @include media-breakpoint-up(lg) { @@ -16,4 +20,23 @@ @include gl-flex-wrap; } } + + .gl-card-body { + @include media-breakpoint-up(sm) { + @include gl-pt-2; + min-height: 372px; + } + } + + @include media-breakpoint-down(xs) { + .nav-controls { + @include gl-w-full; + order: -1; + + .gl-new-dropdown, + .split-content-button { + @include gl-w-full; + } + } + } } diff --git a/app/assets/stylesheets/pages/deploy_keys.scss b/app/assets/stylesheets/pages/deploy_keys.scss index 2fafe052106..997e42a8fd5 100644 --- a/app/assets/stylesheets/pages/deploy_keys.scss +++ b/app/assets/stylesheets/pages/deploy_keys.scss @@ -1,12 +1,3 @@ -.deploy-keys-list { - width: 100%; - overflow: auto; - - table { - border: 1px solid $table-border-color; - } -} - .deploy-keys-title { padding-bottom: 2px; line-height: 2; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index c597d2dd8da..cf5e93e94a2 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -179,6 +179,7 @@ } .block, + .sidebar-contained-width, .issuable-sidebar-header { @include clearfix; padding: $gl-padding 0; @@ -317,6 +318,7 @@ padding: 0; .block, + .sidebar-contained-width, .issuable-sidebar-header { width: $gutter-collapsed-width - 2px; padding: 0; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index cec8d8a29cc..3b86750c6ca 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -269,7 +269,9 @@ $tabs-holder-z-index: 250; } .mr-widget-body { - line-height: 28px; + &:not(.mr-widget-body-line-height-1) { + line-height: 28px; + } @include clearfix; diff --git a/app/assets/stylesheets/startup/_cloaking.scss b/app/assets/stylesheets/startup/_cloaking.scss index 3c25feb0c5c..f60d72a51fb 100644 --- a/app/assets/stylesheets/startup/_cloaking.scss +++ b/app/assets/stylesheets/startup/_cloaking.scss @@ -2,6 +2,8 @@ Prevent flashing of content when using startup.css */ @mixin cloak-startup-scss($display) { + // General selector for cloaking until ready + .cloak-startup, // Breadcrumbs and alerts on the top of the page .content-wrapper > .alert-wrapper, // Content on pages diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index d436c328921..efa4b04ee62 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -5,6 +5,7 @@ body.gl-dark { --gray-50: #303030; --gray-100: #404040; + --gray-600: #bfbfbf; --gray-900: #fafafa; --gray-950: #fff; --green-100: #0d532a; @@ -12,6 +13,7 @@ body.gl-dark { --green-700: #91d4a8; --blue-400: #1f75cb; --orange-400: #ab6100; + --indigo-900-alpha-008: rgba(235, 235, 250, 0.08); --gl-text-color: #fafafa; --border-color: #4f4f4f; --black: #fff; @@ -1693,9 +1695,15 @@ body.gl-dark { --black: #fff; --svg-status-bg: #333; } +.nav-sidebar li a { + color: var(--gray-600); +} .nav-sidebar li.active { box-shadow: none; } +.nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) { + background-color: var(--indigo-900-alpha-008); +} body.gl-dark .navbar-gitlab { background-color: #fafafa; } @@ -1780,7 +1788,7 @@ body.gl-dark .search .search-input-wrap .clear-icon { fill: rgba(250, 250, 250, 0.8); } body.gl-dark .nav-sidebar li.active > a { - color: #f0f0f0; + color: #fafafa; } body.gl-dark .nav-sidebar .fly-out-top-item a, body.gl-dark .nav-sidebar .fly-out-top-item.active a, @@ -1935,7 +1943,7 @@ body.gl-dark { .gl-display-none { display: none; } -@media (min-width: 36rem) { +@media (min-width: 576px) { .gl-sm-display-block { display: block; } diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 40026c95a15..977f994dc78 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -1621,7 +1621,7 @@ svg.s16 { .gl-display-none { display: none; } -@media (min-width: 36rem) { +@media (min-width: 576px) { .gl-sm-display-block { display: block; } diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index 8d7531d6c9c..3daeeb30082 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -776,7 +776,7 @@ svg { .gl-mb-5 { margin-bottom: 1rem; } -@media (min-width: 36rem) { +@media (min-width: 576px) { .gl-sm-mt-0 { margin-top: 0; } diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index b77048174c9..2b5751cab36 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -42,8 +42,24 @@ } .nav-sidebar { - li.active { - box-shadow: none; + li { + a { + color: var(--gray-600); + } + + > a:hover { + background-color: var(--indigo-900-alpha-008); + } + + &.active { + box-shadow: none; + + &:not(.fly-out-top-item) { + > a:not(.has-sub-items) { + background-color: var(--indigo-900-alpha-008); + } + } + } } .sidebar-sub-level-items.fly-out-list { @@ -53,7 +69,7 @@ } body.gl-dark { - @include gitlab-theme($gray-900, $gray-400, $gray-500, $gray-800, $gray-900, $white); + @include gitlab-theme($gray-900, $gray-400, $gray-500, $gray-900, $gray-900, $white); .logo-text svg { fill: var(--gl-text-color); diff --git a/app/assets/stylesheets/themes/theme_blue.scss b/app/assets/stylesheets/themes/theme_blue.scss index 9f9802f77f4..817557f37cd 100644 --- a/app/assets/stylesheets/themes/theme_blue.scss +++ b/app/assets/stylesheets/themes/theme_blue.scss @@ -6,7 +6,7 @@ body { $theme-blue-200, $theme-blue-500, $theme-blue-700, - $theme-blue-800, + $gray-900, $theme-blue-900, $white ); diff --git a/app/assets/stylesheets/themes/theme_dark.scss b/app/assets/stylesheets/themes/theme_dark.scss index e6db6cd2a5e..4c52cdc30df 100644 --- a/app/assets/stylesheets/themes/theme_dark.scss +++ b/app/assets/stylesheets/themes/theme_dark.scss @@ -6,7 +6,7 @@ body { $gray-200, $gray-300, $gray-500, - $gray-700, + $gray-900, $gray-900, $white ); diff --git a/app/assets/stylesheets/themes/theme_green.scss b/app/assets/stylesheets/themes/theme_green.scss index 6dcad6e1301..7e387e97452 100644 --- a/app/assets/stylesheets/themes/theme_green.scss +++ b/app/assets/stylesheets/themes/theme_green.scss @@ -6,7 +6,7 @@ body { $theme-green-200, $theme-green-500, $theme-green-700, - $theme-green-800, + $gray-900, $theme-green-900, $white ); diff --git a/app/assets/stylesheets/themes/theme_indigo.scss b/app/assets/stylesheets/themes/theme_indigo.scss index 9566c9c6004..3bf6cfea650 100644 --- a/app/assets/stylesheets/themes/theme_indigo.scss +++ b/app/assets/stylesheets/themes/theme_indigo.scss @@ -6,7 +6,7 @@ body { $indigo-200, $indigo-500, $indigo-700, - $purple-900, + $gray-900, $indigo-900, $white ); diff --git a/app/assets/stylesheets/themes/theme_light.scss b/app/assets/stylesheets/themes/theme_light.scss index 4c3bc1b2298..f2fdd499781 100644 --- a/app/assets/stylesheets/themes/theme_light.scss +++ b/app/assets/stylesheets/themes/theme_light.scss @@ -6,7 +6,7 @@ body { $gray-500, $gray-700, $gray-500, - $gray-500, + $gray-900, $gray-50, $gray-500 ); diff --git a/app/assets/stylesheets/themes/theme_light_blue.scss b/app/assets/stylesheets/themes/theme_light_blue.scss index 07d1c60a4c6..771a84911b3 100644 --- a/app/assets/stylesheets/themes/theme_light_blue.scss +++ b/app/assets/stylesheets/themes/theme_light_blue.scss @@ -6,7 +6,7 @@ body { $theme-light-blue-200, $theme-light-blue-500, $theme-light-blue-500, - $theme-light-blue-700, + $gray-900, $theme-light-blue-700, $white ); diff --git a/app/assets/stylesheets/themes/theme_light_green.scss b/app/assets/stylesheets/themes/theme_light_green.scss index e122501b93c..8c991a7bfb3 100644 --- a/app/assets/stylesheets/themes/theme_light_green.scss +++ b/app/assets/stylesheets/themes/theme_light_green.scss @@ -6,7 +6,7 @@ body { $theme-green-200, $theme-green-500, $theme-green-500, - $theme-light-green-700, + $gray-900, $theme-light-green-700, $white ); diff --git a/app/assets/stylesheets/themes/theme_light_indigo.scss b/app/assets/stylesheets/themes/theme_light_indigo.scss index 5b607238ed9..6c220e0459a 100644 --- a/app/assets/stylesheets/themes/theme_light_indigo.scss +++ b/app/assets/stylesheets/themes/theme_light_indigo.scss @@ -6,7 +6,7 @@ body { $indigo-200, $indigo-500, $indigo-500, - $indigo-700, + $gray-900, $indigo-700, $white ); diff --git a/app/assets/stylesheets/themes/theme_light_red.scss b/app/assets/stylesheets/themes/theme_light_red.scss index fd3980183f3..e1a715293b4 100644 --- a/app/assets/stylesheets/themes/theme_light_red.scss +++ b/app/assets/stylesheets/themes/theme_light_red.scss @@ -6,7 +6,7 @@ body { $theme-light-red-200, $theme-light-red-500, $theme-light-red-500, - $theme-light-red-700, + $gray-900, $theme-light-red-700, $white ); diff --git a/app/assets/stylesheets/themes/theme_red.scss b/app/assets/stylesheets/themes/theme_red.scss index fa5ecc09f50..19fd150727d 100644 --- a/app/assets/stylesheets/themes/theme_red.scss +++ b/app/assets/stylesheets/themes/theme_red.scss @@ -6,7 +6,7 @@ body { $theme-red-200, $theme-red-500, $theme-red-700, - $theme-red-800, + $gray-900, $theme-red-900, $white ); diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index ec70926b418..7e46f16e1d0 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -281,3 +281,19 @@ $gl-line-height-42: px-to-rem(42px); display: none; } } + +// Will both be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1465 +.gl-text-transparent { + color: transparent; +} + +.gl-focus-ring-border-1-gray-900\! { + @include gl-focus($gl-border-size-1, $gray-900, true); +} + +// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2476 +.gl-md-max-w-50p { + @include gl-media-breakpoint-up(md) { + max-width: 50%; + } +} diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 8039fac02ec..8644d95b96c 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -98,7 +98,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController # Specs are in spec/requests/self_monitoring_project_spec.rb def create_self_monitoring_project - job_id = SelfMonitoringProjectCreateWorker.perform_async # rubocop:disable CodeReuse/Worker + job_id = SelfMonitoringProjectCreateWorker.with_status.perform_async # rubocop:disable CodeReuse/Worker render status: :accepted, json: { job_id: job_id, @@ -137,7 +137,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController # Specs are in spec/requests/self_monitoring_project_spec.rb def delete_self_monitoring_project - job_id = SelfMonitoringProjectDeleteWorker.perform_async # rubocop:disable CodeReuse/Worker + job_id = SelfMonitoringProjectDeleteWorker.with_status.perform_async # rubocop:disable CodeReuse/Worker render status: :accepted, json: { job_id: job_id, diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index ce7d64336c8..83413afb6b7 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -15,6 +15,7 @@ class Admin::ApplicationsController < Admin::ApplicationController end def show + @created = get_created_session end def new @@ -33,6 +34,8 @@ class Admin::ApplicationsController < Admin::ApplicationController if @application.persisted? flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) + set_created_session + redirect_to admin_application_url(@application) else render :new diff --git a/app/controllers/admin/hook_logs_controller.rb b/app/controllers/admin/hook_logs_controller.rb index 444ad17f86d..aa13673095d 100644 --- a/app/controllers/admin/hook_logs_controller.rb +++ b/app/controllers/admin/hook_logs_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Admin::HookLogsController < Admin::ApplicationController - include HooksExecution + include ::Integrations::HooksExecution before_action :hook, only: [:show, :retry] before_action :hook_log, only: [:show, :retry] @@ -9,6 +9,7 @@ class Admin::HookLogsController < Admin::ApplicationController respond_to :html feature_category :integrations + urgency :low, [:retry] def show end diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb index ca24f671b9d..6f5475a4a78 100644 --- a/app/controllers/admin/hooks_controller.rb +++ b/app/controllers/admin/hooks_controller.rb @@ -1,14 +1,15 @@ # frozen_string_literal: true class Admin::HooksController < Admin::ApplicationController - include HooksExecution + include ::Integrations::HooksExecution before_action :hook_logs, only: :edit feature_category :integrations + urgency :low, [:test] def index - @hooks = SystemHook.all + @hooks = SystemHook.all.load @hook = SystemHook.new end diff --git a/app/controllers/admin/integrations_controller.rb b/app/controllers/admin/integrations_controller.rb index a3eb24b9b6f..ad0ee0b2cef 100644 --- a/app/controllers/admin/integrations_controller.rb +++ b/app/controllers/admin/integrations_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Admin::IntegrationsController < Admin::ApplicationController - include IntegrationsActions + include ::Integrations::Actions before_action :not_found, unless: -> { instance_level_integrations? } diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb index 6cc11b40de0..822b7a93c9c 100644 --- a/app/controllers/admin/labels_controller.rb +++ b/app/controllers/admin/labels_controller.rb @@ -3,7 +3,7 @@ class Admin::LabelsController < Admin::ApplicationController before_action :set_label, only: [:show, :edit, :update, :destroy] - feature_category :issue_tracking + feature_category :team_planning def index @labels = Label.templates.page(params[:page]) diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index 8c74352a179..9312651b8bf 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -8,7 +8,6 @@ class Admin::RunnersController < Admin::ApplicationController feature_category :runner def index - @active_runners_count = Ci::Runner.online.count end def show @@ -86,9 +85,12 @@ class Admin::RunnersController < Admin::ApplicationController Project.all end - @projects = @projects.where.not(id: runner.projects.select(:id)) if runner.projects.any? - @projects = @projects.inc_routes - @projects = @projects.page(params[:page]).per(30).without_count + ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') do + @projects = @projects.where.not(id: runner.projects.select(:id)) if runner.projects.any? + @projects = @projects.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') + @projects = @projects.inc_routes + @projects = @projects.page(params[:page]).per(30).without_count + end end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b22167a3952..3af1afab06e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -163,7 +163,8 @@ class ApplicationController < ActionController::Base payload[Labkit::Correlation::CorrelationId::LOG_KEY] = Labkit::Correlation::CorrelationId.current_id payload[:metadata] = @current_context - + payload[:request_urgency] = urgency&.name + payload[:target_duration_s] = urgency&.duration logged_user = auth_user if logged_user.present? payload[:user_id] = logged_user.try(:id) diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 1c07245da08..5cb5690d72d 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -5,7 +5,7 @@ class AutocompleteController < ApplicationController feature_category :users, [:users, :user] feature_category :projects, [:projects] - feature_category :issue_tracking, [:award_emojis] + feature_category :team_planning, [:award_emojis] feature_category :code_review, [:merge_request_target_branches] feature_category :continuous_delivery, [:deploy_keys_with_owners] diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index 7dea6191fa4..e7ae941886d 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -21,7 +21,7 @@ module Boards before_action :validate_id_list, only: [:bulk_move] before_action :can_move_issues?, only: [:bulk_move] - feature_category :boards + feature_category :team_planning def index list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params) diff --git a/app/controllers/boards/lists_controller.rb b/app/controllers/boards/lists_controller.rb index 8ab8337a3ad..696b251301f 100644 --- a/app/controllers/boards/lists_controller.rb +++ b/app/controllers/boards/lists_controller.rb @@ -8,7 +8,7 @@ module Boards before_action :authorize_read_list, only: [:index] skip_before_action :authenticate_user!, only: [:index] - feature_category :boards + feature_category :team_planning def index lists = Boards::Lists::ListService.new(board.resource_parent, current_user).execute(board) diff --git a/app/controllers/concerns/gitlab_recaptcha.rb b/app/controllers/concerns/gitlab_recaptcha.rb new file mode 100644 index 00000000000..15e856463ea --- /dev/null +++ b/app/controllers/concerns/gitlab_recaptcha.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module GitlabRecaptcha + extend ActiveSupport::Concern + include Recaptcha::Verify + include RecaptchaHelper + + def load_recaptcha + recaptcha_enabled? && Gitlab::Recaptcha.load_configurations! + end + + def check_recaptcha + return unless load_recaptcha + return if verify_recaptcha + + flash[:alert] = _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.') + flash.delete :recaptcha_error + + self.resource = resource_class.new + render action: 'new' + end +end diff --git a/app/controllers/concerns/group_tree.rb b/app/controllers/concerns/group_tree.rb index 35c1f358a77..e02a766d2b9 100644 --- a/app/controllers/concerns/group_tree.rb +++ b/app/controllers/concerns/group_tree.rb @@ -38,13 +38,7 @@ module GroupTree # # Pagination needs to be applied before loading the ancestors to # make sure ancestors are not cut off by pagination. - filtered_groups_relation = Group.where(id: filtered_groups.select(:id)) - - if Feature.enabled?(:linear_group_tree_ancestor_scopes, current_user, default_enabled: :yaml) - filtered_groups_relation.self_and_ancestors - else - Gitlab::ObjectHierarchy.new(filtered_groups_relation).base_and_ancestors - end + Group.where(id: filtered_groups.select(:id)).self_and_ancestors end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/controllers/concerns/hooks_execution.rb b/app/controllers/concerns/hooks_execution.rb deleted file mode 100644 index 87d215f50e7..00000000000 --- a/app/controllers/concerns/hooks_execution.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -module HooksExecution - extend ActiveSupport::Concern - - private - - def destroy_hook(hook) - result = WebHooks::DestroyService.new(current_user).execute(hook) - - if result[:status] == :success - flash[:notice] = - if result[:async] - _("%{hook_type} was scheduled for deletion") % { hook_type: hook.model_name.human } - else - _("%{hook_type} was deleted") % { hook_type: hook.model_name.human } - end - else - flash[:alert] = result[:message] - end - end - - def set_hook_execution_notice(result) - http_status = result[:http_status] - message = result[:message] - - if http_status && http_status >= 200 && http_status < 400 - flash[:notice] = "Hook executed successfully: HTTP #{http_status}" - elsif http_status - flash[:alert] = "Hook executed successfully but returned HTTP #{http_status} #{message}" - else - flash[:alert] = "Hook execution failed: #{message}" - end - end - - def create_rate_limit(key, scope) - if rate_limiter.throttled?(key, scope: [scope, current_user]) - rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user) - - render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests - end - end - - def rate_limiter - ::Gitlab::ApplicationRateLimiter - end -end diff --git a/app/controllers/concerns/integrations/actions.rb b/app/controllers/concerns/integrations/actions.rb new file mode 100644 index 00000000000..6490742c0f8 --- /dev/null +++ b/app/controllers/concerns/integrations/actions.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Integrations::Actions + extend ActiveSupport::Concern + + included do + include Integrations::Params + include IntegrationsHelper + + before_action :integration, only: [:edit, :update, :overrides, :test] + + urgency :low, [:test] + end + + def edit + render 'shared/integrations/edit' + end + + def update + saved = integration.update(integration_params[:integration]) + + respond_to do |format| + format.html do + if saved + PropagateIntegrationWorker.perform_async(integration.id) + redirect_to scoped_edit_integration_path(integration), notice: success_message + else + render 'shared/integrations/edit' + end + end + + format.json do + status = saved ? :ok : :unprocessable_entity + + render json: serialize_as_json, status: status + end + end + end + + def test + render json: {}, status: :ok + end + + def reset + integration.destroy! + + flash[:notice] = s_('Integrations|This integration, and inheriting projects were reset.') + + render json: {}, status: :ok + end + + private + + # rubocop:disable Gitlab/ModuleWithInstanceVariables + def integration + @integration ||= find_or_initialize_non_project_specific_integration(params[:id]) + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables + + def success_message + if integration.active? + s_('Integrations|%{integration} settings saved and active.') % { integration: integration.title } + else + s_('Integrations|%{integration} settings saved, but not active.') % { integration: integration.title } + end + end + + def serialize_as_json + integration + .as_json(only: integration.json_fields) + .merge(errors: integration.errors.as_json) + end +end diff --git a/app/controllers/concerns/integrations/hooks_execution.rb b/app/controllers/concerns/integrations/hooks_execution.rb new file mode 100644 index 00000000000..af039057a9c --- /dev/null +++ b/app/controllers/concerns/integrations/hooks_execution.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Integrations::HooksExecution + extend ActiveSupport::Concern + + private + + def destroy_hook(hook) + result = WebHooks::DestroyService.new(current_user).execute(hook) + + if result[:status] == :success + flash[:notice] = + if result[:async] + _("%{hook_type} was scheduled for deletion") % { hook_type: hook.model_name.human } + else + _("%{hook_type} was deleted") % { hook_type: hook.model_name.human } + end + else + flash[:alert] = result[:message] + end + end + + def set_hook_execution_notice(result) + http_status = result[:http_status] + message = result[:message] + + if http_status && http_status >= 200 && http_status < 400 + flash[:notice] = "Hook executed successfully: HTTP #{http_status}" + elsif http_status + flash[:alert] = "Hook executed successfully but returned HTTP #{http_status} #{message}" + else + flash[:alert] = "Hook execution failed: #{message}" + end + end + + def create_rate_limit(key, scope) + if rate_limiter.throttled?(key, scope: [scope, current_user]) + rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user) + + render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests + end + end + + def rate_limiter + ::Gitlab::ApplicationRateLimiter + end +end diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb index 62585ab95af..201fb1dc83f 100644 --- a/app/controllers/concerns/integrations/params.rb +++ b/app/controllers/concerns/integrations/params.rb @@ -9,6 +9,7 @@ module Integrations :add_pusher, :alert_events, :api_key, + :api_token, :api_url, :bamboo_url, :branches_to_be_notified, @@ -74,7 +75,8 @@ module Integrations :url, :user_key, :username, - :webhook + :webhook, + :zentao_product_xid ].freeze def integration_params diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb deleted file mode 100644 index dd066cc1b02..00000000000 --- a/app/controllers/concerns/integrations_actions.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -module IntegrationsActions - extend ActiveSupport::Concern - - included do - include Integrations::Params - include IntegrationsHelper - - before_action :integration, only: [:edit, :update, :overrides, :test] - end - - def edit - render 'shared/integrations/edit' - end - - def update - saved = integration.update(integration_params[:integration]) - - respond_to do |format| - format.html do - if saved - PropagateIntegrationWorker.perform_async(integration.id) - redirect_to scoped_edit_integration_path(integration), notice: success_message - else - render 'shared/integrations/edit' - end - end - - format.json do - status = saved ? :ok : :unprocessable_entity - - render json: serialize_as_json, status: status - end - end - end - - def test - render json: {}, status: :ok - end - - def reset - integration.destroy! - - flash[:notice] = s_('Integrations|This integration, and inheriting projects were reset.') - - render json: {}, status: :ok - end - - private - - # rubocop:disable Gitlab/ModuleWithInstanceVariables - def integration - @integration ||= find_or_initialize_non_project_specific_integration(params[:id]) - end - # rubocop:enable Gitlab/ModuleWithInstanceVariables - - def success_message - if integration.active? - s_('Integrations|%{integration} settings saved and active.') % { integration: integration.title } - else - s_('Integrations|%{integration} settings saved, but not active.') % { integration: integration.title } - end - end - - def serialize_as_json - integration - .as_json(only: integration.json_fields) - .merge(errors: integration.errors.as_json) - end -end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index e1e662a1968..2d7fbb78209 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -149,8 +149,20 @@ module IssuableActions .includes(:noteable) .fresh + if paginated_discussions + paginated_discussions_by_type = paginated_discussions.records.group_by(&:table_name) + + notes = if paginated_discussions_by_type['notes'].present? + notes.with_discussion_ids(paginated_discussions_by_type['notes'].map(&:discussion_id)) + else + notes.none + end + + response.headers['X-Next-Page-Cursor'] = paginated_discussions.cursor_for_next_page if paginated_discussions.has_next_page? + end + if notes_filter != UserPreference::NOTES_FILTERS[:only_comments] - notes = ResourceEvents::MergeIntoNotesService.new(issuable, current_user).execute(notes) + notes = ResourceEvents::MergeIntoNotesService.new(issuable, current_user, paginated_notes: paginated_discussions_by_type).execute(notes) end notes = prepare_notes_for_rendering(notes) @@ -159,9 +171,9 @@ module IssuableActions discussions = Discussion.build_collection(notes, issuable) if issuable.is_a?(MergeRequest) - cache_context = [current_user&.cache_key, project.team.human_max_access(current_user&.id)].join(':') - - render_cached(discussions, with: discussion_serializer, cache_context: -> (_) { cache_context }, context: self) + render_cached(discussions, with: discussion_serializer, cache_context: -> (_) { discussion_cache_context }, context: self) + elsif issuable.is_a?(Issue) + render json: discussion_serializer.represent(discussions, context: self) if stale?(etag: [discussion_cache_context, discussions]) else render json: discussion_serializer.represent(discussions, context: self) end @@ -170,6 +182,17 @@ module IssuableActions private + def paginated_discussions + return if params[:per_page].blank? + return unless issuable.instance_of?(Issue) && Feature.enabled?(:paginated_issue_discussions, project, default_enabled: :yaml) + + strong_memoize(:paginated_discussions) do + issuable + .discussion_root_note_ids(notes_filter: notes_filter) + .keyset_paginate(cursor: params[:cursor], per_page: params[:per_page].to_i) + end + end + def notes_filter strong_memoize(:notes_filter) do notes_filter_param = params[:notes_filter]&.to_i @@ -197,6 +220,10 @@ module IssuableActions current_user&.user_preference&.previous_changes&.any? end + def discussion_cache_context + [current_user&.cache_key, project.team.human_max_access(current_user&.id)].join(':') + end + def discussion_serializer DiscussionSerializer.new(project: project, noteable: issuable, current_user: current_user, note_entity: ProjectNoteEntity) end diff --git a/app/controllers/concerns/oauth_applications.rb b/app/controllers/concerns/oauth_applications.rb index d2c746db12d..794307ebb0c 100644 --- a/app/controllers/concerns/oauth_applications.rb +++ b/app/controllers/concerns/oauth_applications.rb @@ -3,6 +3,8 @@ module OauthApplications extend ActiveSupport::Concern + CREATED_SESSION_KEY = :oauth_applications_created + included do before_action :prepare_scopes, only: [:create, :update] end @@ -15,6 +17,14 @@ module OauthApplications end end + def set_created_session + session[CREATED_SESSION_KEY] = true + end + + def get_created_session + session.delete(CREATED_SESSION_KEY) || false + end + def load_scopes @scopes ||= Doorkeeper.configuration.scopes end diff --git a/app/controllers/concerns/one_trust_csp.rb b/app/controllers/concerns/one_trust_csp.rb index 4e98ec586ca..fbd44f52590 100644 --- a/app/controllers/concerns/one_trust_csp.rb +++ b/app/controllers/concerns/one_trust_csp.rb @@ -5,7 +5,7 @@ module OneTrustCSP included do content_security_policy do |policy| - next if policy.directives.blank? + next unless helpers.one_trust_enabled? || policy.directives.present? default_script_src = policy.directives['script-src'] || policy.directives['default-src'] script_src_values = Array.wrap(default_script_src) | ["'unsafe-eval'", 'https://cdn.cookielaw.org https://*.onetrust.com'] diff --git a/app/controllers/concerns/workhorse_authorization.rb b/app/controllers/concerns/workhorse_authorization.rb index a290ba256b6..f9b85944307 100644 --- a/app/controllers/concerns/workhorse_authorization.rb +++ b/app/controllers/concerns/workhorse_authorization.rb @@ -26,7 +26,7 @@ module WorkhorseAuthorization def file_is_valid?(file) return false unless file.is_a?(::UploadedFile) - file_extension_whitelist.include?(File.extname(file.original_filename).downcase.delete('.')) + file_extension_allowlist.include?(File.extname(file.original_filename).downcase.delete('.')) end def uploader_class @@ -37,7 +37,7 @@ module WorkhorseAuthorization raise NotImplementedError end - def file_extension_whitelist - ImportExportUploader::EXTENSION_WHITELIST + def file_extension_allowlist + ImportExportUploader::EXTENSION_ALLOWLIST end end diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index 0b833e149a4..6725e19df25 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -2,6 +2,10 @@ class ConfirmationsController < Devise::ConfirmationsController include AcceptsPendingInvitations + include GitlabRecaptcha + + prepend_before_action :check_recaptcha, only: :create + before_action :load_recaptcha, only: :new feature_category :users @@ -31,6 +35,12 @@ class ConfirmationsController < Devise::ConfirmationsController end end + def check_recaptcha + return unless resource_params[:email].present? + + super + end + def after_sign_in(resource) after_sign_in_path_for(resource) end diff --git a/app/controllers/dashboard/labels_controller.rb b/app/controllers/dashboard/labels_controller.rb index b661efa12c0..d2f31258ecd 100644 --- a/app/controllers/dashboard/labels_controller.rb +++ b/app/controllers/dashboard/labels_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Dashboard::LabelsController < Dashboard::ApplicationController - feature_category :issue_tracking + feature_category :team_planning def index respond_to do |format| diff --git a/app/controllers/dashboard/milestones_controller.rb b/app/controllers/dashboard/milestones_controller.rb index 1369e82a69b..34d9739d91c 100644 --- a/app/controllers/dashboard/milestones_controller.rb +++ b/app/controllers/dashboard/milestones_controller.rb @@ -4,7 +4,7 @@ class Dashboard::MilestonesController < Dashboard::ApplicationController before_action :projects before_action :groups, only: :index - feature_category :issue_tracking + feature_category :team_planning def index respond_to do |format| diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 21bbb4d0c98..2c5e6817427 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -8,7 +8,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController before_action :authorize_read_group!, only: :index before_action :find_todos, only: [:index, :destroy_all] - feature_category :issue_tracking + feature_category :team_planning def index @sort = params[:sort] diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 227dd0591d4..8d7686a95fb 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -15,7 +15,7 @@ class DashboardController < Dashboard::ApplicationController respond_to :html feature_category :users, [:activity] - feature_category :issue_tracking, [:issues, :issues_calendar] + feature_category :team_planning, [:issues, :issues_calendar] feature_category :code_review, [:merge_requests] def activity diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index 3dc6a16cbc1..14dd2ae5691 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -68,6 +68,20 @@ class Explore::ProjectsController < Explore::ApplicationController end # rubocop: enable CodeReuse/ActiveRecord + def topics + load_project_counts + load_topics + end + + def topic + load_topic + + return render_404 unless @topic + + params[:topic] = @topic.name + @projects = load_projects + end + private def load_project_counts @@ -86,6 +100,14 @@ class Explore::ProjectsController < Explore::ApplicationController prepare_projects_for_rendering(projects) end + def load_topics + @topics = Projects::TopicsFinder.new(params: params.permit(:search)).execute.page(params[:page]).without_count + end + + def load_topic + @topic = Projects::Topic.find_by_name(params[:topic_name]) + end + # rubocop: disable CodeReuse/ActiveRecord def preload_associations(projects) projects.includes(:route, :creator, :group, :project_feature, :topics, namespace: [:route, :owner]) diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 0722a712b5c..fde0f133e53 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -188,6 +188,5 @@ class GraphqlController < ApplicationController def logs RequestStore.store[:graphql_logs].to_a - .map { |log| log.except(:duration_s, :query_string) } end end diff --git a/app/controllers/groups/autocomplete_sources_controller.rb b/app/controllers/groups/autocomplete_sources_controller.rb index 5270a718952..82f8854bd2b 100644 --- a/app/controllers/groups/autocomplete_sources_controller.rb +++ b/app/controllers/groups/autocomplete_sources_controller.rb @@ -2,7 +2,7 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController feature_category :subgroups, [:members] - feature_category :issue_tracking, [:issues, :labels, :milestones, :commands] + feature_category :team_planning, [:issues, :labels, :milestones, :commands] feature_category :code_review, [:merge_requests] def members diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index e8e6a7e5c1a..3152c4d733f 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -12,9 +12,13 @@ class Groups::BoardsController < Groups::ApplicationController push_frontend_feature_flag(:swimlanes_buffered_rendering, group, default_enabled: :yaml) push_frontend_feature_flag(:iteration_cadences, group, default_enabled: :yaml) push_frontend_feature_flag(:labels_widget, group, default_enabled: :yaml) + experiment(:prominent_create_board_btn, subject: current_user) do |e| + e.use { } + e.try { } + end.run end - feature_category :boards + feature_category :team_planning private diff --git a/app/controllers/groups/crm_controller.rb b/app/controllers/groups/crm_controller.rb new file mode 100644 index 00000000000..40661b09be6 --- /dev/null +++ b/app/controllers/groups/crm_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Groups::CrmController < Groups::ApplicationController + feature_category :team_planning + + before_action :authorize_read_crm_contact!, only: [:contacts] + before_action :authorize_read_crm_organization!, only: [:organizations] + + def contacts + respond_to do |format| + format.html + end + end + + def organizations + respond_to do |format| + format.html + end + end + + private + + def authorize_read_crm_contact! + render_404 unless can?(current_user, :read_crm_contact, group) + end + + def authorize_read_crm_organization! + render_404 unless can?(current_user, :read_crm_organization, group) + end +end diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb index e19b8ae35f8..fc930ffebbd 100644 --- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb +++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb @@ -11,8 +11,8 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy before_action :ensure_token_granted!, only: [:blob, :manifest] before_action :ensure_feature_enabled! - before_action :verify_workhorse_api!, only: [:authorize_upload_blob, :upload_blob] - skip_before_action :verify_authenticity_token, only: [:authorize_upload_blob, :upload_blob] + before_action :verify_workhorse_api!, only: [:authorize_upload_blob, :upload_blob, :authorize_upload_manifest, :upload_manifest] + skip_before_action :verify_authenticity_token, only: [:authorize_upload_blob, :upload_blob, :authorize_upload_manifest, :upload_manifest] attr_reader :token @@ -22,20 +22,11 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy result = DependencyProxy::FindOrCreateManifestService.new(group, image, tag, token).execute if result[:status] == :success - response.headers['Docker-Content-Digest'] = result[:manifest].digest - response.headers['Content-Length'] = result[:manifest].size - response.headers['Docker-Distribution-Api-Version'] = DependencyProxy::DISTRIBUTION_API_VERSION - response.headers['Etag'] = "\"#{result[:manifest].digest}\"" - content_type = result[:manifest].content_type - - event_name = tracking_event_name(object_type: :manifest, from_cache: result[:from_cache]) - track_package_event(event_name, :dependency_proxy, namespace: group, user: auth_user) - send_upload( - result[:manifest].file, - proxy: true, - redirect_params: { query: { 'response-content-type' => content_type } }, - send_params: { type: content_type } - ) + if result[:manifest] + send_manifest(result[:manifest], from_cache: result[:from_cache]) + else + send_dependency(manifest_header, DependencyProxy::Registry.manifest_url(image, tag), manifest_file_name) + end else render status: result[:http_status], json: result[:message] end @@ -59,7 +50,7 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy def authorize_upload_blob set_workhorse_internal_api_content_type - render json: DependencyProxy::FileUploader.workhorse_authorize(has_length: false) + render json: DependencyProxy::FileUploader.workhorse_authorize(has_length: false, maximum_size: DependencyProxy::Blob::MAX_FILE_SIZE) end def upload_blob @@ -75,6 +66,37 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy head :ok end + def authorize_upload_manifest + set_workhorse_internal_api_content_type + + render json: DependencyProxy::FileUploader.workhorse_authorize(has_length: false, maximum_size: DependencyProxy::Manifest::MAX_FILE_SIZE) + end + + def upload_manifest + attrs = { + file_name: manifest_file_name, + content_type: request.headers[Gitlab::Workhorse::SEND_DEPENDENCY_CONTENT_TYPE_HEADER], + digest: request.headers[DependencyProxy::Manifest::DIGEST_HEADER], + file: params[:file], + size: params[:file].size + } + + manifest = @group.dependency_proxy_manifests + .active + .find_by_file_name(manifest_file_name) + + if manifest + manifest.update!(attrs) + else + @group.dependency_proxy_manifests.create!(attrs) + end + + event_name = tracking_event_name(object_type: :manifest, from_cache: false) + track_package_event(event_name, :dependency_proxy, namespace: group, user: auth_user) + + head :ok + end + private def blob_via_workhorse @@ -86,14 +108,36 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy send_upload(blob.file) else - send_dependency(token, DependencyProxy::Registry.blob_url(image, params[:sha]), blob_file_name) + send_dependency(token_header, DependencyProxy::Registry.blob_url(image, params[:sha]), blob_file_name) end end + def send_manifest(manifest, from_cache:) + response.headers[DependencyProxy::Manifest::DIGEST_HEADER] = manifest.digest + response.headers['Content-Length'] = manifest.size + response.headers['Docker-Distribution-Api-Version'] = DependencyProxy::DISTRIBUTION_API_VERSION + response.headers['Etag'] = "\"#{manifest.digest}\"" + content_type = manifest.content_type + + event_name = tracking_event_name(object_type: :manifest, from_cache: from_cache) + track_package_event(event_name, :dependency_proxy, namespace: group, user: auth_user) + + send_upload( + manifest.file, + proxy: true, + redirect_params: { query: { 'response-content-type' => content_type } }, + send_params: { type: content_type } + ) + end + def blob_file_name @blob_file_name ||= params[:sha].sub('sha256:', '') + '.gz' end + def manifest_file_name + @manifest_file_name ||= "#{image}:#{tag}.json" + end + def group strong_memoize(:group) do Group.find_by_full_path(params[:group_id], follow_redirects: true) @@ -137,4 +181,12 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy render status: result[:http_status], json: result[:message] end end + + def token_header + { Authorization: ["Bearer #{token}"] } + end + + def manifest_header + token_header.merge(Accept: ::ContainerRegistry::Client::ACCEPTED_TYPES) + end end diff --git a/app/controllers/groups/email_campaigns_controller.rb b/app/controllers/groups/email_campaigns_controller.rb index 70c8a23d918..520ad768939 100644 --- a/app/controllers/groups/email_campaigns_controller.rb +++ b/app/controllers/groups/email_campaigns_controller.rb @@ -40,7 +40,7 @@ class Groups::EmailCampaignsController < Groups::ApplicationController project_pipelines_url(group.projects.first) when :trial, :trial_short 'https://about.gitlab.com/free-trial/' - when :team, :team_short + when :team, :team_short, :invite_team group_group_members_url(group) when :admin_verify project_settings_ci_cd_path(group.projects.first, ci_runner_templates: true, anchor: 'js-runners-settings') @@ -59,6 +59,11 @@ class Groups::EmailCampaignsController < Groups::ApplicationController @track = params[:track]&.to_sym @series = params[:series]&.to_i + # There is only one email that will be sent for invite team track so series + # should only have the value 0. Return early if track is invite team and + # condition for series value is met + return if @track == Namespaces::InviteTeamEmailService::TRACK && @series == 0 + track_valid = @track.in?(Namespaces::InProductMarketingEmailsService::TRACKS.keys) return render_404 unless track_valid diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb index 86dde454cbc..7bcc8182bd6 100644 --- a/app/controllers/groups/labels_controller.rb +++ b/app/controllers/groups/labels_controller.rb @@ -9,7 +9,7 @@ class Groups::LabelsController < Groups::ApplicationController respond_to :html - feature_category :issue_tracking + feature_category :team_planning def index respond_to do |format| diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 63eff750d1b..75877cdef9c 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -6,7 +6,7 @@ class Groups::MilestonesController < Groups::ApplicationController before_action :milestone, only: [:edit, :show, :update, :issues, :merge_requests, :participants, :labels, :destroy] before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update, :destroy] - feature_category :issue_tracking + feature_category :team_planning def index respond_to do |format| diff --git a/app/controllers/groups/packages_controller.rb b/app/controllers/groups/packages_controller.rb index d02a8262948..47f1816cc4c 100644 --- a/app/controllers/groups/packages_controller.rb +++ b/app/controllers/groups/packages_controller.rb @@ -6,10 +6,6 @@ module Groups feature_category :package_registry - before_action do - push_frontend_feature_flag(:package_list_apollo, default_enabled: :yaml) - end - private def verify_packages_enabled! diff --git a/app/controllers/groups/settings/applications_controller.rb b/app/controllers/groups/settings/applications_controller.rb index f05a96d7810..6388277e4dc 100644 --- a/app/controllers/groups/settings/applications_controller.rb +++ b/app/controllers/groups/settings/applications_controller.rb @@ -16,6 +16,7 @@ module Groups end def show + @created = get_created_session end def edit @@ -27,6 +28,8 @@ module Groups if @application.persisted? flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) + set_created_session + redirect_to group_settings_application_url(@group, @application) else set_index_vars diff --git a/app/controllers/groups/settings/integrations_controller.rb b/app/controllers/groups/settings/integrations_controller.rb index a7a1de03224..0a63c3d304b 100644 --- a/app/controllers/groups/settings/integrations_controller.rb +++ b/app/controllers/groups/settings/integrations_controller.rb @@ -3,7 +3,7 @@ module Groups module Settings class IntegrationsController < Groups::ApplicationController - include IntegrationsActions + include ::Integrations::Actions before_action :authorize_admin_group! diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index a419171039e..6ae711a6e14 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -54,7 +54,7 @@ class GroupsController < Groups::ApplicationController :destroy, :details, :transfer, :activity ] - feature_category :issue_tracking, [:issues, :issues_calendar, :preview_markdown] + feature_category :team_planning, [:issues, :issues_calendar, :preview_markdown] feature_category :code_review, [:merge_requests, :unfoldered_environment_names] feature_category :projects, [:projects] feature_category :importers, [:export, :download_export] @@ -92,6 +92,7 @@ class GroupsController < Groups::ApplicationController if @group.import_state&.in_progress? redirect_to group_import_path(@group) else + publish_invite_members_for_task_experiment render_show_html end end @@ -379,6 +380,13 @@ class GroupsController < Groups::ApplicationController def captcha_required? captcha_enabled? && !params[:parent_id] end + + def publish_invite_members_for_task_experiment + return unless params[:open_modal] == 'invite_members_for_task' + return unless current_user&.can?(:admin_group_member, @group) + + experiment(:invite_members_for_task, namespace: @group).publish_to_client + end end GroupsController.prepend_mod_with('GroupsController') diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index 0ad7478584f..e0020c22145 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -122,3 +122,5 @@ class HelpController < ApplicationController end end end + +::HelpController.prepend_mod diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index d32755dbd94..cfd86429df0 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -116,7 +116,9 @@ class Import::BitbucketController < Import::BaseController redirect_to oauth_client.auth_code.authorize_url(redirect_uri: users_import_bitbucket_callback_url) end - def bitbucket_unauthorized + def bitbucket_unauthorized(exception) + log_exception(exception) + go_to_bitbucket_for_permissions end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 4242f918ea0..d4b1306cc5e 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -52,7 +52,7 @@ class InvitesController < ApplicationController end def current_user_matches_invite? - current_user.verified_emails.include?(@member.invite_email) + current_user.verified_email?(@member.invite_email) end def member? diff --git a/app/controllers/jira_connect/app_descriptor_controller.rb b/app/controllers/jira_connect/app_descriptor_controller.rb index e96242c7052..16bd73f5ab6 100644 --- a/app/controllers/jira_connect/app_descriptor_controller.rb +++ b/app/controllers/jira_connect/app_descriptor_controller.rb @@ -32,7 +32,7 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController apiVersion: 1, apiMigrations: { 'context-qsh': true, - 'signed-install': signed_install_active?, + 'signed-install': true, gdpr: true } } diff --git a/app/controllers/jira_connect/application_controller.rb b/app/controllers/jira_connect/application_controller.rb index ecb23c326fe..352e78d6255 100644 --- a/app/controllers/jira_connect/application_controller.rb +++ b/app/controllers/jira_connect/application_controller.rb @@ -74,8 +74,4 @@ class JiraConnect::ApplicationController < ApplicationController params[:jwt] || request.headers['Authorization']&.split(' ', 2)&.last end end - - def signed_install_active? - Feature.enabled?(:jira_connect_asymmetric_jwt) - end end diff --git a/app/controllers/jira_connect/events_controller.rb b/app/controllers/jira_connect/events_controller.rb index 76ac15f7631..1ea0a92662b 100644 --- a/app/controllers/jira_connect/events_controller.rb +++ b/app/controllers/jira_connect/events_controller.rb @@ -4,14 +4,9 @@ class JiraConnect::EventsController < JiraConnect::ApplicationController # See https://developer.atlassian.com/cloud/jira/software/app-descriptor/#lifecycle skip_before_action :verify_atlassian_jwt! - before_action :verify_asymmetric_atlassian_jwt!, if: :signed_install_active? - - before_action :verify_atlassian_jwt!, only: :uninstalled, unless: :signed_install_active? - before_action :verify_qsh_claim!, only: :uninstalled, unless: :signed_install_active? + before_action :verify_asymmetric_atlassian_jwt! def installed - return head :ok if !signed_install_active? && atlassian_jwt_valid? - return head :ok if current_jira_installation installation = JiraConnectInstallation.new(event_params) diff --git a/app/controllers/jira_connect/subscriptions_controller.rb b/app/controllers/jira_connect/subscriptions_controller.rb index 903ad395e44..fcd95c7942c 100644 --- a/app/controllers/jira_connect/subscriptions_controller.rb +++ b/app/controllers/jira_connect/subscriptions_controller.rb @@ -7,8 +7,8 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController next if p.directives.blank? # rubocop: disable Lint/PercentStringArray - script_src_values = Array.wrap(p.directives['script-src']) | %w('self' https://connect-cdn.atl-paas.net https://unpkg.com/jquery@3.3.1/) - style_src_values = Array.wrap(p.directives['style-src']) | %w('self' 'unsafe-inline' https://unpkg.com/@atlaskit/) + script_src_values = Array.wrap(p.directives['script-src']) | %w('self' https://connect-cdn.atl-paas.net) + style_src_values = Array.wrap(p.directives['style-src']) | %w('self' 'unsafe-inline') # rubocop: enable Lint/PercentStringArray p.frame_ancestors :self, 'https://*.atlassian.net' diff --git a/app/controllers/jwks_controller.rb b/app/controllers/jwks_controller.rb index e7b839f5590..3b0e6ca2eb1 100644 --- a/app/controllers/jwks_controller.rb +++ b/app/controllers/jwks_controller.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true -class JwksController < ActionController::Base # rubocop:disable Rails/ApplicationController +class JwksController < Doorkeeper::OpenidConnect::DiscoveryController def index - render json: { keys: keys } + render json: { keys: payload } + end + + def keys + index end private - def keys + def payload [ # We keep openid_connect_signing_key so that we can seamlessly # replace it with ci_jwt_signing_key and remove it on the next release. diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index 81f188256ba..81084ffe38b 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -24,12 +24,18 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController set_index_vars end + def show + @created = get_created_session + end + def create @application = Applications::CreateService.new(current_user, application_params).execute(request) if @application.persisted? flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) + set_created_session + redirect_to oauth_application_url(@application) else set_index_vars diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index c764f2d0459..ead5d7c9026 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true class PasswordsController < Devise::PasswordsController + include GitlabRecaptcha + skip_before_action :require_no_authentication, only: [:edit, :update] + prepend_before_action :check_recaptcha, only: :create + before_action :load_recaptcha, only: :new before_action :resource_from_email, only: [:create] before_action :check_password_authentication_available, only: [:create] before_action :throttle_reset, only: [:create] @@ -59,6 +63,12 @@ class PasswordsController < Devise::PasswordsController alert: _("Password authentication is unavailable.") end + def check_recaptcha + return unless resource_params[:email].present? + + super + end + def throttle_reset return unless resource && resource.recently_sent_password_reset? diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index e0b5d6be155..e607346b40e 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -66,7 +66,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController render 'create' else - @error = _('Invalid pin code') + @error = { message: _('Invalid pin code.') } @qr_code = build_qr_code if Feature.enabled?(:webauthn) @@ -147,7 +147,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController end def current_password_required? - !current_user.password_automatically_set? + !current_user.password_automatically_set? && current_user.allow_password_authentication_for_web? end def build_qr_code diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 69257081cc9..6330a6aa107 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -63,7 +63,7 @@ class ProfilesController < Profiles::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def audit_log - @events = AuditEvent.where(entity_type: "User", entity_id: current_user.id) + @events = AuthenticationEvent.where(user: current_user) .order("created_at DESC") .page(params[:page]) diff --git a/app/controllers/projects/alert_management_controller.rb b/app/controllers/projects/alert_management_controller.rb index 34f9f361e43..ebe867d915d 100644 --- a/app/controllers/projects/alert_management_controller.rb +++ b/app/controllers/projects/alert_management_controller.rb @@ -3,10 +3,6 @@ class Projects::AlertManagementController < Projects::ApplicationController before_action :authorize_read_alert_management_alert! - before_action(only: [:index]) do - push_frontend_feature_flag(:managed_alerts_deprecation, @project, default_enabled: :yaml) - end - feature_category :incident_management def index diff --git a/app/controllers/projects/alerting/notifications_controller.rb b/app/controllers/projects/alerting/notifications_controller.rb index 95b403faf55..ae8498ce65f 100644 --- a/app/controllers/projects/alerting/notifications_controller.rb +++ b/app/controllers/projects/alerting/notifications_controller.rb @@ -18,7 +18,11 @@ module Projects token = extract_alert_manager_token(request) result = notify_service.execute(token, integration) - head result.http_status + if result.success? + render json: AlertManagement::AlertSerializer.new.represent(result.payload[:alerts]), code: result.http_status + else + head result.http_status + end end private diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index 7c419cac1cc..0d5f64c739c 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -3,7 +3,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController before_action :authorize_read_milestone!, only: :milestones - feature_category :issue_tracking, [:issues, :labels, :milestones, :commands] + feature_category :team_planning, [:issues, :labels, :milestones, :commands] feature_category :code_review, [:merge_requests] feature_category :users, [:members] feature_category :snippets, [:snippets] diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb index 0cd59c136e5..8023e51b552 100644 --- a/app/controllers/projects/badges_controller.rb +++ b/app/controllers/projects/badges_controller.rb @@ -6,7 +6,8 @@ class Projects::BadgesController < Projects::ApplicationController before_action :no_cache_headers, only: [:pipeline, :coverage] before_action :authorize_read_build!, only: [:pipeline, :coverage] - feature_category :continuous_integration + feature_category :continuous_integration, [:index, :pipeline] + feature_category :code_testing, [:coverage] def pipeline pipeline_status = Gitlab::Ci::Badge::Pipeline::Status diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb index 1df7b9ed165..0f87690bba5 100644 --- a/app/controllers/projects/blame_controller.rb +++ b/app/controllers/projects/blame_controller.rb @@ -10,6 +10,7 @@ class Projects::BlameController < Projects::ApplicationController before_action :authorize_download_code! feature_category :source_code_management + urgency :low, [:show] def show @blob = @repository.blob_at(@commit.id, @path) diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 17fd28ee06a..cd50c8cf5b1 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -40,6 +40,7 @@ class Projects::BlobController < Projects::ApplicationController track_redis_hll_event :create, :update, name: 'g_edit_by_sfe' feature_category :source_code_management + urgency :low, [:create, :show, :edit, :update, :diff] before_action do push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml) diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index 834e4baa7dd..7354c2c71ac 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -12,9 +12,13 @@ class Projects::BoardsController < Projects::ApplicationController push_frontend_feature_flag(:board_multi_select, project, default_enabled: :yaml) push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml) push_frontend_feature_flag(:labels_widget, project, default_enabled: :yaml) + experiment(:prominent_create_board_btn, subject: current_user) do |e| + e.use { } + e.try { } + end.run end - feature_category :boards + feature_category :team_planning private diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index b75effc52d1..63ac5f97420 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -14,6 +14,7 @@ class Projects::BranchesController < Projects::ApplicationController before_action :limit_diverging_commit_counts!, only: [:diverging_commit_counts] feature_category :source_code_management + urgency :low, [:index, :diverging_commit_counts, :create, :destroy] def index respond_to do |format| @@ -105,8 +106,7 @@ class Projects::BranchesController < Projects::ApplicationController # rubocop: enable CodeReuse/ActiveRecord def destroy - @branch_name = Addressable::URI.unescape(params[:id]) - result = ::Branches::DeleteService.new(project, current_user).execute(@branch_name) + result = ::Branches::DeleteService.new(project, current_user).execute(params[:id]) respond_to do |format| format.html do diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb index 22cd247644d..600516f95a2 100644 --- a/app/controllers/projects/ci/pipeline_editor_controller.rb +++ b/app/controllers/projects/ci/pipeline_editor_controller.rb @@ -2,8 +2,8 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController before_action :check_can_collaborate! + before_action :setup_walkthrough_experiment, only: :show before_action do - push_frontend_feature_flag(:pipeline_editor_mini_graph, @project, default_enabled: :yaml) push_frontend_feature_flag(:schema_linting, @project, default_enabled: :yaml) end @@ -17,4 +17,11 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController def check_can_collaborate! render_404 unless can_collaborate_with_project?(@project) end + + def setup_walkthrough_experiment + experiment(:pipeline_editor_walkthrough, namespace: @project.namespace, sticky_to: current_user) do |e| + e.candidate {} + e.record! + end + end end diff --git a/app/controllers/projects/cluster_agents_controller.rb b/app/controllers/projects/cluster_agents_controller.rb index e7fbe93131d..404d3907128 100644 --- a/app/controllers/projects/cluster_agents_controller.rb +++ b/app/controllers/projects/cluster_agents_controller.rb @@ -3,6 +3,10 @@ class Projects::ClusterAgentsController < Projects::ApplicationController before_action :authorize_can_read_cluster_agent! + before_action do + push_frontend_feature_flag(:cluster_vulnerabilities, project, default_enabled: :yaml) + end + feature_category :kubernetes_management def show @@ -17,3 +21,5 @@ class Projects::ClusterAgentsController < Projects::ApplicationController access_denied! end end + +Projects::ClusterAgentsController.prepend_mod_with('Projects::ClusterAgentsController') diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 6748be06ded..62935e133c5 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -24,6 +24,7 @@ class Projects::CommitController < Projects::ApplicationController COMMIT_DIFFS_PER_PAGE = 20 feature_category :source_code_management + urgency :low, [:pipelines, :merge_requests, :show] def show apply_diff_view_cookie! diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index 9ca917841e9..1ca35903703 100644 --- a/app/controllers/projects/commits_controller.rb +++ b/app/controllers/projects/commits_controller.rb @@ -6,6 +6,8 @@ class Projects::CommitsController < Projects::ApplicationController include ExtractsPath include RendersCommits + COMMITS_DEFAULT_LIMIT = 40 + prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } around_action :allow_gitaly_ref_name_caching before_action :require_non_empty_project @@ -15,6 +17,7 @@ class Projects::CommitsController < Projects::ApplicationController before_action :set_commits, except: :commits_root feature_category :source_code_management + urgency :low, [:signatures, :show] def commits_root redirect_to project_commits_path(@project, @project.default_branch) @@ -63,7 +66,9 @@ class Projects::CommitsController < Projects::ApplicationController def set_commits render_404 unless @path.empty? || request.format == :atom || @repository.blob_at(@commit.id, @path) || @repository.tree(@commit.id, @path).entries.present? - @limit = (params[:limit] || 40).to_i + + limit = params[:limit].to_i + @limit = limit > 0 ? limit : COMMITS_DEFAULT_LIMIT # limit can only ever be a positive number @offset = (params[:offset] || 0).to_i search = params[:search] author = params[:author] diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 99f62c18593..07f7c1cf7de 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -21,6 +21,7 @@ class Projects::CompareController < Projects::ApplicationController before_action :validate_refs! feature_category :source_code_management + urgency :low, [:show, :create, :signatures] # Diffs may be pretty chunky, the less is better in this endpoint. # Pagination design guides: https://design.gitlab.com/components/pagination/#behavior diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index db5ba51ee01..dc6a9a73d9e 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -6,8 +6,10 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController include CycleAnalyticsParams include GracefulTimeoutHandling include RedisTracking + extend ::Gitlab::Utils::Override before_action :authorize_read_cycle_analytics! + before_action :load_value_stream, only: :show track_redis_hll_event :show, name: 'p_analytics_valuestream' @@ -19,6 +21,7 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController def show @cycle_analytics = Analytics::CycleAnalytics::ProjectLevel.new(project: @project, options: options(cycle_analytics_project_params)) + @request_params ||= ::Gitlab::Analytics::CycleAnalytics::RequestParams.new(all_cycle_analytics_params) respond_to do |format| format.html do @@ -34,6 +37,15 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController private + override :all_cycle_analytics_params + def all_cycle_analytics_params + super.merge({ project: @project, value_stream: @value_stream }) + end + + def load_value_stream + @value_stream = Analytics::CycleAnalytics::ProjectValueStream.build_default_value_stream(@project) + end + def cycle_analytics_json { summary: @cycle_analytics.summary, diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index 708b7a6c7ba..9f7d47b95f3 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -9,7 +9,7 @@ class Projects::DiscussionsController < Projects::ApplicationController before_action :discussion, only: [:resolve, :unresolve] before_action :authorize_resolve_discussion!, only: [:resolve, :unresolve] - feature_category :issue_tracking + feature_category :team_planning def resolve Discussions::ResolveService.new(project, current_user, one_or_more_discussions: discussion).execute diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 23dabd885c8..84ebdcd9364 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -70,11 +70,9 @@ class Projects::EnvironmentsController < Projects::ApplicationController end # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord def show - @deployments = environment.deployments.order(id: :desc).page(params[:page]) + @deployments = environment.deployments.ordered.page(params[:page]) end - # rubocop: enable CodeReuse/ActiveRecord def new @environment = project.environments.new diff --git a/app/controllers/projects/find_file_controller.rb b/app/controllers/projects/find_file_controller.rb index 89e72d98a33..c6bc115e737 100644 --- a/app/controllers/projects/find_file_controller.rb +++ b/app/controllers/projects/find_file_controller.rb @@ -11,6 +11,7 @@ class Projects::FindFileController < Projects::ApplicationController before_action :authorize_download_code! feature_category :source_code_management + urgency :low, [:show, :list] def show return render_404 unless @repository.commit(@ref) diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 7135c0d959e..5154f145b46 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -15,6 +15,7 @@ class Projects::ForksController < Projects::ApplicationController before_action :authorize_fork_namespace!, only: [:create] feature_category :source_code_management + urgency :low, [:index] before_action do push_frontend_feature_flag(:fork_project_form, @project, default_enabled: :yaml) diff --git a/app/controllers/projects/google_cloud_controller.rb b/app/controllers/projects/google_cloud_controller.rb index d185457aeb3..7257ed1ef6f 100644 --- a/app/controllers/projects/google_cloud_controller.rb +++ b/app/controllers/projects/google_cloud_controller.rb @@ -1,16 +1,34 @@ # frozen_string_literal: true class Projects::GoogleCloudController < Projects::ApplicationController - before_action :authorize_can_manage_google_cloud_deployments! + feature_category :google_cloud - feature_category :release_orchestration + before_action :admin_project_google_cloud? + before_action :google_oauth2_enabled? + before_action :feature_flag_enabled? def index + @js_data = { + serviceAccounts: GoogleCloud::ServiceAccountsService.new(project).find_for_project, + createServiceAccountUrl: '#mocked-url-create-service', + emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg') + }.to_json end private - def authorize_can_manage_google_cloud_deployments! - access_denied! unless can?(current_user, :manage_project_google_cloud, project) + def admin_project_google_cloud? + access_denied! unless can?(current_user, :admin_project_google_cloud, project) + end + + def google_oauth2_enabled? + config = Gitlab::Auth::OAuth::Provider.config_for('google_oauth2') + if config.app_id.blank? || config.app_secret.blank? + access_denied! 'This GitLab instance not configured for Google Oauth2.' + end + end + + def feature_flag_enabled? + access_denied! unless Feature.enabled?(:incubation_5mp_google_cloud) end end diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb index 7a7961c28bb..d3a05736a47 100644 --- a/app/controllers/projects/graphs_controller.rb +++ b/app/controllers/projects/graphs_controller.rb @@ -11,7 +11,10 @@ class Projects::GraphsController < Projects::ApplicationController track_redis_hll_event :charts, name: 'p_analytics_repo' - feature_category :source_code_management + feature_category :source_code_management, [:show, :commits, :languages, :charts] + urgency :low, [:show] + + feature_category :continuous_integration, [:ci] def show respond_to do |format| diff --git a/app/controllers/projects/hook_logs_controller.rb b/app/controllers/projects/hook_logs_controller.rb index 99ebe3335c0..0ca3d71f728 100644 --- a/app/controllers/projects/hook_logs_controller.rb +++ b/app/controllers/projects/hook_logs_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Projects::HookLogsController < Projects::ApplicationController - include HooksExecution + include ::Integrations::HooksExecution before_action :authorize_admin_project! @@ -13,6 +13,7 @@ class Projects::HookLogsController < Projects::ApplicationController layout 'project_settings' feature_category :integrations + urgency :low, [:retry] def show end diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index b87bfc58f8b..c79e5a8cc85 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Projects::HooksController < Projects::ApplicationController - include HooksExecution + include ::Integrations::HooksExecution # Authorize before_action :authorize_admin_project! @@ -13,9 +13,10 @@ class Projects::HooksController < Projects::ApplicationController layout "project_settings" feature_category :integrations + urgency :low, [:test] def index - @hooks = @project.hooks + @hooks = @project.hooks.load @hook = ProjectHook.new end diff --git a/app/controllers/projects/issue_links_controller.rb b/app/controllers/projects/issue_links_controller.rb index 35f3e00fae7..e8c3110574f 100644 --- a/app/controllers/projects/issue_links_controller.rb +++ b/app/controllers/projects/issue_links_controller.rb @@ -7,7 +7,7 @@ module Projects before_action :authorize_admin_issue_link!, only: [:create, :destroy] before_action :authorize_issue_link_association!, only: :destroy - feature_category :issue_tracking + feature_category :team_planning private diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index fd508d5f127..853e9c7ccdd 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -48,12 +48,11 @@ class Projects::IssuesController < Projects::ApplicationController end before_action only: :show do - real_time_enabled = Gitlab::ActionCable::Config.in_app? || Feature.enabled?(:real_time_issue_sidebar, @project) - - push_to_gon_attributes(:features, :real_time_issue_sidebar, real_time_enabled) + push_frontend_feature_flag(:real_time_issue_sidebar, @project, default_enabled: :yaml) push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml) push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml) push_frontend_feature_flag(:labels_widget, @project, default_enabled: :yaml) + push_frontend_feature_flag(:paginated_issue_discussions, @project, default_enabled: :yaml) experiment(:invite_members_in_comment, namespace: @project.root_ancestor) do |experiment_instance| experiment_instance.exclude! unless helpers.can_admin_project_member?(@project) @@ -71,7 +70,7 @@ class Projects::IssuesController < Projects::ApplicationController alias_method :designs, :show - feature_category :issue_tracking, [ + feature_category :team_planning, [ :index, :calendar, :show, :new, :create, :edit, :update, :destroy, :move, :reorder, :designs, :toggle_subscription, :discussions, :bulk_update, :realtime_changes, diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 994be5c2b5c..81b8da9cba3 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -133,9 +133,9 @@ class Projects::JobsController < Projects::ApplicationController end def raw - if trace_artifact_file + if @build.trace.archived_trace_exist? workhorse_set_content_type! - send_upload(trace_artifact_file, + send_upload(@build.job_artifacts_trace.file, send_params: raw_send_params, redirect_params: raw_redirect_params) else @@ -219,10 +219,6 @@ class Projects::JobsController < Projects::ApplicationController params.permit(job_variables_attributes: %i[key secret_value]) end - def trace_artifact_file - @trace_artifact_file ||= @build.job_artifacts_trace&.file - end - def find_job_as_build @build = project.builds.find(params[:id]) end diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 6bf3885fb7a..814081194d6 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -14,7 +14,7 @@ class Projects::LabelsController < Projects::ApplicationController respond_to :js, :html - feature_category :issue_tracking + feature_category :team_planning def index respond_to do |format| diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 46df514abcb..6c5a8aa0610 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -41,6 +41,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:diffs_virtual_scrolling, project, default_enabled: :yaml) push_frontend_feature_flag(:restructured_mr_widget, project, default_enabled: :yaml) push_frontend_feature_flag(:mr_changes_fluid_layout, project, default_enabled: :yaml) + push_frontend_feature_flag(:mr_attention_requests, project, default_enabled: :yaml) + push_frontend_feature_flag(:labels_widget, project, default_enabled: :yaml) # Usage data feature flags push_frontend_feature_flag(:users_expanding_widgets_usage_data, @project, default_enabled: :yaml) @@ -140,8 +142,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo cache_context = [ params[:serializer], current_user&.cache_key, - @merge_request.assignees.map(&:cache_key), - @merge_request.reviewers.map(&:cache_key) + @merge_request.merge_request_assignees.map(&:cache_key), + @merge_request.merge_request_reviewers.map(&:cache_key) ] render_cached(@merge_request, diff --git a/app/controllers/projects/metrics_dashboard_controller.rb b/app/controllers/projects/metrics_dashboard_controller.rb index 89c99a5fd5a..3f10749602e 100644 --- a/app/controllers/projects/metrics_dashboard_controller.rb +++ b/app/controllers/projects/metrics_dashboard_controller.rb @@ -12,7 +12,6 @@ module Projects before_action do push_frontend_feature_flag(:prometheus_computed_alerts) push_frontend_feature_flag(:disable_metric_dashboard_refresh_rate) - push_frontend_feature_flag(:managed_alerts_deprecation, @project, default_enabled: :yaml) end feature_category :metrics diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 630e7ccd43f..5dc9718d7a4 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -18,7 +18,7 @@ class Projects::MilestonesController < Projects::ApplicationController respond_to :html - feature_category :issue_tracking + feature_category :team_planning def index @sort = params[:sort] || 'due_date_asc' diff --git a/app/controllers/projects/network_controller.rb b/app/controllers/projects/network_controller.rb index f3a7bc7913e..84ac9fb01fd 100644 --- a/app/controllers/projects/network_controller.rb +++ b/app/controllers/projects/network_controller.rb @@ -11,6 +11,7 @@ class Projects::NetworkController < Projects::ApplicationController before_action :assign_commit feature_category :source_code_management + urgency :low, [:show] def show @url = project_network_path(@project, @ref, @options.merge(format: :json)) diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index e7e6aed8ec8..e8057308386 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -11,7 +11,7 @@ class Projects::NotesController < Projects::ApplicationController before_action :authorize_create_note!, only: [:create] before_action :authorize_resolve_note!, only: [:resolve, :unresolve] - feature_category :issue_tracking + feature_category :team_planning def delete_attachment note.remove_attachment! @@ -55,6 +55,14 @@ class Projects::NotesController < Projects::ApplicationController end end + def outdated_line_change + diff_lines = Rails.cache.fetch(['note', note.id, 'oudated_line_change'], expires_in: 7.days) do + ::MergeRequests::OutdatedDiscussionDiffLinesService.new(project: @project, note: note).execute.to_json + end + + render json: diff_lines + end + private def render_json_with_notes_serializer diff --git a/app/controllers/projects/packages/packages_controller.rb b/app/controllers/projects/packages/packages_controller.rb index dd7c2ad3cbd..5de71466c10 100644 --- a/app/controllers/projects/packages/packages_controller.rb +++ b/app/controllers/projects/packages/packages_controller.rb @@ -7,10 +7,6 @@ module Projects feature_category :package_registry - before_action do - push_frontend_feature_flag(:package_list_apollo, default_enabled: :yaml) - end - def show @package = project.packages.find(params[:id]) end diff --git a/app/controllers/projects/prometheus/alerts_controller.rb b/app/controllers/projects/prometheus/alerts_controller.rb index 19c908026cf..312919831d4 100644 --- a/app/controllers/projects/prometheus/alerts_controller.rb +++ b/app/controllers/projects/prometheus/alerts_controller.rb @@ -30,7 +30,11 @@ module Projects token = extract_alert_manager_token(request) result = notify_service.execute(token) - head result.http_status + if result.success? + render json: AlertManagement::AlertSerializer.new.represent(result.payload[:alerts]), code: result.http_status + else + head result.http_status + end end def create diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb index 4d23c853334..73eb6bb2bf2 100644 --- a/app/controllers/projects/refs_controller.rb +++ b/app/controllers/projects/refs_controller.rb @@ -12,6 +12,7 @@ class Projects::RefsController < Projects::ApplicationController before_action :authorize_download_code! feature_category :source_code_management + urgency :low, [:switch, :logs_tree] def switch respond_to do |format| diff --git a/app/controllers/projects/releases/evidences_controller.rb b/app/controllers/projects/releases/evidences_controller.rb index 1e2dbf8047c..41e2ce81eb8 100644 --- a/app/controllers/projects/releases/evidences_controller.rb +++ b/app/controllers/projects/releases/evidences_controller.rb @@ -20,7 +20,6 @@ module Projects private def authorize_read_release_evidence! - access_denied! unless Feature.enabled?(:release_evidence, project, default_enabled: true) access_denied! unless can?(current_user, :read_release_evidence, evidence) end diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index be2abc5cddf..7fba6cc5bf4 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -5,9 +5,6 @@ class Projects::ReleasesController < Projects::ApplicationController before_action :require_non_empty_project, except: [:index] before_action :release, only: %i[edit show update downloads] before_action :authorize_read_release! - # We have to check `download_code` permission because detail URL path - # contains git-tag name. - before_action :authorize_download_code!, except: [:index] before_action :authorize_update_release!, only: %i[edit update] before_action :authorize_create_release!, only: :new before_action only: :index do diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index c42d382c4bb..9464826701d 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -18,6 +18,7 @@ class Projects::ServicesController < Projects::ApplicationController layout "project_settings" feature_category :integrations + urgency :low, [:test] def edit end diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 3033dac8246..4fe37352995 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -104,8 +104,7 @@ module Projects CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false) # rubocop:enable CodeReuse/Worker - pipelines_link_start = '<a href="%{url}">'.html_safe % { url: project_pipelines_path(@project) } - flash[:toast] = _("A new Auto DevOps pipeline has been created, go to %{pipelines_link_start}Pipelines page%{pipelines_link_end} for details") % { pipelines_link_start: pipelines_link_start, pipelines_link_end: "</a>".html_safe } + flash[:toast] = _("A new Auto DevOps pipeline has been created, go to the Pipelines page for details") end def define_variables diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index 728231dbdbd..cc419bab687 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -12,6 +12,7 @@ module Projects feature_category :source_code_management, [:show, :cleanup] feature_category :continuous_delivery, [:create_deploy_token] + urgency :low, [:show] def show render_show diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index 02d36c3353d..de0faaca9c0 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -10,23 +10,29 @@ class Projects::TagsController < Projects::ApplicationController before_action :authorize_download_code! before_action :authorize_admin_tag!, only: [:new, :create, :destroy] - feature_category :source_code_management, [:index, :show, :new, :destroy] - feature_category :release_evidence, [:create] + feature_category :source_code_management + urgency :low, [:new, :show, :index] # rubocop: disable CodeReuse/ActiveRecord def index - params[:sort] = params[:sort].presence || sort_value_recently_updated + begin + params[:sort] = params[:sort].presence || sort_value_recently_updated - @sort = params[:sort] + @sort = params[:sort] - @tags, @tags_loading_error = TagsFinder.new(@repository, params).execute + @tags = TagsFinder.new(@repository, params).execute - @tags = Kaminari.paginate_array(@tags).page(params[:page]) - tag_names = @tags.map(&:name) - @tags_pipelines = @project.ci_pipelines.latest_successful_for_refs(tag_names) + @tags = Kaminari.paginate_array(@tags).page(params[:page]) + tag_names = @tags.map(&:name) + @tags_pipelines = @project.ci_pipelines.latest_successful_for_refs(tag_names) - @releases = project.releases.where(tag: tag_names) - @tag_pipeline_statuses = Ci::CommitStatusesFinder.new(@project, @repository, current_user, @tags).execute + @releases = project.releases.where(tag: tag_names) + @tag_pipeline_statuses = Ci::CommitStatusesFinder.new(@project, @repository, current_user, @tags).execute + + rescue Gitlab::Git::CommandError => e + @tags = [] + @tags_loading_error = e + end respond_to do |format| status = @tags_loading_error ? :service_unavailable : :ok diff --git a/app/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb index 4bad6dc1b3d..6d06b05c1e9 100644 --- a/app/controllers/projects/templates_controller.rb +++ b/app/controllers/projects/templates_controller.rb @@ -6,6 +6,7 @@ class Projects::TemplatesController < Projects::ApplicationController before_action :get_template_class feature_category :source_code_management + urgency :low, [:names] def index templates = @template_type.template_subsets(project) diff --git a/app/controllers/projects/todos_controller.rb b/app/controllers/projects/todos_controller.rb index 6ba89ab34f8..dafdeb4c9ef 100644 --- a/app/controllers/projects/todos_controller.rb +++ b/app/controllers/projects/todos_controller.rb @@ -6,7 +6,7 @@ class Projects::TodosController < Projects::ApplicationController before_action :authenticate_user!, only: [:create] - feature_category :issue_tracking + feature_category :team_planning private diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index a76d45411dd..f8f2c1f0836 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -17,11 +17,12 @@ class Projects::TreeController < Projects::ApplicationController before_action do push_frontend_feature_flag(:lazy_load_commits, @project, default_enabled: :yaml) - push_frontend_feature_flag(:paginated_tree_graphql_query, @project, default_enabled: :yaml) push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml) + push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml) end feature_category :source_code_management + urgency :low, [:show] def show return render_404 unless @commit diff --git a/app/controllers/projects/usage_quotas_controller.rb b/app/controllers/projects/usage_quotas_controller.rb index 103e1cc596a..b319e427eaa 100644 --- a/app/controllers/projects/usage_quotas_controller.rb +++ b/app/controllers/projects/usage_quotas_controller.rb @@ -2,7 +2,6 @@ class Projects::UsageQuotasController < Projects::ApplicationController before_action :authorize_admin_project! - before_action :verify_usage_quotas_enabled! layout "project_settings" @@ -20,10 +19,4 @@ class Projects::UsageQuotasController < Projects::ApplicationController wiki_help_page_path: help_page_path('administration/wikis/index.md', anchor: 'reduce-wiki-repository-size') } end - - private - - def verify_usage_quotas_enabled! - render_404 unless Feature.enabled?(:project_storage_ui, project&.group, default_enabled: :yaml) - end end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 9ee8847004e..02dfaf4c193 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -3,11 +3,9 @@ class Projects::WikisController < Projects::ApplicationController include WikiActions - alias_method :container, :project + urgency :low - before_action do - push_frontend_feature_flag(:content_editor_block_tables, @project, default_enabled: :yaml) - end + alias_method :container, :project feature_category :wiki end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 0760f97d7c1..5b17b75a963 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -37,7 +37,6 @@ class ProjectsController < Projects::ApplicationController push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml) push_frontend_feature_flag(:refactor_text_viewer, @project, default_enabled: :yaml) push_frontend_feature_flag(:increase_page_size_exponentially, @project, default_enabled: :yaml) - push_frontend_feature_flag(:paginated_tree_graphql_query, @project, default_enabled: :yaml) push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml) end @@ -49,9 +48,10 @@ class ProjectsController < Projects::ApplicationController ] feature_category :source_code_management, [:remove_fork, :housekeeping, :refs] - feature_category :issue_tracking, [:preview_markdown, :new_issuable_address] + feature_category :team_planning, [:preview_markdown, :new_issuable_address] feature_category :importers, [:export, :remove_export, :generate_new_export, :download_export] feature_category :code_review, [:unfoldered_environment_names] + urgency :low, [:refs] def index redirect_to(current_user ? root_path : explore_root_path) @@ -293,7 +293,11 @@ class ProjectsController < Projects::ApplicationController end if find_tags && @repository.tag_count.nonzero? - tags, _ = TagsFinder.new(@repository, params).execute + tags = begin + TagsFinder.new(@repository, params).execute + rescue Gitlab::Git::CommandError + [] + end options['Tags'] = tags.take(100).map(&:name) end @@ -336,11 +340,6 @@ class ProjectsController < Projects::ApplicationController if can?(current_user, :download_code, @project) return render 'projects/no_repo' unless @project.repository_exists? - if @project.can_current_user_push_to_default_branch? - property = @project.empty_repo? ? 'empty' : 'nonempty' - experiment(:empty_repo_upload, project: @project).track(:view_project_show, property: property) - end - render 'projects/empty' if @project.empty_repo? else if can?(current_user, :read_wiki, @project) @@ -452,6 +451,7 @@ class ProjectsController < Projects::ApplicationController :suggestion_commit_message, :packages_enabled, :service_desk_enabled, + :merge_commit_template, project_setting_attributes: project_setting_attributes ] + [project_feature_attributes: project_feature_attributes] end diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb index 416bbf43464..39d3125a4a3 100644 --- a/app/controllers/registrations/welcome_controller.rb +++ b/app/controllers/registrations/welcome_controller.rb @@ -16,7 +16,9 @@ module Registrations result = ::Users::SignupService.new(current_user, update_params).execute if result[:status] == :success - return redirect_to experiment(:combined_registration, user: current_user).redirect_path(trial_params) if show_signup_onboarding? + return redirect_to issues_dashboard_path(assignee_username: current_user.username) if show_tasks_to_be_done? + + return redirect_to update_success_path if show_signup_onboarding? members = current_user.members @@ -64,12 +66,23 @@ module Registrations members.last.source.activity_path end + # overridden in EE def show_signup_onboarding? false end + def show_tasks_to_be_done? + return unless experiment(:invite_members_for_task).enabled? + + MemberTask.for_members(current_user.members).exists? + end + + # overridden in EE def trial_params - nil + end + + # overridden in EE + def update_success_path end end end diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb index a7719516cb6..30cafb6747e 100644 --- a/app/controllers/repositories/lfs_api_controller.rb +++ b/app/controllers/repositories/lfs_api_controller.rb @@ -13,6 +13,7 @@ module Repositories # added here as a part of the refactor, will be removed # https://gitlab.com/gitlab-org/gitlab/-/issues/328692 delegate :deploy_token, :user, to: :authentication_result, allow_nil: true + urgency :medium, [:batch] def batch unless objects.present? diff --git a/app/controllers/repositories/lfs_storage_controller.rb b/app/controllers/repositories/lfs_storage_controller.rb index 6ec63a0f939..252b604dcb0 100644 --- a/app/controllers/repositories/lfs_storage_controller.rb +++ b/app/controllers/repositories/lfs_storage_controller.rb @@ -11,6 +11,8 @@ module Repositories # added here as a part of the refactor, will be removed # https://gitlab.com/gitlab-org/gitlab/-/issues/328692 delegate :deploy_token, :user, to: :authentication_result, allow_nil: true + urgency :medium, [:download, :upload_authorize] + urgency :low, [:upload_finalize] def download lfs_object = LfsObject.find_by_oid(oid) diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 0a18559fc81..0e285dae089 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -46,6 +46,7 @@ class SearchController < ApplicationController @search_results = @search_service.search_results @search_objects = @search_service.search_objects @search_highlight = @search_service.search_highlight + @aggregations = @search_service.search_aggregations increment_search_counters end @@ -151,7 +152,7 @@ class SearchController < ApplicationController def block_anonymous_global_searches return if params[:project_id].present? || params[:group_id].present? return if current_user - return unless ::Feature.enabled?(:block_anonymous_global_searches) + return unless ::Feature.enabled?(:block_anonymous_global_searches, type: :ops) store_location_for(:user, request.fullpath) diff --git a/app/experiments/change_continuous_onboarding_link_urls_experiment.rb b/app/experiments/change_continuous_onboarding_link_urls_experiment.rb new file mode 100644 index 00000000000..680cb8eadd8 --- /dev/null +++ b/app/experiments/change_continuous_onboarding_link_urls_experiment.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ChangeContinuousOnboardingLinkUrlsExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass + attr_writer :namespace + + def track(action, **event_args) + super(action, **event_args.merge(namespace: @namespace)) + end +end diff --git a/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt b/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt index bbe8271386f..7592c7c6ab7 100644 --- a/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt +++ b/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt @@ -23,13 +23,14 @@ git push -uf origin <%= @project.default_branch_or_main %> ## Integrate with your tools -- [ ] [Set up project integrations](<%= redirect("https://docs.gitlab.com/ee/user/project/integrations/") %>) +- [ ] [Set up project integrations](<%= redirect(project_settings_integrations_url(@project)) %>) ## Collaborate with your team - [ ] [Invite team members and collaborators](<%= redirect("https://docs.gitlab.com/ee/user/project/members/") %>) - [ ] [Create a new merge request](<%= redirect("https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html") %>) - [ ] [Automatically close issues from merge requests](<%= redirect("https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically") %>) +- [ ] [Enable merge request approvals](<%= redirect("https://docs.gitlab.com/ee/user/project/merge_requests/approvals/") %>) - [ ] [Automatically merge when pipeline succeeds](<%= redirect("https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html") %>) ## Test and Deploy @@ -40,6 +41,7 @@ Use the built-in continuous integration in GitLab. - [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](<%= redirect("https://docs.gitlab.com/ee/user/application_security/sast/") %>) - [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](<%= redirect("https://docs.gitlab.com/ee/topics/autodevops/requirements.html") %>) - [ ] [Use pull-based deployments for improved Kubernetes management](<%= redirect("https://docs.gitlab.com/ee/user/clusters/agent/") %>) +- [ ] [Set up protected environments](<%= redirect("https://docs.gitlab.com/ee/ci/environments/protected_environments.html") %>) *** diff --git a/app/finders/autocomplete/routes_finder.rb b/app/finders/autocomplete/routes_finder.rb index b3f2693b273..858a4b69376 100644 --- a/app/finders/autocomplete/routes_finder.rb +++ b/app/finders/autocomplete/routes_finder.rb @@ -30,7 +30,7 @@ module Autocomplete class NamespacesOnly < self def routables - return Namespace.all if current_user.admin? + return Namespace.without_project_namespaces if current_user.admin? current_user.namespaces end diff --git a/app/finders/award_emojis_finder.rb b/app/finders/award_emojis_finder.rb index 9ff64637128..709d3f3e593 100644 --- a/app/finders/award_emojis_finder.rb +++ b/app/finders/award_emojis_finder.rb @@ -40,7 +40,7 @@ class AwardEmojisFinder def validate_name_param return unless params[:name] - raise ArgumentError, 'Invalid name param' unless params[:name].to_s.in?(Gitlab::Emoji.emojis_names) + raise ArgumentError, 'Invalid name param' unless TanukiEmoji.find_by_alpha_code(params[:name].to_s) end def validate_awarded_by_param diff --git a/app/finders/ci/pipelines_finder.rb b/app/finders/ci/pipelines_finder.rb index 39355853d88..712d5f8c6fb 100644 --- a/app/finders/ci/pipelines_finder.rb +++ b/app/finders/ci/pipelines_finder.rb @@ -5,6 +5,13 @@ module Ci attr_reader :project, :pipelines, :params, :current_user ALLOWED_INDEXED_COLUMNS = %w[id status ref updated_at user_id].freeze + ALLOWED_SCOPES = { + RUNNING: 'running', + PENDING: 'pending', + FINISHED: 'finished', + BRANCHES: 'branches', + TAGS: 'tags' + }.freeze def initialize(project, current_user, params = {}) @project = project @@ -65,15 +72,15 @@ module Ci def by_scope(items) case params[:scope] - when 'running' + when ALLOWED_SCOPES[:RUNNING] items.running - when 'pending' + when ALLOWED_SCOPES[:PENDING] items.pending - when 'finished' + when ALLOWED_SCOPES[:FINISHED] items.finished - when 'branches' + when ALLOWED_SCOPES[:BRANCHES] from_ids(ids_for_ref(branches)) - when 'tags' + when ALLOWED_SCOPES[:TAGS] from_ids(ids_for_ref(tags)) else items diff --git a/app/finders/ci/pipelines_for_merge_request_finder.rb b/app/finders/ci/pipelines_for_merge_request_finder.rb index 9476c30f525..b623a94541b 100644 --- a/app/finders/ci/pipelines_for_merge_request_finder.rb +++ b/app/finders/ci/pipelines_for_merge_request_finder.rb @@ -31,65 +31,27 @@ module Ci # Fetch all pipelines without permission check. def all - ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336891') do - strong_memoize(:all_pipelines) do - next Ci::Pipeline.none unless source_project - - pipelines = - if merge_request.persisted? - all_pipelines_for_merge_request - else - triggered_for_branch.for_sha(commit_shas) - end - - sort(pipelines) - end - end - end - - private + strong_memoize(:all_pipelines) do + next Ci::Pipeline.none unless source_project - # rubocop: disable CodeReuse/ActiveRecord - def pipelines_using_cte - sha_relation = merge_request.all_commits.select(:sha).distinct - - cte = Gitlab::SQL::CTE.new(:shas, sha_relation) - - pipelines_for_merge_requests = triggered_by_merge_request - pipelines_for_branch = filter_by_sha(triggered_for_branch, cte) - - Ci::Pipeline.with(cte.to_arel) # rubocop: disable CodeReuse/ActiveRecord - .from_union([pipelines_for_merge_requests, pipelines_for_branch]) - end - # rubocop: enable CodeReuse/ActiveRecord + pipelines = + if merge_request.persisted? + all_pipelines_for_merge_request + else + triggered_for_branch.for_sha(commit_shas) + end - def filter_by_sha(pipelines, cte) - hex = Arel::Nodes::SqlLiteral.new("'hex'") - string_sha = Arel::Nodes::NamedFunction.new('encode', [cte.table[:sha], hex]) - join_condition = string_sha.eq(Ci::Pipeline.arel_table[:sha]) - - filter_by(pipelines, cte, join_condition) + sort(pipelines) + end end - def filter_by(pipelines, cte, join_condition) - shas_table = - Ci::Pipeline.arel_table - .join(cte.table, Arel::Nodes::InnerJoin) - .on(join_condition) - .join_sources - - pipelines.joins(shas_table) # rubocop: disable CodeReuse/ActiveRecord - end + private def all_pipelines_for_merge_request - if Feature.enabled?(:decomposed_ci_query_in_pipelines_for_merge_request_finder, target_project, default_enabled: :yaml) - pipelines_for_merge_request = triggered_by_merge_request - pipelines_for_branch = triggered_for_branch.for_sha(recent_diff_head_shas(COMMITS_LIMIT)) + pipelines_for_merge_request = triggered_by_merge_request + pipelines_for_branch = triggered_for_branch.for_sha(recent_diff_head_shas(COMMITS_LIMIT)) - Ci::Pipeline.from_union([pipelines_for_merge_request, pipelines_for_branch]) - else - pipelines_using_cte - end + Ci::Pipeline.from_union([pipelines_for_merge_request, pipelines_for_branch]) end # NOTE: this method returns only parent merge request pipelines. diff --git a/app/finders/clusters/agent_authorizations_finder.rb b/app/finders/clusters/agent_authorizations_finder.rb new file mode 100644 index 00000000000..373cf7fe8b9 --- /dev/null +++ b/app/finders/clusters/agent_authorizations_finder.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Clusters + class AgentAuthorizationsFinder + def initialize(project) + @project = project + end + + def execute + # closest, most-specific authorization for a given agent wins + (project_authorizations + implicit_authorizations + group_authorizations) + .uniq(&:agent_id) + end + + private + + attr_reader :project + + def implicit_authorizations + project.cluster_agents.map do |agent| + Clusters::Agents::ImplicitAuthorization.new(agent: agent) + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def project_authorizations + ancestor_ids = project.group ? project.ancestors.select(:id) : project.namespace_id + + Clusters::Agents::ProjectAuthorization + .where(project_id: project.id) + .joins(agent: :project) + .preload(agent: :project) + .where(cluster_agents: { projects: { namespace_id: ancestor_ids } }) + .with_available_ci_access_fields(project) + .to_a + end + + def group_authorizations + return [] unless project.group + + authorizations = Clusters::Agents::GroupAuthorization.arel_table + + ordered_ancestors_cte = Gitlab::SQL::CTE.new( + :ordered_ancestors, + project.group.self_and_ancestors(hierarchy_order: :asc).reselect(:id) + ) + + cte_join_sources = authorizations.join(ordered_ancestors_cte.table).on( + authorizations[:group_id].eq(ordered_ancestors_cte.table[:id]) + ).join_sources + + Clusters::Agents::GroupAuthorization + .with(ordered_ancestors_cte.to_arel) + .joins(cte_join_sources) + .joins(agent: :project) + .where('projects.namespace_id IN (SELECT id FROM ordered_ancestors)') + .with_available_ci_access_fields(project) + .order(Arel.sql('agent_id, array_position(ARRAY(SELECT id FROM ordered_ancestors)::bigint[], agent_group_authorizations.group_id)')) + .select('DISTINCT ON (agent_id) agent_group_authorizations.*') + .preload(agent: :project) + .to_a + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/finders/environments/environments_by_deployments_finder.rb b/app/finders/environments/environments_by_deployments_finder.rb index e0ecc98b1c0..a41cfcb37e4 100644 --- a/app/finders/environments/environments_by_deployments_finder.rb +++ b/app/finders/environments/environments_by_deployments_finder.rb @@ -23,12 +23,18 @@ module Environments deployments.none end - environment_ids = deployments - .group(:environment_id) - .select(:environment_id) + environments = + if Feature.enabled?(:environments_by_deployments_finder_exists_optimization, default_enabled: :yaml) + project.environments.available + .where('EXISTS (?)', deployments.where('environment_id = environments.id')) + else + environment_ids = deployments + .group(:environment_id) + .select(:environment_id) - environments = project.environments.available - .where(id: environment_ids) + project.environments.available + .where(id: environment_ids) + end if params[:find_latest] find_one(environments.order_by_last_deployed_at_desc) diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb index 4c619f3d7ea..4ed447a90ce 100644 --- a/app/finders/events_finder.rb +++ b/app/finders/events_finder.rb @@ -44,7 +44,6 @@ class EventsFinder events = by_created_at_after(events) events = sort(events) - events = events.with_associations if params[:with_associations] paginated_filtered_by_user_visibility(events) end @@ -113,10 +112,12 @@ class EventsFinder end def paginated_filtered_by_user_visibility(events) + events_count = events.count + events = events.with_associations if params[:with_associations] limited_events = events.page(page).per(per_page) visible_events = limited_events.select { |event| event.visible_to_user?(current_user) } - Kaminari.paginate_array(visible_events, total_count: events.count) + Kaminari.paginate_array(visible_events, total_count: events_count) end def per_page diff --git a/app/finders/issuables/label_filter.rb b/app/finders/issuables/label_filter.rb index f4712fa6879..9a6ca107b19 100644 --- a/app/finders/issuables/label_filter.rb +++ b/app/finders/issuables/label_filter.rb @@ -82,6 +82,8 @@ module Issuables if root_namespace label_ids = find_label_ids(label_names).flatten(1) + return issuables if label_ids.empty? + issuables.where(label_link_query(target_model, label_ids: label_ids).arel.exists.not) else issuables.where(label_link_query(target_model, label_names: label_names).arel.exists.not) @@ -133,11 +135,7 @@ module Issuables # rubocop: disable CodeReuse/ActiveRecord def label_link_query(target_model, label_ids: nil, label_names: nil) - relation = LabelLink - .where(target_type: target_model.name) - .where(LabelLink.arel_table['target_id'].eq(target_model.arel_table['id'])) - - relation = relation.where(label_id: label_ids) if label_ids + relation = LabelLink.by_target_for_exists_query(target_model.name, target_model.arel_table['id'], label_ids) relation = relation.joins(:label).where(labels: { name: label_names }) if label_names relation diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index 0faafa6df9c..e68a0c8fca9 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -70,16 +70,11 @@ class MembersFinder end def project_invited_groups - invited_groups_and_ancestors = if ::Feature.enabled?(:linear_members_finder_ancestor_scopes, current_user, default_enabled: :yaml) - project.invited_groups - .self_and_ancestors - else - Gitlab::ObjectHierarchy - .new(project.invited_groups) - .base_and_ancestors - end - - invited_groups_ids_including_ancestors = invited_groups_and_ancestors.public_or_visible_to_user(current_user).select(:id) + invited_groups_ids_including_ancestors = project + .invited_groups + .self_and_ancestors + .public_or_visible_to_user(current_user) + .select(:id) GroupMember.with_source_id(invited_groups_ids_including_ancestors).non_minimal_access end diff --git a/app/finders/packages/group_packages_finder.rb b/app/finders/packages/group_packages_finder.rb index 3ac5f00d518..2a62dd5c0e5 100644 --- a/app/finders/packages/group_packages_finder.rb +++ b/app/finders/packages/group_packages_finder.rb @@ -22,7 +22,7 @@ module Packages def packages_for_group_projects(installable_only: false) packages = ::Packages::Package - .including_build_info + .preload_pipelines .including_project_route .including_tags .for_projects(group_projects_visible_to_current_user.select(:id)) diff --git a/app/finders/packages/package_finder.rb b/app/finders/packages/package_finder.rb index ee96896e350..e482a0503f0 100644 --- a/app/finders/packages/package_finder.rb +++ b/app/finders/packages/package_finder.rb @@ -9,7 +9,7 @@ module Packages def execute @project .packages - .including_build_info + .preload_pipelines .including_project_route .including_tags .displayable diff --git a/app/finders/packages/packages_finder.rb b/app/finders/packages/packages_finder.rb index 552468ecfd1..3bc348c8dc8 100644 --- a/app/finders/packages/packages_finder.rb +++ b/app/finders/packages/packages_finder.rb @@ -14,9 +14,10 @@ module Packages def execute packages = project.packages - .including_build_info + .preload_pipelines .including_project_route .including_tags + packages = filter_with_version(packages) packages = filter_by_package_type(packages) packages = filter_by_package_name(packages) diff --git a/app/finders/security/security_jobs_finder.rb b/app/finders/security/security_jobs_finder.rb index 008d4e29b13..5754492cfa7 100644 --- a/app/finders/security/security_jobs_finder.rb +++ b/app/finders/security/security_jobs_finder.rb @@ -13,7 +13,7 @@ module Security class SecurityJobsFinder < JobsFinder def self.allowed_job_types - [:sast, :dast, :dependency_scanning, :container_scanning, :secret_detection, :coverage_fuzzing, :api_fuzzing, :cluster_image_scanning] + [:sast, :sast_iac, :dast, :dependency_scanning, :container_scanning, :secret_detection, :coverage_fuzzing, :api_fuzzing, :cluster_image_scanning] end end end diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index 81643826782..b1e12721712 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -42,12 +42,11 @@ class SnippetsFinder < UnionFinder include FinderMethods include Gitlab::Utils::StrongMemoize - attr_accessor :current_user, :params - delegate :explore, :only_personal, :only_project, :scope, :sort, to: :params + attr_reader :current_user, :params def initialize(current_user = nil, params = {}) @current_user = current_user - @params = OpenStruct.new(params) + @params = params if project && author raise( @@ -77,9 +76,9 @@ class SnippetsFinder < UnionFinder private def init_collection - if explore + if explore? snippets_for_explore - elsif only_personal + elsif only_personal? personal_snippets elsif project snippets_for_a_single_project @@ -110,7 +109,7 @@ class SnippetsFinder < UnionFinder # over the resulting SQL query. def snippets_for_personal_and_multiple_projects queries = [] - queries << personal_snippets unless only_project + queries << personal_snippets unless only_project? if Ability.allowed?(current_user, :read_cross_project) queries << snippets_of_visible_projects @@ -171,7 +170,7 @@ class SnippetsFinder < UnionFinder end def visibility_from_scope - case scope.to_s + case params[:scope].to_s when 'are_private' Snippet::PRIVATE when 'are_internal' @@ -206,7 +205,19 @@ class SnippetsFinder < UnionFinder end def sort_param - sort.presence || 'id_desc' + params[:sort].presence || 'id_desc' + end + + def explore? + params[:explore].present? + end + + def only_personal? + params[:only_personal].present? + end + + def only_project? + params[:only_project].present? end def prepared_union(queries) diff --git a/app/finders/tags_finder.rb b/app/finders/tags_finder.rb index 0ccbbdc1b87..6bc5419e704 100644 --- a/app/finders/tags_finder.rb +++ b/app/finders/tags_finder.rb @@ -5,11 +5,36 @@ class TagsFinder < GitRefsFinder super(repository, params) end - def execute - tags = repository.tags_sorted_by(sort) + def execute(gitaly_pagination: false) + tags = if gitaly_pagination + repository.tags_sorted_by(sort, pagination_params) + else + repository.tags_sorted_by(sort) + end - [by_search(tags), nil] - rescue Gitlab::Git::CommandError => e - [[], e] + by_search(tags) + + rescue ArgumentError => e + raise Gitlab::Git::InvalidPageToken, "Invalid page token: #{page_token}" if e.message.include?('page token') + + raise + end + + def total + repository.tag_count + end + + private + + def per_page + params[:per_page].presence + end + + def page_token + "#{Gitlab::Git::TAG_REF_PREFIX}#{@params[:page_token]}" if params[:page_token] + end + + def pagination_params + { limit: per_page, page_token: page_token } end end diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 38ba1611c48..e15a185a743 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -10,11 +10,17 @@ class GitlabSchema < GraphQL::Schema DEFAULT_MAX_DEPTH = 15 AUTHENTICATED_MAX_DEPTH = 20 + # Tracers (order is important) + use Gitlab::Graphql::Tracers::ApplicationContextTracer + use Gitlab::Graphql::Tracers::MetricsTracer + use Gitlab::Graphql::Tracers::LoggerTracer + use Gitlab::Graphql::GenericTracing # Old tracer which will be removed eventually + use Gitlab::Graphql::Tracers::TimerTracer + use GraphQL::Subscriptions::ActionCableSubscriptions use GraphQL::Pagination::Connections use BatchLoader::GraphQL use Gitlab::Graphql::Pagination::Connections - use Gitlab::Graphql::GenericTracing use Gitlab::Graphql::Timeout, max_seconds: Gitlab.config.gitlab.graphql_timeout query_analyzer Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer.new diff --git a/app/graphql/mutations/customer_relations/contacts/create.rb b/app/graphql/mutations/customer_relations/contacts/create.rb index 77b4864468b..3495f30f227 100644 --- a/app/graphql/mutations/customer_relations/contacts/create.rb +++ b/app/graphql/mutations/customer_relations/contacts/create.rb @@ -42,13 +42,11 @@ module Mutations required: false, description: 'Description of or notes for the contact.' - authorize :admin_contact + authorize :admin_crm_contact def resolve(args) group = authorized_find!(id: args[:group_id]) - raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, group, default_enabled: :yaml) - set_organization!(args) result = ::CustomerRelations::Contacts::CreateService.new(group: group, current_user: current_user, params: args).execute { contact: result.payload, errors: result.errors } diff --git a/app/graphql/mutations/customer_relations/contacts/update.rb b/app/graphql/mutations/customer_relations/contacts/update.rb index e9e7c9b6abd..e2f671058f0 100644 --- a/app/graphql/mutations/customer_relations/contacts/update.rb +++ b/app/graphql/mutations/customer_relations/contacts/update.rb @@ -8,7 +8,7 @@ module Mutations graphql_name 'CustomerRelationsContactUpdate' - authorize :admin_contact + authorize :admin_crm_contact field :contact, Types::CustomerRelations::ContactType, @@ -48,8 +48,6 @@ module Mutations raise_resource_not_available_error! unless contact group = contact.group - raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, group, default_enabled: :yaml) - authorize!(group) result = ::CustomerRelations::Contacts::UpdateService.new(group: group, current_user: current_user, params: args).execute(contact) diff --git a/app/graphql/mutations/customer_relations/organizations/create.rb b/app/graphql/mutations/customer_relations/organizations/create.rb index bb02e1f7346..17e0e9ad459 100644 --- a/app/graphql/mutations/customer_relations/organizations/create.rb +++ b/app/graphql/mutations/customer_relations/organizations/create.rb @@ -33,13 +33,11 @@ module Mutations required: false, description: 'Description of or notes for the organization.' - authorize :admin_organization + authorize :admin_crm_organization def resolve(args) group = authorized_find!(id: args[:group_id]) - raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, group, default_enabled: :yaml) - result = ::CustomerRelations::Organizations::CreateService.new(group: group, current_user: current_user, params: args).execute { organization: result.payload, errors: result.errors } end diff --git a/app/graphql/mutations/customer_relations/organizations/update.rb b/app/graphql/mutations/customer_relations/organizations/update.rb index d8eb55d77e9..21fcf565239 100644 --- a/app/graphql/mutations/customer_relations/organizations/update.rb +++ b/app/graphql/mutations/customer_relations/organizations/update.rb @@ -8,7 +8,7 @@ module Mutations graphql_name 'CustomerRelationsOrganizationUpdate' - authorize :admin_organization + authorize :admin_crm_organization field :organization, Types::CustomerRelations::OrganizationType, @@ -39,8 +39,6 @@ module Mutations raise_resource_not_available_error! unless organization group = organization.group - raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, group, default_enabled: :yaml) - authorize!(group) result = ::CustomerRelations::Organizations::UpdateService.new(group: group, current_user: current_user, params: args).execute(organization) diff --git a/app/graphql/mutations/issues/create.rb b/app/graphql/mutations/issues/create.rb index 70a8f539ccf..72b03cc27c2 100644 --- a/app/graphql/mutations/issues/create.rb +++ b/app/graphql/mutations/issues/create.rb @@ -3,13 +3,14 @@ module Mutations module Issues class Create < BaseMutation + include Mutations::SpamProtection include FindsProject + include CommonMutationArguments + graphql_name 'CreateIssue' authorize :create_issue - include CommonMutationArguments - argument :project_path, GraphQL::Types::ID, required: true, description: 'Project full path the issue is associated with.' @@ -76,9 +77,7 @@ module Mutations spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) issue = ::Issues::CreateService.new(project: project, current_user: current_user, params: params, spam_params: spam_params).execute - if issue.spam? - issue.errors.add(:base, 'Spam detected.') - end + check_spam_action_response!(issue) { issue: issue.valid? ? issue : nil, diff --git a/app/graphql/mutations/issues/set_crm_contacts.rb b/app/graphql/mutations/issues/set_crm_contacts.rb new file mode 100644 index 00000000000..7a9e6237eaa --- /dev/null +++ b/app/graphql/mutations/issues/set_crm_contacts.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Mutations + module Issues + class SetCrmContacts < Base + graphql_name 'IssueSetCrmContacts' + + argument :crm_contact_ids, + [::Types::GlobalIDType[::CustomerRelations::Contact]], + required: true, + description: 'Customer relations contact IDs to set. Replaces existing contacts by default.' + + argument :operation_mode, + Types::MutationOperationModeEnum, + required: false, + description: 'Changes the operation mode. Defaults to REPLACE.' + + def resolve(project_path:, iid:, crm_contact_ids:, operation_mode: Types::MutationOperationModeEnum.enum[:replace]) + issue = authorized_find!(project_path: project_path, iid: iid) + project = issue.project + raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, project.group, default_enabled: :yaml) + + crm_contact_ids = crm_contact_ids.compact.map do |crm_contact_id| + raise Gitlab::Graphql::Errors::ArgumentError, "Contact #{crm_contact_id} is invalid." unless crm_contact_id.respond_to?(:model_id) + + crm_contact_id.model_id.to_i + end + + attribute_name = case operation_mode + when Types::MutationOperationModeEnum.enum[:append] + :add_crm_contact_ids + when Types::MutationOperationModeEnum.enum[:remove] + :remove_crm_contact_ids + else + :crm_contact_ids + end + + response = ::Issues::SetCrmContactsService.new(project: project, current_user: current_user, params: { attribute_name => crm_contact_ids }) + .execute(issue) + + { + issue: issue, + errors: response.errors + } + end + end + end +end diff --git a/app/graphql/mutations/merge_requests/set_wip.rb b/app/graphql/mutations/merge_requests/set_wip.rb deleted file mode 100644 index 9b6b67d4b4f..00000000000 --- a/app/graphql/mutations/merge_requests/set_wip.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module Mutations - module MergeRequests - class SetWip < Base - graphql_name 'MergeRequestSetWip' - - argument :wip, - GraphQL::Types::Boolean, - required: true, - description: <<~DESC - Whether or not to set the merge request as a draft. - DESC - - def resolve(project_path:, iid:, wip: nil) - merge_request = authorized_find!(project_path: project_path, iid: iid) - project = merge_request.project - - ::MergeRequests::UpdateService.new(project: project, current_user: current_user, params: { wip_event: wip_event(merge_request, wip) }) - .execute(merge_request) - - { - merge_request: merge_request, - errors: errors_on_object(merge_request) - } - end - - private - - def wip_event(merge_request, wip) - wip ? 'wip' : 'unwip' - end - end - end -end diff --git a/app/graphql/mutations/merge_requests/toggle_attention_requested.rb b/app/graphql/mutations/merge_requests/toggle_attention_requested.rb new file mode 100644 index 00000000000..f316f23fb85 --- /dev/null +++ b/app/graphql/mutations/merge_requests/toggle_attention_requested.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class ToggleAttentionRequested < Base + graphql_name 'MergeRequestToggleAttentionRequested' + + argument :user_id, ::Types::GlobalIDType[::User], + loads: Types::UserType, + required: true, + description: <<~DESC + User ID for the user to toggle attention requested. + DESC + + def resolve(project_path:, iid:, user:) + merge_request = authorized_find!(project_path: project_path, iid: iid) + + result = ::MergeRequests::ToggleAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request, user: user).execute + + { + merge_request: merge_request, + errors: Array(result[:message]) + } + end + end + end +end diff --git a/app/graphql/mutations/security/ci_configuration/configure_sast_iac.rb b/app/graphql/mutations/security/ci_configuration/configure_sast_iac.rb new file mode 100644 index 00000000000..a2073f10b1d --- /dev/null +++ b/app/graphql/mutations/security/ci_configuration/configure_sast_iac.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Mutations + module Security + module CiConfiguration + class ConfigureSastIac < BaseSecurityAnalyzer + graphql_name 'ConfigureSastIac' + description <<~DESC + Enable SAST IaC for a project in a new or + modified `.gitlab-ci.yml` file in a new branch. The new + branch and a URL to create a merge request are a part of the + response. + DESC + + def configure_analyzer(project, **_args) + ::Security::CiConfiguration::SastIacCreateService.new(project, current_user).execute + end + end + end + end +end diff --git a/app/graphql/queries/epic/epic_children.query.graphql b/app/graphql/queries/epic/epic_children.query.graphql index b0e55811b7d..be82813dddb 100644 --- a/app/graphql/queries/epic/epic_children.query.graphql +++ b/app/graphql/queries/epic/epic_children.query.graphql @@ -89,6 +89,7 @@ query childItems( iid epicIssueId title + blocked closedAt state createdAt diff --git a/app/graphql/resolvers/base_issues_resolver.rb b/app/graphql/resolvers/base_issues_resolver.rb new file mode 100644 index 00000000000..54ebb697cb2 --- /dev/null +++ b/app/graphql/resolvers/base_issues_resolver.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Resolvers + class BaseIssuesResolver < BaseResolver + prepend IssueResolverArguments + + argument :state, Types::IssuableStateEnum, + required: false, + description: 'Current state of this issue.' + argument :sort, Types::IssueSortEnum, + description: 'Sort issues by this criteria.', + required: false, + default_value: :created_desc + + type Types::IssueType.connection_type, null: true + + NON_STABLE_CURSOR_SORTS = %i[priority_asc priority_desc + popularity_asc popularity_desc + label_priority_asc label_priority_desc + milestone_due_asc milestone_due_desc].freeze + + def continue_issue_resolve(parent, finder, **args) + issues = Gitlab::Graphql::Loaders::IssuableLoader.new(parent, finder).batching_find_all { |q| apply_lookahead(q) } + + if non_stable_cursor_sort?(args[:sort]) + # Certain complex sorts are not supported by the stable cursor pagination yet. + # In these cases, we use offset pagination, so we return the correct connection. + offset_pagination(issues) + else + issues + end + end + + private + + def unconditional_includes + [ + { + project: [:project_feature] + }, + :author + ] + end + + def preloads + { + alert_management_alert: [:alert_management_alert], + labels: [:labels], + assignees: [:assignees], + timelogs: [:timelogs], + customer_relations_contacts: { customer_relations_contacts: [:group] } + } + end + + def non_stable_cursor_sort?(sort) + NON_STABLE_CURSOR_SORTS.include?(sort) + end + end +end + +Resolvers::BaseIssuesResolver.prepend_mod_with('Resolvers::BaseIssuesResolver') diff --git a/app/graphql/resolvers/ci/pipeline_job_artifacts_resolver.rb b/app/graphql/resolvers/ci/pipeline_job_artifacts_resolver.rb new file mode 100644 index 00000000000..35d30827561 --- /dev/null +++ b/app/graphql/resolvers/ci/pipeline_job_artifacts_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class PipelineJobArtifactsResolver < BaseResolver + type [Types::Ci::JobArtifactType], null: false + + alias_method :pipeline, :object + + def resolve + find_job_artifacts + end + + private + + def find_job_artifacts + BatchLoader::GraphQL.for(pipeline).batch do |pipelines, loader| + ActiveRecord::Associations::Preloader.new.preload(pipelines, :job_artifacts) # rubocop: disable CodeReuse/ActiveRecord + + pipelines.each { |pl| loader.call(pl, pl.job_artifacts) } + end + end + end + end +end diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb index 855877110e5..38c79ff52ac 100644 --- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb +++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb @@ -55,8 +55,8 @@ module IssueResolverArguments description: 'Filter issues by the given issue types.', required: false argument :milestone_wildcard_id, ::Types::MilestoneWildcardIdEnum, - required: false, - description: 'Filter issues by milestone ID wildcard.' + required: false, + description: 'Filter issues by milestone ID wildcard.' argument :my_reaction_emoji, GraphQL::Types::String, required: false, description: 'Filter by reaction emoji applied by the current user. Wildcard values "NONE" and "ANY" are supported.' @@ -83,6 +83,7 @@ module IssueResolverArguments args[:attempt_project_search_optimizations] = true if args[:search].present? prepare_assignee_username_params(args) + prepare_release_tag_params(args) finder = IssuesFinder.new(current_user, args) @@ -93,6 +94,7 @@ module IssueResolverArguments params_not_mutually_exclusive(args, mutually_exclusive_assignee_username_args) params_not_mutually_exclusive(args, mutually_exclusive_milestone_args) params_not_mutually_exclusive(args.fetch(:not, {}), mutually_exclusive_milestone_args) + params_not_mutually_exclusive(args, mutually_exclusive_release_tag_args) validate_anonymous_search_access! if args[:search].present? super @@ -105,10 +107,30 @@ module IssueResolverArguments complexity end + + def accept_release_tag + argument :release_tag, [GraphQL::Types::String], + required: false, + description: "Release tag associated with the issue's milestone." + argument :release_tag_wildcard_id, Types::ReleaseTagWildcardIdEnum, + required: false, + description: 'Filter issues by release tag ID wildcard.' + end end private + def prepare_release_tag_params(args) + release_tag_wildcard = args.delete(:release_tag_wildcard_id) + return if release_tag_wildcard.blank? + + args[:release_tag] ||= release_tag_wildcard + end + + def mutually_exclusive_release_tag_args + [:release_tag, :release_tag_wildcard_id] + end + def prepare_assignee_username_params(args) args[:assignee_username] = args.delete(:assignee_usernames) if args[:assignee_usernames].present? args[:not][:assignee_username] = args[:not].delete(:assignee_usernames) if args.dig(:not, :assignee_usernames).present? diff --git a/app/graphql/resolvers/concerns/resolves_groups.rb b/app/graphql/resolvers/concerns/resolves_groups.rb new file mode 100644 index 00000000000..c451d4e7936 --- /dev/null +++ b/app/graphql/resolvers/concerns/resolves_groups.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Mixin for all resolver classes for type `Types::GroupType.connection_type`. +module ResolvesGroups + extend ActiveSupport::Concern + include LooksAhead + + def resolve_with_lookahead(**args) + apply_lookahead(resolve_groups(**args)) + end + + private + + # The resolver should implement this method. + def resolve_groups(**args) + raise NotImplementedError + end + + def preloads + { + contacts: [:contacts], + container_repositories_count: [:container_repositories], + custom_emoji: [:custom_emoji], + full_path: [:route], + organizations: [:organizations], + path: [:route], + dependency_proxy_blob_count: [:dependency_proxy_blobs], + dependency_proxy_blobs: [:dependency_proxy_blobs], + dependency_proxy_image_count: [:dependency_proxy_manifests], + dependency_proxy_image_ttl_policy: [:dependency_proxy_image_ttl_policy], + dependency_proxy_setting: [:dependency_proxy_setting] + } + end +end diff --git a/app/graphql/resolvers/concerns/resolves_pipelines.rb b/app/graphql/resolvers/concerns/resolves_pipelines.rb index 7fb0852b11e..1c01e5e0250 100644 --- a/app/graphql/resolvers/concerns/resolves_pipelines.rb +++ b/app/graphql/resolvers/concerns/resolves_pipelines.rb @@ -9,6 +9,9 @@ module ResolvesPipelines Types::Ci::PipelineStatusEnum, required: false, description: "Filter pipelines by their status." + argument :scope, ::Types::Ci::PipelineScopeEnum, + required: false, + description: 'Filter pipelines by scope.' argument :ref, GraphQL::Types::String, required: false, diff --git a/app/graphql/resolvers/group_issues_resolver.rb b/app/graphql/resolvers/group_issues_resolver.rb index 1db0ab08e31..28f9266974f 100644 --- a/app/graphql/resolvers/group_issues_resolver.rb +++ b/app/graphql/resolvers/group_issues_resolver.rb @@ -1,10 +1,18 @@ # frozen_string_literal: true -# rubocop:disable Graphql/ResolverType (inherited from IssuesResolver) +# rubocop:disable Graphql/ResolverType (inherited from BaseIssuesResolver) module Resolvers - class GroupIssuesResolver < IssuesResolver + class GroupIssuesResolver < BaseIssuesResolver include GroupIssuableResolver include_subgroups 'issues' + + def ready?(**args) + if args.dig(:not, :release_tag).present? + raise ::Gitlab::Graphql::Errors::ArgumentError, 'releaseTag filter is not allowed when parent is a group.' + end + + super + end end end diff --git a/app/graphql/resolvers/groups_resolver.rb b/app/graphql/resolvers/groups_resolver.rb index b090fdc49d4..abd3bf9e6e0 100644 --- a/app/graphql/resolvers/groups_resolver.rb +++ b/app/graphql/resolvers/groups_resolver.rb @@ -2,6 +2,8 @@ module Resolvers class GroupsResolver < BaseResolver + include ResolvesGroups + type Types::GroupType, null: true argument :include_parent_descendants, GraphQL::Types::Boolean, @@ -19,16 +21,12 @@ module Resolvers alias_method :parent, :object - def resolve(**args) - return [] unless parent.present? - - find_groups(args) - end - private # rubocop: disable CodeReuse/ActiveRecord - def find_groups(args) + def resolve_groups(args) + return Group.none unless parent.present? + GroupsFinder .new(context[:current_user], args.merge(parent: parent)) .execute diff --git a/app/graphql/resolvers/issue_status_counts_resolver.rb b/app/graphql/resolvers/issue_status_counts_resolver.rb index 58cff559d0d..db5c91daac2 100644 --- a/app/graphql/resolvers/issue_status_counts_resolver.rb +++ b/app/graphql/resolvers/issue_status_counts_resolver.rb @@ -5,6 +5,7 @@ module Resolvers prepend IssueResolverArguments type Types::IssueStatusCountsType, null: true + accept_release_tag extras [:lookahead] diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index b556964ae0c..4b52ef61d57 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -1,61 +1,8 @@ # frozen_string_literal: true +# rubocop:disable Graphql/ResolverType (inherited from BaseIssuesResolver) module Resolvers - class IssuesResolver < BaseResolver - prepend IssueResolverArguments - - argument :state, Types::IssuableStateEnum, - required: false, - description: 'Current state of this issue.' - argument :sort, Types::IssueSortEnum, - description: 'Sort issues by this criteria.', - required: false, - default_value: :created_desc - - type Types::IssueType.connection_type, null: true - - NON_STABLE_CURSOR_SORTS = %i[priority_asc priority_desc - popularity_asc popularity_desc - label_priority_asc label_priority_desc - milestone_due_asc milestone_due_desc].freeze - - def continue_issue_resolve(parent, finder, **args) - issues = Gitlab::Graphql::Loaders::IssuableLoader.new(parent, finder).batching_find_all { |q| apply_lookahead(q) } - - if non_stable_cursor_sort?(args[:sort]) - # Certain complex sorts are not supported by the stable cursor pagination yet. - # In these cases, we use offset pagination, so we return the correct connection. - offset_pagination(issues) - else - issues - end - end - - private - - def unconditional_includes - [ - { - project: [:project_feature] - }, - :author - ] - end - - def preloads - { - alert_management_alert: [:alert_management_alert], - labels: [:labels], - assignees: [:assignees], - timelogs: [:timelogs], - customer_relations_contacts: { customer_relations_contacts: [:group] } - } - end - - def non_stable_cursor_sort?(sort) - NON_STABLE_CURSOR_SORTS.include?(sort) - end + class IssuesResolver < BaseIssuesResolver + accept_release_tag end end - -Resolvers::IssuesResolver.prepend_mod_with('Resolvers::IssuesResolver') diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb index c0dd61078c6..bd7f1f0774e 100644 --- a/app/graphql/resolvers/merge_requests_resolver.rb +++ b/app/graphql/resolvers/merge_requests_resolver.rb @@ -68,6 +68,12 @@ module Resolvers description: 'Sort merge requests by this criteria.', required: false, default_value: :created_desc + argument :created_after, Types::TimeType, + required: false, + description: 'Merge requests created after this timestamp.' + argument :created_before, Types::TimeType, + required: false, + description: 'Merge requests created before this timestamp.' negated do argument :labels, [GraphQL::Types::String], diff --git a/app/graphql/resolvers/topics_resolver.rb b/app/graphql/resolvers/topics_resolver.rb new file mode 100644 index 00000000000..d8199f3d89b --- /dev/null +++ b/app/graphql/resolvers/topics_resolver.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Resolvers + class TopicsResolver < BaseResolver + type Types::Projects::TopicType, null: true + + argument :search, GraphQL::Types::String, + required: false, + description: 'Search query for topic name.' + + def resolve(**args) + if args[:search].present? + ::Projects::Topic.search(args[:search]).order_by_total_projects_count + else + ::Projects::Topic.order_by_total_projects_count + end + end + end +end diff --git a/app/graphql/resolvers/users/groups_resolver.rb b/app/graphql/resolvers/users/groups_resolver.rb index 0899b08e19c..eafb56d8f4c 100644 --- a/app/graphql/resolvers/users/groups_resolver.rb +++ b/app/graphql/resolvers/users/groups_resolver.rb @@ -3,8 +3,8 @@ module Resolvers module Users class GroupsResolver < BaseResolver + include ResolvesGroups include Gitlab::Graphql::Authorize::AuthorizeResource - include LooksAhead type Types::GroupType.connection_type, null: true @@ -20,22 +20,17 @@ module Resolvers description: 'Filter by permissions the user has on groups.' before_connection_authorization do |nodes, current_user| - Preloaders::UserMaxAccessLevelInGroupsPreloader.new(nodes, current_user).execute + Preloaders::GroupPolicyPreloader.new(nodes, current_user).execute end - def resolve_with_lookahead(**args) - return unless Feature.enabled?(:paginatable_namespace_drop_down_for_project_creation, current_user, default_enabled: :yaml) - - apply_lookahead(Groups::UserGroupsFinder.new(current_user, object, args).execute) + def ready?(**args) + Feature.enabled?(:paginatable_namespace_drop_down_for_project_creation, current_user, default_enabled: :yaml) end private - def preloads - { - path: [:route], - full_path: [:route] - } + def resolve_groups(**args) + Groups::UserGroupsFinder.new(current_user, object, args).execute end end end diff --git a/app/graphql/types/ci/job_artifact_type.rb b/app/graphql/types/ci/job_artifact_type.rb index f80681bcf36..69bb5325dba 100644 --- a/app/graphql/types/ci/job_artifact_type.rb +++ b/app/graphql/types/ci/job_artifact_type.rb @@ -12,6 +12,10 @@ module Types field :file_type, ::Types::Ci::JobArtifactFileTypeEnum, null: true, description: 'File type of the artifact.' + field :name, GraphQL::Types::String, null: true, + description: 'File name of the artifact.', + method: :filename + def download_path ::Gitlab::Routing.url_helpers.download_project_job_artifacts_path( object.project, diff --git a/app/graphql/types/ci/pipeline_scope_enum.rb b/app/graphql/types/ci/pipeline_scope_enum.rb new file mode 100644 index 00000000000..3ce81bc7bf3 --- /dev/null +++ b/app/graphql/types/ci/pipeline_scope_enum.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module Ci + class PipelineScopeEnum < BaseEnum + SCOPES_DESCRIPTION = { + running: 'Pipeline is running.', + pending: 'Pipeline has not started running yet.', + finished: 'Pipeline has completed.', + branches: 'Branches.', + tags: 'Tags.' + }.freeze + + SCOPES_DESCRIPTION.each do |scope, description| + value scope.to_s.upcase, + description: description, + value: scope.to_s + end + end + end +end diff --git a/app/graphql/types/ci/pipeline_status_enum.rb b/app/graphql/types/ci/pipeline_status_enum.rb index 2800454a999..c8e031e18ea 100644 --- a/app/graphql/types/ci/pipeline_status_enum.rb +++ b/app/graphql/types/ci/pipeline_status_enum.rb @@ -3,10 +3,24 @@ module Types module Ci class PipelineStatusEnum < BaseEnum - ::Ci::Pipeline.all_state_names.each do |state_symbol| - value state_symbol.to_s.upcase, - description: "#{::Ci::Pipeline::STATUSES_DESCRIPTION[state_symbol]}.", - value: state_symbol.to_s + STATUSES_DESCRIPTION = { + created: 'Pipeline has been created.', + waiting_for_resource: 'A resource (for example, a runner) that the pipeline requires to run is unavailable.', + preparing: 'Pipeline is preparing to run.', + pending: 'Pipeline has not started running yet.', + running: 'Pipeline is running.', + failed: 'At least one stage of the pipeline failed.', + success: 'Pipeline completed successfully.', + canceled: 'Pipeline was canceled before completion.', + skipped: 'Pipeline was skipped.', + manual: 'Pipeline needs to be manually started.', + scheduled: 'Pipeline is scheduled to run.' + }.freeze + + STATUSES_DESCRIPTION.each do |state, description| + value state.to_s.upcase, + description: description, + value: state.to_s end end end diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb index 493ce188d9b..da2f11be9e2 100644 --- a/app/graphql/types/ci/pipeline_type.rb +++ b/app/graphql/types/ci/pipeline_type.rb @@ -108,6 +108,11 @@ module Types description: 'Name of the job.' end + field :job_artifacts, + null: true, + description: 'Job artifacts of the pipeline.', + resolver: ::Resolvers::Ci::PipelineJobArtifactsResolver + field :source_job, type: Types::Ci::JobType, null: true, @@ -125,6 +130,10 @@ module Types field :path, GraphQL::Types::String, null: true, description: "Relative path to the pipeline's page." + field :commit, Types::CommitType, null: true, + description: "Git commit of the pipeline.", + calls_gitaly: true + field :commit_path, GraphQL::Types::String, null: true, description: 'Path to the commit that triggered the pipeline.' diff --git a/app/graphql/types/ci/runner_setup_type.rb b/app/graphql/types/ci/runner_setup_type.rb index 70f33cef8ad..b6b020db40e 100644 --- a/app/graphql/types/ci/runner_setup_type.rb +++ b/app/graphql/types/ci/runner_setup_type.rb @@ -9,7 +9,7 @@ module Types field :install_instructions, GraphQL::Types::String, null: false, description: 'Instructions for installing the runner on the specified architecture.' field :register_instructions, GraphQL::Types::String, null: true, - description: 'Instructions for registering the runner.' + description: 'Instructions for registering the runner. The actual registration tokens are not included in the commands. Instead, a placeholder `$REGISTRATION_TOKEN` is shown.' end end end diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb index dc1bf6a3101..7d141bd6daa 100644 --- a/app/graphql/types/commit_type.rb +++ b/app/graphql/types/commit_type.rb @@ -14,12 +14,19 @@ module Types description: 'SHA1 ID of the commit.' field :short_id, type: GraphQL::Types::String, null: false, description: 'Short SHA1 ID of the commit.' + field :title, type: GraphQL::Types::String, null: true, calls_gitaly: true, description: 'Title of the commit message.' markdown_field :title_html, null: true + + field :full_title, type: GraphQL::Types::String, null: true, calls_gitaly: true, + description: 'Full title of the commit message.' + markdown_field :full_title_html, null: true + field :description, type: GraphQL::Types::String, null: true, description: 'Description of the commit message.' markdown_field :description_html, null: true + field :message, type: GraphQL::Types::String, null: true, description: 'Raw commit message.' field :authored_date, type: Types::TimeType, null: true, diff --git a/app/graphql/types/customer_relations/contact_type.rb b/app/graphql/types/customer_relations/contact_type.rb index b5224a3e239..6a3882c66af 100644 --- a/app/graphql/types/customer_relations/contact_type.rb +++ b/app/graphql/types/customer_relations/contact_type.rb @@ -5,7 +5,7 @@ module Types class ContactType < BaseObject graphql_name 'CustomerRelationsContact' - authorize :read_contact + authorize :read_crm_contact field :id, GraphQL::Types::ID, diff --git a/app/graphql/types/customer_relations/organization_type.rb b/app/graphql/types/customer_relations/organization_type.rb index 9b22fa35b11..0f97f0a2433 100644 --- a/app/graphql/types/customer_relations/organization_type.rb +++ b/app/graphql/types/customer_relations/organization_type.rb @@ -5,7 +5,7 @@ module Types class OrganizationType < BaseObject graphql_name 'CustomerRelationsOrganization' - authorize :read_organization + authorize :read_crm_organization field :id, GraphQL::Types::ID, diff --git a/app/graphql/types/dependency_proxy/manifest_type.rb b/app/graphql/types/dependency_proxy/manifest_type.rb index 9aa62266ef7..ef9f730df43 100644 --- a/app/graphql/types/dependency_proxy/manifest_type.rb +++ b/app/graphql/types/dependency_proxy/manifest_type.rb @@ -8,6 +8,7 @@ module Types authorize :read_dependency_proxy + field :id, ::Types::GlobalIDType[::DependencyProxy::Manifest], null: false, description: 'ID of the manifest.' field :created_at, Types::TimeType, null: false, description: 'Date of creation.' field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.' field :file_name, GraphQL::Types::String, null: false, description: 'Name of the manifest.' diff --git a/app/graphql/types/evidence_type.rb b/app/graphql/types/evidence_type.rb index 26fb64d25d8..33f46c712f1 100644 --- a/app/graphql/types/evidence_type.rb +++ b/app/graphql/types/evidence_type.rb @@ -5,7 +5,7 @@ module Types graphql_name 'ReleaseEvidence' description 'Evidence for a release' - authorize :download_code + authorize :read_release_evidence present_using Releases::EvidencePresenter diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index b1bbabcdaed..4a20d84f2ab 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -34,6 +34,7 @@ module Types null: true, method: :project_creation_level_str, description: 'Permission level required to create projects in the group.' + field :subgroup_creation_level, type: GraphQL::Types::String, null: true, @@ -44,6 +45,7 @@ module Types type: GraphQL::Types::Boolean, null: true, description: 'Indicates if all users in this group are required to set up two-factor authentication.' + field :two_factor_grace_period, type: GraphQL::Types::Int, null: true, @@ -220,12 +222,16 @@ module Types group.container_repositories.size end + def dependency_proxy_manifests + group.dependency_proxy_manifests.order_id_desc + end + def dependency_proxy_image_count - group.dependency_proxy_manifests.count + group.dependency_proxy_manifests.size end def dependency_proxy_blob_count - group.dependency_proxy_blobs.count + group.dependency_proxy_blobs.size end def dependency_proxy_total_size diff --git a/app/graphql/types/issues/negated_issue_filter_input_type.rb b/app/graphql/types/issues/negated_issue_filter_input_type.rb index c8b7cdaa68e..73e090a4802 100644 --- a/app/graphql/types/issues/negated_issue_filter_input_type.rb +++ b/app/graphql/types/issues/negated_issue_filter_input_type.rb @@ -14,6 +14,9 @@ module Types argument :milestone_title, [GraphQL::Types::String], required: false, description: 'Milestone not applied to this issue.' + argument :release_tag, [GraphQL::Types::String], + required: false, + description: "Release tag not associated with the issue's milestone. Ignored when parent is a group." argument :author_username, GraphQL::Types::String, required: false, description: "Username of a user who didn't author the issue." diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 004ac364487..a0f00ddc3c6 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -53,9 +53,6 @@ module Types description: 'Indicates if the source branch is protected.' field :target_branch, GraphQL::Types::String, null: false, description: 'Target branch of the merge request.' - field :work_in_progress, GraphQL::Types::Boolean, method: :work_in_progress?, null: false, - deprecated: { reason: 'Use `draft`', milestone: '13.12' }, - description: 'Indicates if the merge request is a draft.' field :draft, GraphQL::Types::Boolean, method: :draft?, null: false, description: 'Indicates if the merge request is a draft.' field :merge_when_pipeline_succeeds, GraphQL::Types::Boolean, null: true, @@ -102,7 +99,8 @@ module Types field :default_merge_commit_message, GraphQL::Types::String, null: true, description: 'Default merge commit message of the merge request.' field :default_merge_commit_message_with_description, GraphQL::Types::String, null: true, - description: 'Default merge commit message of the merge request with description.' + description: 'Default merge commit message of the merge request with description. Will have the same value as `defaultMergeCommitMessage` when project has `mergeCommitTemplate` set.', + deprecated: { reason: 'Define merge commit template in project and use `defaultMergeCommitMessage`', milestone: '14.5' } field :default_squash_commit_message, GraphQL::Types::String, null: true, calls_gitaly: true, description: 'Default squash commit message of the merge request.' field :merge_ongoing, GraphQL::Types::Boolean, method: :merge_ongoing?, null: false, diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index cd4c45d2942..e8a952e9c61 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -16,6 +16,7 @@ module Types mount_mutation Mutations::AlertManagement::HttpIntegration::ResetToken mount_mutation Mutations::AlertManagement::HttpIntegration::Destroy mount_mutation Mutations::Security::CiConfiguration::ConfigureSast + mount_mutation Mutations::Security::CiConfiguration::ConfigureSastIac mount_mutation Mutations::Security::CiConfiguration::ConfigureSecretDetection mount_mutation Mutations::AlertManagement::PrometheusIntegration::Create mount_mutation Mutations::AlertManagement::PrometheusIntegration::Update @@ -48,6 +49,7 @@ module Types mount_mutation Mutations::Environments::CanaryIngress::Update mount_mutation Mutations::Issues::Create mount_mutation Mutations::Issues::SetAssignees + mount_mutation Mutations::Issues::SetCrmContacts mount_mutation Mutations::Issues::SetConfidential mount_mutation Mutations::Issues::SetLocked mount_mutation Mutations::Issues::SetDueDate @@ -63,12 +65,10 @@ module Types mount_mutation Mutations::MergeRequests::SetLocked mount_mutation Mutations::MergeRequests::SetMilestone mount_mutation Mutations::MergeRequests::SetSubscription - mount_mutation Mutations::MergeRequests::SetWip, - calls_gitaly: true, - deprecated: { reason: 'Use mergeRequestSetDraft', milestone: '13.12' } mount_mutation Mutations::MergeRequests::SetDraft, calls_gitaly: true mount_mutation Mutations::MergeRequests::SetAssignees mount_mutation Mutations::MergeRequests::ReviewerRereview + mount_mutation Mutations::MergeRequests::ToggleAttentionRequested, feature_flag: :mr_attention_requests mount_mutation Mutations::Metrics::Dashboard::Annotations::Create mount_mutation Mutations::Metrics::Dashboard::Annotations::Delete mount_mutation Mutations::Notes::Create::Note, calls_gitaly: true diff --git a/app/graphql/types/packages/file_metadata_type.rb b/app/graphql/types/packages/file_metadata_type.rb index 46ccb424218..0731fc897a2 100644 --- a/app/graphql/types/packages/file_metadata_type.rb +++ b/app/graphql/types/packages/file_metadata_type.rb @@ -14,6 +14,8 @@ module Types case object when ::Packages::Conan::FileMetadatum ::Types::Packages::Conan::FileMetadatumType + when ::Packages::Helm::FileMetadatum + ::Types::Packages::Helm::FileMetadatumType else # NOTE: This method must be kept in sync with `PackageFileType#file_metadata`, # which must never produce data that this discriminator cannot handle. @@ -21,7 +23,8 @@ module Types end end - orphan_types Types::Packages::Conan::FileMetadatumType + orphan_types Types::Packages::Conan::FileMetadatumType, + Types::Packages::Helm::FileMetadatumType end end end diff --git a/app/graphql/types/packages/helm/dependency_type.rb b/app/graphql/types/packages/helm/dependency_type.rb new file mode 100644 index 00000000000..35598c2b1d7 --- /dev/null +++ b/app/graphql/types/packages/helm/dependency_type.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + module Packages + module Helm + # rubocop: disable Graphql/AuthorizeTypes + class DependencyType < BaseObject + graphql_name 'PackageHelmDependencyType' + description 'Represents a Helm dependency' + + # Need to be synced with app/validators/json_schemas/helm_metadata.json#dependencies + field :name, GraphQL::Types::String, null: true, description: 'Name of the dependency.' + field :version, GraphQL::Types::String, null: true, description: 'Version of the dependency.' + field :repository, GraphQL::Types::String, null: true, description: 'Repository of the dependency.' + field :condition, GraphQL::Types::String, null: true, description: 'Condition of the dependency.' + field :tags, [GraphQL::Types::String], null: true, description: 'Tags of the dependency.' + field :enabled, GraphQL::Types::Boolean, null: true, description: 'Indicates the dependency is enabled.' + field :import_values, [GraphQL::Types::JSON], null: true, description: 'Import-values of the dependency.', hash_key: "import-values" # rubocop:disable Graphql/JSONType + field :alias, GraphQL::Types::String, null: true, description: 'Alias of the dependency.', resolver_method: :resolve_alias + + # field :alias` conflicts with a built-in method + def resolve_alias + object['alias'] + end + end + end + end +end diff --git a/app/graphql/types/packages/helm/file_metadatum_type.rb b/app/graphql/types/packages/helm/file_metadatum_type.rb new file mode 100644 index 00000000000..734ed52df40 --- /dev/null +++ b/app/graphql/types/packages/helm/file_metadatum_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Packages + module Helm + class FileMetadatumType < BaseObject + graphql_name 'HelmFileMetadata' + description 'Helm file metadata' + + implements Types::Packages::FileMetadataType + + authorize :read_package + + field :channel, GraphQL::Types::String, null: false, description: 'Channel of the Helm chart.' + field :metadata, Types::Packages::Helm::MetadataType, null: false, description: 'Metadata of the Helm chart.' + end + end + end +end diff --git a/app/graphql/types/packages/helm/maintainer_type.rb b/app/graphql/types/packages/helm/maintainer_type.rb new file mode 100644 index 00000000000..6d25a26c46b --- /dev/null +++ b/app/graphql/types/packages/helm/maintainer_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module Packages + module Helm + # rubocop: disable Graphql/AuthorizeTypes + class MaintainerType < BaseObject + graphql_name 'PackageHelmMaintainerType' + description 'Represents a Helm maintainer' + + # Need to be synced with app/validators/json_schemas/helm_metadata.json#maintainers + field :name, GraphQL::Types::String, null: true, description: 'Name of the maintainer.' + field :email, GraphQL::Types::String, null: true, description: 'Email of the maintainer.' + field :url, GraphQL::Types::String, null: true, description: 'URL of the maintainer.' + end + end + end +end diff --git a/app/graphql/types/packages/helm/metadata_type.rb b/app/graphql/types/packages/helm/metadata_type.rb new file mode 100644 index 00000000000..eeb3e8087a8 --- /dev/null +++ b/app/graphql/types/packages/helm/metadata_type.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Types + module Packages + module Helm + # rubocop: disable Graphql/AuthorizeTypes + class MetadataType < BaseObject + graphql_name 'PackageHelmMetadataType' + description 'Represents the contents of a Helm Chart.yml file' + + # Need to be synced with app/validators/json_schemas/helm_metadata.json + field :name, GraphQL::Types::String, null: false, description: 'Name of the chart.' + field :home, GraphQL::Types::String, null: true, description: 'URL of the home page.' + field :sources, [GraphQL::Types::String], null: true, description: 'URLs of the source code for the chart.' + field :version, GraphQL::Types::String, null: false, description: 'Version of the chart.' + field :description, GraphQL::Types::String, null: true, description: 'Description of the chart.' + field :keywords, [GraphQL::Types::String], null: true, description: 'Keywords for the chart.' + field :maintainers, [Types::Packages::Helm::MaintainerType], null: true, description: 'Maintainers of the chart.' + field :icon, GraphQL::Types::String, null: true, description: 'URL to an SVG or PNG image for the chart.' + field :api_version, GraphQL::Types::String, null: false, description: 'API version of the chart.', hash_key: "apiVersion" + field :condition, GraphQL::Types::String, null: true, description: 'Condition for the chart.' + field :tags, GraphQL::Types::String, null: true, description: 'Tags for the chart.' + field :app_version, GraphQL::Types::String, null: true, description: 'App version of the chart.', hash_key: "appVersion" + field :deprecated, GraphQL::Types::Boolean, null: true, description: 'Indicates if the chart is deprecated.' + field :annotations, GraphQL::Types::JSON, null: true, description: 'Annotations for the chart.' # rubocop:disable Graphql/JSONType + field :kube_version, GraphQL::Types::String, null: true, description: 'Kubernetes versions for the chart.', hash_key: "kubeVersion" + field :dependencies, [Types::Packages::Helm::DependencyType], null: true, description: 'Dependencies of the chart.' + field :type, GraphQL::Types::String, null: true, description: 'Type of the chart.', hash_key: "appVersion" + end + end + end +end diff --git a/app/graphql/types/packages/package_file_type.rb b/app/graphql/types/packages/package_file_type.rb index 8cc0f9b984a..f90a0992bf8 100644 --- a/app/graphql/types/packages/package_file_type.rb +++ b/app/graphql/types/packages/package_file_type.rb @@ -27,6 +27,8 @@ module Types case object.package.package_type when 'conan' object.conan_file_metadatum + when 'helm' + object.helm_file_metadatum else nil end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 791875242df..b6cb9cd3302 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -306,7 +306,7 @@ module Types null: true, description: 'A single release of the project.', resolver: Resolvers::ReleasesResolver.single, - authorize: :download_code + authorize: :read_release field :container_expiration_policy, Types::ContainerExpirationPolicyType, @@ -381,6 +381,11 @@ module Types description: 'Cluster agents associated with the project.', resolver: ::Resolvers::Clusters::AgentsResolver + field :merge_commit_template, + GraphQL::Types::String, + null: true, + description: 'Template used to create merge commit message in merge requests.' + def label(title:) BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args| LabelsFinder diff --git a/app/graphql/types/projects/topic_type.rb b/app/graphql/types/projects/topic_type.rb new file mode 100644 index 00000000000..79ab69e794b --- /dev/null +++ b/app/graphql/types/projects/topic_type.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + module Projects + # rubocop: disable Graphql/AuthorizeTypes + class TopicType < BaseObject + graphql_name 'Topic' + + field :id, GraphQL::Types::ID, null: false, + description: 'ID of the topic.' + + field :name, GraphQL::Types::String, null: false, + description: 'Name of the topic.' + + field :description, GraphQL::Types::String, null: true, + description: 'Description of the topic.' + markdown_field :description_html, null: true + + field :avatar_url, GraphQL::Types::String, null: true, + description: 'URL to avatar image file of the topic.' + + def avatar_url + object.avatar_url(only_path: false) + end + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index ed4ddbb982b..4a4d6727c3f 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -140,6 +140,11 @@ module Types null: true, resolver: Resolvers::BoardListResolver + field :topics, Types::Projects::TopicType.connection_type, + null: true, + resolver: Resolvers::TopicsResolver, + description: "Find project topics." + def design_management DesignManagementObject.new(nil) end diff --git a/app/graphql/types/release_links_type.rb b/app/graphql/types/release_links_type.rb index 7830e29f3cd..37ad52ce6d0 100644 --- a/app/graphql/types/release_links_type.rb +++ b/app/graphql/types/release_links_type.rb @@ -4,7 +4,7 @@ module Types class ReleaseLinksType < BaseObject graphql_name 'ReleaseLinks' - authorize :download_code + authorize :read_release alias_method :release, :object @@ -16,14 +16,19 @@ module Types description: "HTTP URL of the release's edit page.", authorize: :update_release field :opened_merge_requests_url, GraphQL::Types::String, null: true, - description: 'HTTP URL of the merge request page, filtered by this release and `state=open`.' + description: 'HTTP URL of the merge request page, filtered by this release and `state=open`.', + authorize: :download_code field :merged_merge_requests_url, GraphQL::Types::String, null: true, - description: 'HTTP URL of the merge request page , filtered by this release and `state=merged`.' + description: 'HTTP URL of the merge request page , filtered by this release and `state=merged`.', + authorize: :download_code field :closed_merge_requests_url, GraphQL::Types::String, null: true, - description: 'HTTP URL of the merge request page , filtered by this release and `state=closed`.' + description: 'HTTP URL of the merge request page , filtered by this release and `state=closed`.', + authorize: :download_code field :opened_issues_url, GraphQL::Types::String, null: true, - description: 'HTTP URL of the issues page, filtered by this release and `state=open`.' + description: 'HTTP URL of the issues page, filtered by this release and `state=open`.', + authorize: :download_code field :closed_issues_url, GraphQL::Types::String, null: true, - description: 'HTTP URL of the issues page, filtered by this release and `state=closed`.' + description: 'HTTP URL of the issues page, filtered by this release and `state=closed`.', + authorize: :download_code end end diff --git a/app/graphql/types/release_tag_wildcard_id_enum.rb b/app/graphql/types/release_tag_wildcard_id_enum.rb new file mode 100644 index 00000000000..27765864101 --- /dev/null +++ b/app/graphql/types/release_tag_wildcard_id_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class ReleaseTagWildcardIdEnum < BaseEnum + graphql_name 'ReleaseTagWildcardId' + description 'Release tag ID wildcard values' + + value 'NONE', 'No release tag is assigned.' + value 'ANY', 'Release tag is assigned.' + end +end diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb index 6dda93c7329..fcc9ec49252 100644 --- a/app/graphql/types/release_type.rb +++ b/app/graphql/types/release_type.rb @@ -14,8 +14,7 @@ module Types present_using ReleasePresenter field :tag_name, GraphQL::Types::String, null: true, method: :tag, - description: 'Name of the tag associated with the release.', - authorize: :download_code + description: 'Name of the tag associated with the release.' field :tag_path, GraphQL::Types::String, null: true, description: 'Relative web path to the tag associated with the release.', authorize: :download_code diff --git a/app/graphql/types/repository/blob_type.rb b/app/graphql/types/repository/blob_type.rb index ef7f535212f..104171e6772 100644 --- a/app/graphql/types/repository/blob_type.rb +++ b/app/graphql/types/repository/blob_type.rb @@ -68,6 +68,9 @@ module Types field :replace_path, GraphQL::Types::String, null: true, description: 'Web path to replace the blob content.' + field :pipeline_editor_path, GraphQL::Types::String, null: true, + description: 'Web path to edit .gitlab-ci.yml file.' + field :file_type, GraphQL::Types::String, null: true, description: 'Expected format of the blob based on the extension.' diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb index 63d1eef5b59..fc9860900c9 100644 --- a/app/graphql/types/repository_type.rb +++ b/app/graphql/types/repository_type.rb @@ -16,8 +16,7 @@ module Types description: 'Tree of the repository.' field :paginated_tree, Types::Tree::TreeType.connection_type, null: true, resolver: Resolvers::PaginatedTreeResolver, calls_gitaly: true, max_page_size: 100, - description: 'Paginated tree of the repository.', - feature_flag: :paginated_tree_graphql_query + description: 'Paginated tree of the repository.' field :blobs, Types::Repository::BlobType.connection_type, null: true, resolver: Resolvers::BlobsResolver, calls_gitaly: true, description: 'Blobs contained within the repository' field :branch_names, [GraphQL::Types::String], null: true, calls_gitaly: true, diff --git a/app/helpers/admin/deploy_key_helper.rb b/app/helpers/admin/deploy_key_helper.rb new file mode 100644 index 00000000000..caf3757a68e --- /dev/null +++ b/app/helpers/admin/deploy_key_helper.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Admin + module DeployKeyHelper + def admin_deploy_keys_data + { + edit_path: edit_admin_deploy_key_path(':id'), + delete_path: admin_deploy_key_path(':id'), + create_path: new_admin_deploy_key_path, + empty_state_svg_path: image_path('illustrations/empty-state/empty-deploy-keys-lg.svg') + } + end + end +end diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index 60e37c96f61..5ca360f38da 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -39,14 +39,14 @@ module AppearancesHelper if current_appearance&.header_logo? image_tag current_appearance.header_logo_path, class: 'brand-header-logo' else - render 'shared/logo.svg' + render partial: 'shared/logo', formats: :svg end end # Skip the 'GitLab' type logo when custom brand logo is set def brand_header_logo_type unless current_appearance&.header_logo? - render 'shared/logo_type.svg' + render partial: 'shared/logo_type', formats: :svg end end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 2103a37180f..b8ee71daeee 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -404,6 +404,10 @@ module ApplicationSettingsHelper :keep_latest_artifact, :whats_new_variant, :user_deactivation_emails_enabled, + :sentry_enabled, + :sentry_dsn, + :sentry_clientside_dsn, + :sentry_environment, :sidekiq_job_limiter_mode, :sidekiq_job_limiter_compression_threshold_bytes, :sidekiq_job_limiter_limit_bytes, diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index a0c3a6f2f52..6fe92a5a978 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -8,6 +8,7 @@ module AuthHelper azure_oauth2 bitbucket facebook + dingtalk github gitlab google_oauth2 diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 33d5bae88f4..c26a73028b9 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -43,7 +43,7 @@ module BoardsHelper def build_issue_link_base if board.group_board? - "#{group_path(@board.group)}/:project_path/issues" + "/:project_path/-/issues" else project_issues_path(@project) end diff --git a/app/helpers/breadcrumbs_helper.rb b/app/helpers/breadcrumbs_helper.rb index ade7c48b03f..3a622a65685 100644 --- a/app/helpers/breadcrumbs_helper.rb +++ b/app/helpers/breadcrumbs_helper.rb @@ -27,10 +27,10 @@ module BreadcrumbsHelper end end - def add_to_breadcrumb_dropdown(link, location: :before) - @breadcrumb_dropdown_links ||= {} - @breadcrumb_dropdown_links[location] ||= [] - @breadcrumb_dropdown_links[location] << link + def add_to_breadcrumb_collapsed_links(link, location: :before) + @breadcrumb_collapsed_links ||= {} + @breadcrumb_collapsed_links[location] ||= [] + @breadcrumb_collapsed_links[location] << link end def push_to_schema_breadcrumb(text, link) diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb index 6be46b40023..6104a1256d5 100644 --- a/app/helpers/ci/pipelines_helper.rb +++ b/app/helpers/ci/pipelines_helper.rb @@ -67,6 +67,17 @@ module Ci ] end + def has_pipeline_badges?(pipeline) + pipeline.child? || + pipeline.latest? || + pipeline.merge_train_pipeline? || + pipeline.has_yaml_errors? || + pipeline.failure_reason? || + pipeline.auto_devops_source? || + pipeline.detached_merge_request_pipeline? || + pipeline.stuck? + end + private def warning_markdown(pipeline) diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb index ec10610714b..17057505173 100644 --- a/app/helpers/ci/runners_helper.rb +++ b/app/helpers/ci/runners_helper.rb @@ -6,27 +6,30 @@ module Ci def runner_status_icon(runner, size: 16, icon_class: '') status = runner.status + active = runner.active title = '' icon = 'warning-solid' span_class = '' case status + when :online + if active + title = s_("Runners|Runner is online, last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(runner.contacted_at) } + icon = 'status-active' + span_class = 'gl-text-green-500' + else + title = s_("Runners|Runner is paused, last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(runner.contacted_at) } + icon = 'status-paused' + span_class = 'gl-text-gray-600' + end when :not_connected title = s_("Runners|New runner, has not connected yet") icon = 'warning-solid' - when :online - title = s_("Runners|Runner is online, last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(runner.contacted_at) } - icon = 'status-active' - span_class = 'gl-text-green-500' when :offline title = s_("Runners|Runner is offline, last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(runner.contacted_at) } icon = 'status-failed' span_class = 'gl-text-red-500' - when :paused - title = s_("Runners|Runner is paused, last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(runner.contacted_at) } - icon = 'status-paused' - span_class = 'gl-text-gray-600' end content_tag(:span, class: span_class, title: title, data: { toggle: 'tooltip', container: 'body', testid: 'runner_status_icon', qa_selector: "runner_status_#{status}_content" }) do @@ -57,11 +60,30 @@ module Ci end end + def admin_runners_data_attributes + { + # Runner install help page is external, located at + # https://gitlab.com/gitlab-org/gitlab-runner + runner_install_help_page: 'https://docs.gitlab.com/runner/install/', + registration_token: Gitlab::CurrentSettings.runners_registration_token, + + # All runner counts are returned as formatted strings + active_runners_count: Ci::Runner.online.count.to_s, + all_runners_count: limited_counter_with_delimiter(Ci::Runner), + instance_runners_count: limited_counter_with_delimiter(Ci::Runner.instance_type), + group_runners_count: limited_counter_with_delimiter(Ci::Runner.group_type), + project_runners_count: limited_counter_with_delimiter(Ci::Runner.project_type) + } + end + def group_shared_runners_settings_data(group) { update_path: api_v4_groups_path(id: group.id), shared_runners_availability: group.shared_runners_setting, - parent_shared_runners_availability: group.parent&.shared_runners_setting + parent_shared_runners_availability: group.parent&.shared_runners_setting, + runner_enabled: Namespace::SR_ENABLED, + runner_disabled: Namespace::SR_DISABLED_AND_UNOVERRIDABLE, + runner_allow_override: Namespace::SR_DISABLED_WITH_OVERRIDE } end diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index c355fa5cc67..93b6b4e8fe2 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -12,35 +12,36 @@ module ClustersHelper end end - def display_cluster_agents?(_clusterable) - false + def display_cluster_agents?(clusterable) + clusterable.is_a?(Project) end - def js_cluster_agents_list_data(clusterable_project) - { - default_branch_name: clusterable_project.default_branch, - empty_state_image: image_path('illustrations/clusters_empty.svg'), - project_path: clusterable_project.full_path, - agent_docs_url: help_page_path('user/clusters/agent/index'), - install_docs_url: help_page_path('administration/clusters/kas'), - get_started_docs_url: help_page_path('user/clusters/agent/index', anchor: 'define-a-configuration-repository'), - integration_docs_url: help_page_path('user/clusters/agent/index', anchor: 'get-started-with-gitops-and-the-gitlab-agent'), - kas_address: Gitlab::Kas.external_url - } - end - - def js_clusters_list_data(path = nil) + def js_clusters_list_data(clusterable) { ancestor_help_path: help_page_path('user/group/clusters/index', anchor: 'cluster-precedence'), - endpoint: path, + endpoint: clusterable.index_path(format: :json), img_tags: { aws: { path: image_path('illustrations/logos/amazon_eks.svg'), text: s_('ClusterIntegration|Amazon EKS') }, default: { path: image_path('illustrations/logos/kubernetes.svg'), text: _('Kubernetes Cluster') }, gcp: { path: image_path('illustrations/logos/google_gke.svg'), text: s_('ClusterIntegration|Google GKE') } - } + }, + clusters_empty_state_image: image_path('illustrations/empty-state/empty-state-clusters.svg'), + empty_state_help_text: clusterable.empty_state_help_text, + new_cluster_path: clusterable.new_path(tab: 'create'), + can_add_cluster: clusterable.can_add_cluster?.to_s } end + def js_clusters_data(clusterable) + { + default_branch_name: clusterable.default_branch, + empty_state_image: image_path('illustrations/empty-state/empty-state-agents.svg'), + project_path: clusterable.full_path, + add_cluster_path: clusterable.new_path(tab: 'add'), + kas_address: Gitlab::Kas.external_url + }.merge(js_clusters_list_data(clusterable)) + end + def js_cluster_form_data(cluster, can_edit) { enabled: cluster.enabled?.to_s, diff --git a/app/helpers/emoji_helper.rb b/app/helpers/emoji_helper.rb index 51b7fd7f352..c390924f7e3 100644 --- a/app/helpers/emoji_helper.rb +++ b/app/helpers/emoji_helper.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true module EmojiHelper - def emoji_icon(*args) - raw Gitlab::Emoji.gl_emoji_tag(*args) + def emoji_icon(emoji_name, *options) + emoji = TanukiEmoji.find_by_alpha_code(emoji_name) + raw Gitlab::Emoji.gl_emoji_tag(emoji, *options) end end diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index b2842664879..cde45e7bc0f 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -69,9 +69,7 @@ module EnvironmentsHelper 'custom_metrics_path' => project_prometheus_metrics_path(project), 'validate_query_path' => validate_query_project_prometheus_metrics_path(project), 'custom_metrics_available' => "#{custom_metrics_available?(project)}", - 'prometheus_alerts_available' => "#{can?(current_user, :read_prometheus_alerts, project)}", - 'dashboard_timezone' => project.metrics_setting_dashboard_timezone.to_s.upcase, - 'has_managed_prometheus' => has_managed_prometheus?(project).to_s + 'dashboard_timezone' => project.metrics_setting_dashboard_timezone.to_s.upcase } end @@ -86,10 +84,6 @@ module EnvironmentsHelper } end - def has_managed_prometheus?(project) - project.prometheus_integration&.prometheus_available? == true - end - def metrics_dashboard_base_path(environment, project) # This is needed to support our transition from environment scoped metric paths to project scoped. if project diff --git a/app/helpers/graph_helper.rb b/app/helpers/graph_helper.rb index 3a94f7d47c2..45ca820f7b3 100644 --- a/app/helpers/graph_helper.rb +++ b/app/helpers/graph_helper.rb @@ -26,6 +26,10 @@ module GraphHelper def should_render_dora_charts false end + + def should_render_quality_summary + false + end end GraphHelper.prepend_mod_with('GraphHelper') diff --git a/app/helpers/groups/settings_helper.rb b/app/helpers/groups/settings_helper.rb new file mode 100644 index 00000000000..1b391680996 --- /dev/null +++ b/app/helpers/groups/settings_helper.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Groups + module SettingsHelper + include GroupsHelper + + def group_settings_confirm_modal_data(group, remove_form_id = nil) + { + remove_form_id: remove_form_id, + button_text: _('Remove group'), + button_testid: 'remove-group-button', + disabled: group.paid?.to_s, + confirm_danger_message: remove_group_message(group), + phrase: group.full_path + } + end + end +end + +Groups::SettingsHelper.prepend_mod_with('Groups::SettingsHelper') diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 30aaa0a5acc..9ba7d004d6c 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -39,7 +39,7 @@ module GroupsHelper sorted_ancestors(group).with_route.reverse_each.with_index do |parent, index| if index > 0 - add_to_breadcrumb_dropdown(group_title_link(parent, hidable: false, show_avatar: true, for_dropdown: true), location: :before) + add_to_breadcrumb_collapsed_links(group_title_link(parent), location: :before) else full_title << breadcrumb_list_item(group_title_link(parent, hidable: false)) end @@ -47,7 +47,7 @@ module GroupsHelper push_to_schema_breadcrumb(simple_sanitize(parent.name), group_path(parent)) end - full_title << render("layouts/nav/breadcrumbs/collapsed_dropdown", location: :before, title: _("Show parent subgroups")) + full_title << render("layouts/nav/breadcrumbs/collapsed_inline_list", location: :before, title: _("Show all breadcrumbs")) full_title << breadcrumb_list_item(group_title_link(group)) push_to_schema_breadcrumb(simple_sanitize(group.name), group_path(group)) diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index c38b4a7aedf..32d808c960c 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -9,9 +9,7 @@ module IconsHelper def custom_icon(icon_name, size: DEFAULT_ICON_SIZE) memoized_icon("#{icon_name}_#{size}") do - # We can't simply do the below, because there are some .erb SVGs. - # File.read(Rails.root.join("app/views/shared/icons/_#{icon_name}.svg")).html_safe - render "shared/icons/#{icon_name}.svg", size: size + render partial: "shared/icons/#{icon_name}", formats: :svg, locals: { size: size } end end diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb index 8819aa9e9cc..bb4a7fef6be 100644 --- a/app/helpers/integrations_helper.rb +++ b/app/helpers/integrations_helper.rb @@ -132,6 +132,20 @@ module IntegrationsHelper end end + def zentao_issue_breadcrumb_link(issue) + link_to issue[:web_url], { target: '_blank', rel: 'noopener noreferrer', class: 'gl-display-flex gl-align-items-center gl-white-space-nowrap' } do + icon = image_tag image_path('logos/zentao.svg'), width: 15, height: 15, class: 'gl-mr-2' + [icon, html_escape(issue[:id])].join.html_safe + end + end + + def zentao_issues_show_data + { + issues_show_path: project_integrations_zentao_issue_path(@project, params[:id], format: :json), + issues_list_path: project_integrations_zentao_issues_path(@project) + } + end + extend self private diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb index d9bd64f4c2e..01ae0ce4f31 100644 --- a/app/helpers/invite_members_helper.rb +++ b/app/helpers/invite_members_helper.rb @@ -42,6 +42,14 @@ module InviteMembersHelper e.candidate { dataset.merge!(areas_of_focus_options: member_areas_of_focus_options.to_json, no_selection_areas_of_focus: ['no_selection']) } end + if show_invite_members_for_task? + dataset.merge!( + tasks_to_be_done_options: tasks_to_be_done_options.to_json, + projects: projects_for_source(source).to_json, + new_project_path: source.is_a?(Group) ? new_project_path(namespace_id: source.id) : '' + ) + end + dataset end @@ -71,4 +79,19 @@ module InviteMembersHelper def users_filter_data(group) {} end + + def show_invite_members_for_task? + return unless current_user && experiment(:invite_members_for_task).enabled? + + params[:open_modal] == 'invite_members_for_task' + end + + def tasks_to_be_done_options + ::MemberTask::TASKS.keys.map { |task| { value: task, text: localized_tasks_to_be_done_choices[task] } } + end + + def projects_for_source(source) + projects = source.is_a?(Project) ? [source] : source.projects + projects.map { |project| { id: project.id, title: project.title } } + end end diff --git a/app/helpers/issuables_description_templates_helper.rb b/app/helpers/issuables_description_templates_helper.rb index a5b9a6eee80..6b546d5c6fc 100644 --- a/app/helpers/issuables_description_templates_helper.rb +++ b/app/helpers/issuables_description_templates_helper.rb @@ -32,14 +32,17 @@ module IssuablesDescriptionTemplatesHelper @template_types[project.id][issuable_type] ||= TemplateFinder.all_template_names(project, issuable_type.pluralize) end - # Overriden on EE::IssuablesDescriptionTemplatesHelper to include inherited templates names - def issuable_templates_names(issuable, include_inherited_templates = false) + def selected_template(issuable) all_templates = issuable_templates(ref_project, issuable.to_ability_name) - all_templates.values.flatten.map { |tpl| tpl[:name] if tpl[:project_id] == ref_project.id }.compact.uniq + + # Only local templates will be listed if licenses for inherited templates are not present + all_templates = all_templates.values.flatten.map { |tpl| tpl[:name] }.compact.uniq + + all_templates.find { |tmpl_name| tmpl_name == params[:issuable_template] } end - def selected_template(issuable) - params[:issuable_template] if issuable_templates_names(issuable, true).any? { |tmpl_name| tmpl_name == params[:issuable_template] } + def available_service_desk_templates_for(project) + issuable_templates(project, 'issue').flatten.to_json end def template_names_path(parent, issuable) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 24c6ef8cd68..07f5adae272 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -257,7 +257,8 @@ module IssuablesHelper zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable), sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier, # rubocop:disable CodeReuse/ActiveRecord iid: issuable.iid.to_s, - isHidden: issue_hidden?(issuable) + isHidden: issue_hidden?(issuable), + canCreateIncident: create_issue_type_allowed?(issuable.project, :incident) } end @@ -284,9 +285,7 @@ module IssuablesHelper end def issuables_count_for_state(issuable_type, state) - store_in_cache = parent.is_a?(Group) ? parent.cached_issues_state_count_enabled? : false - - Gitlab::IssuablesCountForState.new(finder, store_in_redis_cache: store_in_cache)[state] + Gitlab::IssuablesCountForState.new(finder, store_in_redis_cache: true)[state] end def close_issuable_path(issuable) @@ -442,7 +441,7 @@ module IssuablesHelper end def format_count(issuable_type, count, threshold) - if issuable_type == :issues && parent.is_a?(Group) && parent.cached_issues_state_count_enabled? + if issuable_type == :issues && parent.is_a?(Group) format_cached_count(threshold, count) else number_with_delimiter(count) diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 49f7d9aeef1..a88ca6f6b11 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module IssuesHelper + include Issues::IssueTypeHelpers + def issue_css_classes(issue) classes = ["issue"] classes << "closed" if issue.closed? @@ -190,6 +192,7 @@ module IssuesHelper { can_create_issue: show_new_issue_link?(project).to_s, + can_create_incident: create_issue_type_allowed?(project, :incident).to_s, can_reopen_issue: can?(current_user, :reopen_issue, issuable).to_s, can_report_spam: issuable.submittable_as_spam_by?(current_user).to_s, can_update_issue: can?(current_user, :update_issue, issuable).to_s, @@ -233,6 +236,7 @@ module IssuesHelper new_issue_path: new_project_issue_path(project, issue: { milestone_id: finder.milestones.first.try(:id) }), project_import_jira_path: project_import_jira_path(project), quick_actions_help_path: help_page_path('user/project/quick_actions'), + releases_path: project_releases_path(project, format: :json), reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'), show_new_issue_link: show_new_issue_link?(project).to_s ) diff --git a/app/helpers/learn_gitlab_helper.rb b/app/helpers/learn_gitlab_helper.rb index 4fb7a05a0e9..08a30c4d53b 100644 --- a/app/helpers/learn_gitlab_helper.rb +++ b/app/helpers/learn_gitlab_helper.rb @@ -7,10 +7,32 @@ module LearnGitlabHelper learn_gitlab_onboarding_available?(project) end + def learn_gitlab_data(project) + { + actions: onboarding_actions_data(project).to_json, + sections: onboarding_sections_data.to_json + } + end + + def learn_gitlab_onboarding_available?(project) + OnboardingProgress.onboarding?(project.namespace) && + LearnGitlab::Project.new(current_user).available? + end + + private + def onboarding_actions_data(project) attributes = onboarding_progress(project).attributes.symbolize_keys - action_urls.to_h do |action, url| + urls_to_use = nil + + experiment(:change_continuous_onboarding_link_urls) do |e| + e.namespace = project.namespace + e.use { urls_to_use = action_urls } + e.try { urls_to_use = new_action_urls(project) } + end + + urls_to_use.to_h do |action, url| [ action, url: url, @@ -34,18 +56,22 @@ module LearnGitlabHelper } end - def learn_gitlab_onboarding_available?(project) - OnboardingProgress.onboarding?(project.namespace) && - LearnGitlab::Project.new(current_user).available? - end - - private - def action_urls LearnGitlab::Onboarding::ACTION_ISSUE_IDS.transform_values { |id| project_issue_url(learn_gitlab_project, id) } .merge(LearnGitlab::Onboarding::ACTION_DOC_URLS) end + def new_action_urls(project) + action_urls.merge( + issue_created: project_issues_path(project), + git_write: project_path(project), + pipeline_created: project_pipelines_path(project), + merge_request_created: project_merge_requests_path(project), + user_added: project_members_url(project), + security_scan_enabled: project_security_configuration_path(project) + ) + end + def learn_gitlab_project @learn_gitlab_project ||= LearnGitlab::Project.new(current_user).project end @@ -54,3 +80,5 @@ module LearnGitlabHelper OnboardingProgress.find_by(namespace: project.namespace) # rubocop: disable CodeReuse/ActiveRecord end end + +LearnGitlabHelper.prepend_mod_with('LearnGitlabHelper') diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index d3db5d24207..aac49cfa234 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -56,6 +56,14 @@ module MembersHelper end end + def localized_tasks_to_be_done_choices + { + code: s_('TasksToBeDone|Create/import code into a project (repository)'), + ci: s_('TasksToBeDone|Set up CI/CD pipelines to build, test, deploy, and monitor code'), + issues: s_('TasksToBeDone|Create/import issues (tickets) to collaborate on ideas and plan work') + }.freeze + end + private def source_text(member) diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb index 3055ad57b80..ecef2d38e54 100644 --- a/app/helpers/nav/top_nav_helper.rb +++ b/app/helpers/nav/top_nav_helper.rb @@ -267,6 +267,7 @@ module Nav builder.add_primary_menu_item(id: 'your', title: _('Your projects'), href: dashboard_projects_path) builder.add_primary_menu_item(id: 'starred', title: _('Starred projects'), href: starred_dashboard_projects_path) builder.add_primary_menu_item(id: 'explore', title: _('Explore projects'), href: explore_root_path) + builder.add_primary_menu_item(id: 'topics', title: _('Explore topics'), href: topics_explore_projects_path) builder.add_secondary_menu_item(id: 'create', title: _('Create new project'), href: new_project_path) builder.build end diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index fff7e5d1c7f..2dadaa0be0a 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -167,11 +167,11 @@ module NotesHelper } end - def discussions_path(issuable) + def discussions_path(issuable, **params) if issuable.is_a?(Issue) - discussions_project_issue_path(@project, issuable, format: :json) + discussions_project_issue_path(@project, issuable, params.merge(format: :json)) else - discussions_project_merge_request_path(@project, issuable, format: :json) + discussions_project_merge_request_path(@project, issuable, params.merge(format: :json)) end end @@ -188,7 +188,8 @@ module NotesHelper reopenPath: reopen_issuable_path(issuable), notesPath: notes_url, prerenderedNotesCount: issuable.capped_notes_count(MAX_PRERENDERED_NOTES), - lastFetchedAt: initial_last_fetched_at + lastFetchedAt: initial_last_fetched_at, + notesFilter: current_user&.notes_filter_for(issuable) } if issuable.is_a?(MergeRequest) diff --git a/app/helpers/one_trust_helper.rb b/app/helpers/one_trust_helper.rb index 9f92a73a4d4..55364d36a0e 100644 --- a/app/helpers/one_trust_helper.rb +++ b/app/helpers/one_trust_helper.rb @@ -4,7 +4,6 @@ module OneTrustHelper def one_trust_enabled? Feature.enabled?(:ecomm_instrumentation, type: :ops) && Gitlab.config.extra.has_key?('one_trust_id') && - Gitlab.config.extra.one_trust_id.present? && - !current_user + Gitlab.config.extra.one_trust_id.present? end end diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb index b50e287a509..e03f2ae78bf 100644 --- a/app/helpers/projects/alert_management_helper.rb +++ b/app/helpers/projects/alert_management_helper.rb @@ -10,7 +10,6 @@ module Projects::AlertManagementHelper 'empty-alert-svg-path' => image_path('illustrations/alert-management-empty-state.svg'), 'user-can-enable-alert-management' => can?(current_user, :admin_operations, project).to_s, 'alert-management-enabled' => alert_management_enabled?(project).to_s, - 'has-managed-prometheus' => has_managed_prometheus?(project).to_s, 'text-query': params[:search], 'assignee-username-query': params[:assignee_username] } @@ -28,10 +27,6 @@ module Projects::AlertManagementHelper private - def has_managed_prometheus?(project) - project.prometheus_integration&.prometheus_available? == true - end - def alert_management_enabled?(project) !!( project.alert_management_alerts.any? || diff --git a/app/helpers/projects/incidents_helper.rb b/app/helpers/projects/incidents_helper.rb index dde2980817f..4b3ff8a3e83 100644 --- a/app/helpers/projects/incidents_helper.rb +++ b/app/helpers/projects/incidents_helper.rb @@ -11,7 +11,8 @@ module Projects::IncidentsHelper 'empty-list-svg-path' => image_path('illustrations/incident-empty-state.svg'), 'text-query': params[:search], 'author-username-query': params[:author_username], - 'assignee-username-query': params[:assignee_username] + 'assignee-username-query': params[:assignee_username], + 'can-create-incident': create_issue_type_allowed?(project, :incident).to_s } end end diff --git a/app/helpers/projects/security/configuration_helper.rb b/app/helpers/projects/security/configuration_helper.rb index dee106ab3ae..8281b1f8522 100644 --- a/app/helpers/projects/security/configuration_helper.rb +++ b/app/helpers/projects/security/configuration_helper.rb @@ -4,7 +4,7 @@ module Projects module Security module ConfigurationHelper def security_upgrade_path - 'https://about.gitlab.com/pricing/' + "https://#{ApplicationHelper.promo_host}/pricing/" end end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index e3b63d122d2..8366b25d2bc 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -196,12 +196,26 @@ module ProjectsHelper cookies["hide_auto_devops_implicitly_enabled_banner_#{project.id}".to_sym].blank? end - def link_to_set_password - if current_user.require_password_creation_for_git? - link_to s_('SetPasswordToCloneLink|set a password'), edit_profile_password_path - else - link_to s_('CreateTokenToCloneLink|create a personal access token'), profile_personal_access_tokens_path - end + def no_password_message + push_pull_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('gitlab-basics/start-using-git', anchor: 'pull-and-push') } + clone_with_https_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('gitlab-basics/start-using-git', anchor: 'clone-with-https') } + set_password_link_start = '<a href="%{url}">'.html_safe % { url: edit_profile_password_path } + set_up_pat_link_start = '<a href="%{url}">'.html_safe % { url: profile_personal_access_tokens_path } + + message = if current_user.require_password_creation_for_git? + _('Your account is authenticated with SSO or SAML. To %{push_pull_link_start}push and pull%{link_end} over %{protocol} with Git using this account, you must %{set_password_link_start}set a password%{link_end} or %{set_up_pat_link_start}set up a Personal Access Token%{link_end} to use instead of a password. For more information, see %{clone_with_https_link_start}Clone with HTTPS%{link_end}.') + else + _('Your account is authenticated with SSO or SAML. To %{push_pull_link_start}push and pull%{link_end} over %{protocol} with Git using this account, you must %{set_up_pat_link_start}set up a Personal Access Token%{link_end} to use instead of a password. For more information, see %{clone_with_https_link_start}Clone with HTTPS%{link_end}.') + end + + html_escape(message) % { + push_pull_link_start: push_pull_link_start, + protocol: gitlab_config.protocol.upcase, + clone_with_https_link_start: clone_with_https_link_start, + set_password_link_start: set_password_link_start, + set_up_pat_link_start: set_up_pat_link_start, + link_end: '</a>'.html_safe + } end # Returns true if any projects are present. @@ -382,6 +396,15 @@ module ProjectsHelper "" end + # Returns the confirm phrase the user needs to type in order to delete the project + # + # Thus the phrase should include the namespace to make it very clear to the + # user which project is subject to deletion. + # Relevant issue: https://gitlab.com/gitlab-org/gitlab/-/issues/343591 + def delete_confirm_phrase(project) + project.path_with_namespace + end + private def tab_ability_map @@ -590,6 +613,7 @@ module ProjectsHelper %w[ environments clusters + cluster_agents functions error_tracking alert_management diff --git a/app/helpers/recaptcha_helper.rb b/app/helpers/recaptcha_helper.rb index 0df62f7b715..5b17ab4b815 100644 --- a/app/helpers/recaptcha_helper.rb +++ b/app/helpers/recaptcha_helper.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true module RecaptchaHelper - def show_recaptcha_sign_up? + def recaptcha_enabled? !!Gitlab::Recaptcha.enabled? end + alias_method :show_recaptcha_sign_up?, :recaptcha_enabled? end RecaptchaHelper.prepend_mod diff --git a/app/helpers/reminder_emails_helper.rb b/app/helpers/reminder_emails_helper.rb index bffb3cf7751..132fc3b784c 100644 --- a/app/helpers/reminder_emails_helper.rb +++ b/app/helpers/reminder_emails_helper.rb @@ -7,7 +7,8 @@ module ReminderEmailsHelper s_('InviteReminderEmail|Invitation pending') when 1 if format == :html - s_('InviteReminderEmail|Hey there %{wave_emoji}').html_safe % { wave_emoji: Gitlab::Emoji.gl_emoji_tag('wave') } + wave_emoji_tag = Gitlab::Emoji.gl_emoji_tag(TanukiEmoji.find_by_alpha_code('wave')) + s_('InviteReminderEmail|Hey there %{wave_emoji}').html_safe % { wave_emoji: wave_emoji_tag } else s_('InviteReminderEmail|Hey there!') end diff --git a/app/helpers/routing/pseudonymization_helper.rb b/app/helpers/routing/pseudonymization_helper.rb index b73e49803ae..ac30669dc83 100644 --- a/app/helpers/routing/pseudonymization_helper.rb +++ b/app/helpers/routing/pseudonymization_helper.rb @@ -2,58 +2,86 @@ module Routing module PseudonymizationHelper - def masked_page_url - return unless Feature.enabled?(:mask_page_urls, type: :ops) + class MaskHelper + QUERY_PARAMS_TO_NOT_MASK = %w[].freeze - mask_params(Rails.application.routes.recognize_path(request.original_fullpath)) - rescue ActionController::RoutingError, URI::InvalidURIError => e - Gitlab::ErrorTracking.track_exception(e, url: request.original_fullpath) - nil - end + def initialize(request_object, group, project) + @request = request_object + @group = group + @project = project + end + + def mask_params + return default_root_url + @request.original_fullpath unless has_maskable_params? - private + masked_params = @request.path_parameters.to_h do |key, value| + case key + when :project_id + [key, "project#{@project&.id}"] + when :namespace_id, :group_id + namespace = @group || @project&.namespace + [key, "namespace#{namespace&.id}"] + when :id + [key, mask_id(value)] + else + [key, value] + end + end - def mask_params(request_params) - return if request_params[:action] == 'new' + Gitlab::Routing.url_helpers.url_for(masked_params.merge(params: masked_query_params)) + end - namespace_type = request_params[:controller].split('/')[1] + private - namespace_type.present? ? url_with_namespace_type(request_params, namespace_type) : url_without_namespace_type(request_params) - end + def mask_id(value) + if @request.path_parameters[:controller] == 'projects/blob' + ':repository_path' + elsif @request.path_parameters[:controller] == 'projects' + "project#{@project&.id}" + elsif @request.path_parameters[:controller] == 'groups' + "namespace#{@group&.id}" + else + value + end + end - def url_without_namespace_type(request_params) - masked_url = "#{request.protocol}#{request.host_with_port}" + def has_maskable_params? + request_params = @request.path_parameters.to_h + request_params.key?(:namespace_id) || request_params.key?(:group_id) || request_params.key?(:project_id) || request_params.key?(:id) || @request.query_string.present? + end - masked_url += case request_params[:controller] - when 'groups' - "/namespace:#{group.id}" - when 'projects' - "/namespace:#{project.namespace_id}/project:#{project.id}" - when 'root' - '' - else - "#{request.path}" - end + def masked_query_params + return {} unless @request.query_string.present? - masked_url += request.query_string.present? ? "?#{request.query_string}" : '' + query_string_hash = Rack::Utils.parse_nested_query(@request.query_string) - masked_url - end + query_string_hash.keys.each do |key| + next if QUERY_PARAMS_TO_NOT_MASK.include?(key) - def url_with_namespace_type(request_params, namespace_type) - masked_url = "#{request.protocol}#{request.host_with_port}" + query_string_hash[key] = "masked_#{key}" + end - if request_params.has_key?(:project_id) - masked_url += "/namespace:#{project.namespace_id}/project:#{project.id}/-/#{namespace_type}" + query_string_hash end - if request_params.has_key?(:id) - masked_url += namespace_type == 'blob' ? '/:repository_path' : "/#{request_params[:id]}" + def default_root_url + Gitlab::Routing.url_helpers.root_url(only_path: false) end + end - masked_url += request.query_string.present? ? "?#{request.query_string}" : '' + def masked_page_url + return unless Feature.enabled?(:mask_page_urls, type: :ops) + + current_group = group if defined?(group) + current_project = project if defined?(project) + mask_helper = MaskHelper.new(request, current_group, current_project) + mask_helper.mask_params - masked_url + # We rescue all exception for time being till we test this helper extensively. + # Check https://gitlab.com/gitlab-org/gitlab/-/merge_requests/72864#note_711515501 + rescue => e # rubocop:disable Style/RescueStandardError + Gitlab::ErrorTracking.track_exception(e, url: request.original_fullpath) + nil end end end diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb index d6a4d6ac57a..a60143db739 100644 --- a/app/helpers/storage_helper.rb +++ b/app/helpers/storage_helper.rb @@ -14,12 +14,13 @@ module StorageHelper counter_repositories: storage_counter(statistics.repository_size), counter_wikis: storage_counter(statistics.wiki_size), counter_build_artifacts: storage_counter(statistics.build_artifacts_size), + counter_pipeline_artifacts: storage_counter(statistics.pipeline_artifacts_size), counter_lfs_objects: storage_counter(statistics.lfs_objects_size), counter_snippets: storage_counter(statistics.snippets_size), counter_packages: storage_counter(statistics.packages_size), counter_uploads: storage_counter(statistics.uploads_size) } - _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets} / Packages: %{counter_packages} / Uploads: %{counter_uploads}") % counters + _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / Pipeline Artifacts: %{counter_pipeline_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets} / Packages: %{counter_packages} / Uploads: %{counter_uploads}") % counters end end diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb index a6bb2f3b246..e53e35baac3 100644 --- a/app/helpers/tab_helper.rb +++ b/app/helpers/tab_helper.rb @@ -33,7 +33,6 @@ module TabHelper # :item_active - Overrides the default state focing the "active" css classes (optional). # def gl_tab_link_to(name = nil, options = {}, html_options = {}, &block) - tab_class = 'nav-item' link_classes = %w[nav-link gl-tab-nav-item] active_link_classes = %w[active gl-tab-nav-item-active gl-tab-nav-item-active-indigo] @@ -52,6 +51,8 @@ module TabHelper end html_options = html_options.except(:item_active) + extra_tab_classes = html_options.delete(:tab_class) + tab_class = %w[nav-item].push(*extra_tab_classes) content_tag(:li, class: tab_class, role: 'presentation') do if block_given? @@ -210,3 +211,12 @@ module TabHelper current_page?(options) end end + +def gl_tab_counter_badge(count, html_options = {}) + badge_classes = %w[badge badge-muted badge-pill gl-badge sm gl-tab-counter-badge] + content_tag(:span, + count, + class: [*html_options[:class], badge_classes].join(' '), + data: html_options[:data] + ) +end diff --git a/app/helpers/terms_helper.rb b/app/helpers/terms_helper.rb new file mode 100644 index 00000000000..5f321551413 --- /dev/null +++ b/app/helpers/terms_helper.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module TermsHelper + def terms_data(terms, redirect) + redirect_params = { redirect: redirect } if redirect + + { + terms: markdown_field(terms, :terms), + permissions: { + can_accept: can?(current_user, :accept_terms, terms), + can_decline: can?(current_user, :decline_terms, terms) + }, + paths: { + accept: accept_term_path(terms, redirect_params), + decline: decline_term_path(terms, redirect_params), + root: root_path + } + }.to_json + end +end diff --git a/app/helpers/time_zone_helper.rb b/app/helpers/time_zone_helper.rb index a0d9c8403e8..db355f5ff65 100644 --- a/app/helpers/time_zone_helper.rb +++ b/app/helpers/time_zone_helper.rb @@ -32,10 +32,16 @@ module TimeZoneHelper end end + def local_time_instance(timezone) + return Time.zone if timezone.blank? + + ActiveSupport::TimeZone.new(timezone) || Time.zone + end + def local_time(timezone) return if timezone.blank? - time_zone_instance = ActiveSupport::TimeZone.new(timezone) || Time.zone + time_zone_instance = local_time_instance(timezone) time_zone_instance.now.strftime("%-l:%M %p") end end diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index e9dc271dbdd..79767ca76b7 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -24,6 +24,7 @@ module TodosHelper when Todo::UNMERGEABLE then 'Could not merge' when Todo::DIRECTLY_ADDRESSED then "directly addressed #{todo_action_subject(todo)} on" when Todo::MERGE_TRAIN_REMOVED then "Removed from Merge Train:" + when Todo::ATTENTION_REQUESTED then 'requested your attention on' end end diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index 1c67ca983fa..d8e69145c40 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -81,32 +81,11 @@ module UserCalloutsHelper def user_dismissed_for_group(feature_name, group, ignore_dismissal_earlier_than = nil) return false unless current_user - set_dismissed_from_cookie(group) - current_user.dismissed_callout_for_group?(feature_name: feature_name, group: group, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than) end - def set_dismissed_from_cookie(group) - # bridge function for one milestone to try and not annoy users who might have already dismissed this alert - # remove in 14.4 or 14.5? https://gitlab.com/gitlab-org/gitlab/-/issues/340322 - dismissed_key = "invite_#{group.id}_#{current_user.id}" - - if cookies[dismissed_key].present? - params = { - feature_name: INVITE_MEMBERS_BANNER, - group_id: group.id - } - - Users::DismissGroupCalloutService.new( - container: nil, current_user: current_user, params: params - ).execute - - cookies.delete dismissed_key - end - end - def just_created? flash[:notice]&.include?('successfully created') end diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb index 1b0d1254dc8..ba876f6cb65 100644 --- a/app/helpers/wiki_helper.rb +++ b/app/helpers/wiki_helper.rb @@ -9,7 +9,7 @@ module WikiHelper if page.persisted? titles << page.human_title breadcrumb_title(page.human_title) - wiki_breadcrumb_dropdown_links(page.slug) + wiki_breadcrumb_collapsed_links(page.slug) end titles << action if action @@ -39,14 +39,14 @@ module WikiHelper .join(' / ') end - def wiki_breadcrumb_dropdown_links(page_slug) + def wiki_breadcrumb_collapsed_links(page_slug) page_slug_split = page_slug.split('/') page_slug_split.pop(1) current_slug = "" page_slug_split .map do |dir_or_page| current_slug = "#{current_slug}#{dir_or_page}/" - add_to_breadcrumb_dropdown link_to(WikiPage.unhyphenize(dir_or_page).capitalize, wiki_page_path(@wiki, current_slug)), location: :after + add_to_breadcrumb_collapsed_links link_to(WikiPage.unhyphenize(dir_or_page).capitalize, wiki_page_path(@wiki, current_slug)), location: :after end end diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb index 4862282bc73..2460c956bb6 100644 --- a/app/helpers/workhorse_helper.rb +++ b/app/helpers/workhorse_helper.rb @@ -41,8 +41,8 @@ module WorkhorseHelper head :ok end - def send_dependency(token, url, filename) - headers.store(*Gitlab::Workhorse.send_dependency(token, url)) + def send_dependency(dependency_headers, url, filename) + headers.store(*Gitlab::Workhorse.send_dependency(dependency_headers, url)) headers['Content-Disposition'] = ActionDispatch::Http::ContentDisposition.format(disposition: 'attachment', filename: filename) headers['Content-Type'] = 'application/gzip' diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 6954fd46850..8a9ed557cc6 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -43,9 +43,13 @@ module Emails user = User.find(user_id) + @source_hidden = !member_source.readable_by?(user) + + human_name = @source_hidden ? 'Hidden' : member_source.human_name + member_email_with_layout( to: user.notification_email_for(notification_group), - subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was denied")) + subject: subject("Access to the #{human_name} #{member_source.model_name.singular} was denied")) end def member_invited_email(member_source_type, member_id, token) diff --git a/app/mailers/emails/pipelines.rb b/app/mailers/emails/pipelines.rb index 1b27d062391..5363ad63771 100644 --- a/app/mailers/emails/pipelines.rb +++ b/app/mailers/emails/pipelines.rb @@ -2,21 +2,23 @@ module Emails module Pipelines - def pipeline_success_email(pipeline, recipients) - pipeline_mail(pipeline, recipients, 'Successful') + def pipeline_success_email(pipeline, recipient) + pipeline_mail(pipeline, recipient, 'Successful') end - def pipeline_failed_email(pipeline, recipients) - pipeline_mail(pipeline, recipients, 'Failed') + def pipeline_failed_email(pipeline, recipient) + pipeline_mail(pipeline, recipient, 'Failed') end - def pipeline_fixed_email(pipeline, recipients) - pipeline_mail(pipeline, recipients, 'Fixed') + def pipeline_fixed_email(pipeline, recipient) + pipeline_mail(pipeline, recipient, 'Fixed') end private - def pipeline_mail(pipeline, recipients, status) + def pipeline_mail(pipeline, recipient, status) + raise ArgumentError if recipient.is_a?(Array) + @project = pipeline.project @pipeline = pipeline @@ -28,10 +30,7 @@ module Emails add_headers - # We use bcc here because we don't want to generate these emails for a - # thousand times. This could be potentially expensive in a loop, and - # recipients would contain all project watchers so it could be a lot. - mail(bcc: recipients, + mail(to: recipient, subject: subject(pipeline_subject(status))) do |format| format.html { render layout: 'mailer' } format.text { render layout: 'mailer' } diff --git a/app/models/analytics/cycle_analytics/issue_stage_event.rb b/app/models/analytics/cycle_analytics/issue_stage_event.rb index 3e6ed86d534..837eb35c839 100644 --- a/app/models/analytics/cycle_analytics/issue_stage_event.rb +++ b/app/models/analytics/cycle_analytics/issue_stage_event.rb @@ -8,9 +8,22 @@ module Analytics validates(*%i[stage_event_hash_id issue_id group_id project_id start_event_timestamp], presence: true) + alias_attribute :state, :state_id + enum state: Issue.available_states, _suffix: true + + scope :assigned_to, ->(user) do + assignees_class = IssueAssignee + condition = assignees_class.where(user_id: user).where(arel_table[:issue_id].eq(assignees_class.arel_table[:issue_id])) + where(condition.arel.exists) + end + def self.issuable_id_column :issue_id end + + def self.issuable_model + ::Issue + end end end end diff --git a/app/models/analytics/cycle_analytics/merge_request_stage_event.rb b/app/models/analytics/cycle_analytics/merge_request_stage_event.rb index d0ec3c4e8b9..0dfa322b2c3 100644 --- a/app/models/analytics/cycle_analytics/merge_request_stage_event.rb +++ b/app/models/analytics/cycle_analytics/merge_request_stage_event.rb @@ -8,9 +8,22 @@ module Analytics validates(*%i[stage_event_hash_id merge_request_id group_id project_id start_event_timestamp], presence: true) + alias_attribute :state, :state_id + enum state: MergeRequest.available_states, _suffix: true + + scope :assigned_to, ->(user) do + assignees_class = MergeRequestAssignee + condition = assignees_class.where(user_id: user).where(arel_table[:merge_request_id].eq(assignees_class.arel_table[:merge_request_id])) + where(condition.arel.exists) + end + def self.issuable_id_column :merge_request_id end + + def self.issuable_model + ::MergeRequest + end end end end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index d2757d8c17d..bcd8bdd6638 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,7 +1,10 @@ # frozen_string_literal: true class ApplicationRecord < ActiveRecord::Base - self.gitlab_schema = :gitlab_main + include DatabaseReflection + include Transactions + include LegacyBulkInsert + self.abstract_class = true alias_method :reset, :reload @@ -92,8 +95,7 @@ class ApplicationRecord < ActiveRecord::Base end def self.declarative_enum(enum_mod) - values = enum_mod.definition.transform_values { |v| v[:value] } - enum(enum_mod.key => values) + enum(enum_mod.key => enum_mod.values) end def self.cached_column_list diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 5a8cbd8d71c..af5796d682f 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -536,6 +536,18 @@ class ApplicationSetting < ApplicationRecord validates :sidekiq_job_limiter_limit_bytes, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :sentry_enabled, + inclusion: { in: [true, false], message: _('must be a boolean value') } + validates :sentry_dsn, + addressable_url: true, presence: true, length: { maximum: 255 }, + if: :sentry_enabled? + validates :sentry_clientside_dsn, + addressable_url: true, allow_blank: true, length: { maximum: 255 }, + if: :sentry_enabled? + validates :sentry_environment, + presence: true, length: { maximum: 255 }, + if: :sentry_enabled? + attr_encrypted :asset_proxy_secret_key, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_truncated, diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 7bdea36bb8a..54ec8b2c3e4 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -146,6 +146,9 @@ module ApplicationSettingImplementation session_expire_delay: Settings.gitlab['session_expire_delay'], shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'], shared_runners_text: nil, + sidekiq_job_limiter_mode: Gitlab::SidekiqMiddleware::SizeLimiter::Validator::COMPRESS_MODE, + sidekiq_job_limiter_compression_threshold_bytes: Gitlab::SidekiqMiddleware::SizeLimiter::Validator::DEFAULT_COMPRESSION_THRESHOLD_BYTES, + sidekiq_job_limiter_limit_bytes: Gitlab::SidekiqMiddleware::SizeLimiter::Validator::DEFAULT_SIZE_LIMIT, sign_in_text: nil, signup_enabled: Settings.gitlab['signup_enabled'], snippet_size_limit: 50.megabytes, @@ -241,11 +244,11 @@ module ApplicationSettingImplementation end def home_page_url_column_exists? - ::Gitlab::Database.main.cached_column_exists?(:application_settings, :home_page_url) + ApplicationSetting.database.cached_column_exists?(:home_page_url) end def help_page_support_url_column_exists? - ::Gitlab::Database.main.cached_column_exists?(:application_settings, :help_page_support_url) + ApplicationSetting.database.cached_column_exists?(:help_page_support_url) end def disabled_oauth_sign_in_sources=(sources) diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index a1c6793607f..1a8bd05c42c 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -30,6 +30,8 @@ class AuditEvent < ApplicationRecord scope :by_entity_type, -> (entity_type) { where(entity_type: entity_type) } scope :by_entity_id, -> (entity_id) { where(entity_id: entity_id) } scope :by_author_id, -> (author_id) { where(author_id: author_id) } + scope :by_entity_username, -> (username) { where(entity_id: find_user_id(username)) } + scope :by_author_username, -> (username) { where(author_id: find_user_id(username)) } after_initialize :initialize_details @@ -106,6 +108,10 @@ class AuditEvent < ApplicationRecord self[name] = self.details[name] = original end end + + def self.find_user_id(username) + User.find_by_username(username)&.id + end end AuditEvent.prepend_mod_with('AuditEvent') diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index c8f6b9aaedb..b665f3d5d8c 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -14,7 +14,7 @@ class AwardEmoji < ApplicationRecord validates :user, presence: true validates :awardable, presence: true, unless: :importing? - validates :name, presence: true, inclusion: { in: Gitlab::Emoji.emojis_names } + validates :name, presence: true, 'gitlab/emoji_name': true validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] }, unless: -> { ghost_user? || importing? } participant :user diff --git a/app/models/blob_viewer/package_json.rb b/app/models/blob_viewer/package_json.rb index 7cae60a74d6..1d10cc82a85 100644 --- a/app/models/blob_viewer/package_json.rb +++ b/app/models/blob_viewer/package_json.rb @@ -7,11 +7,15 @@ module BlobViewer self.file_types = %i(package_json) def manager_name - 'npm' + yarn? ? 'yarn' : 'npm' + end + + def yarn? + json_data['engines'].present? && json_data['engines']['yarn'].present? end def manager_url - 'https://www.npmjs.com/' + yarn? ? 'https://yarnpkg.com/' : 'https://www.npmjs.com/' end def package_name @@ -38,7 +42,11 @@ module BlobViewer end def npm_url - "https://www.npmjs.com/package/#{package_name}" + if yarn? + "https://yarnpkg.com/package/#{package_name}" + else + "https://www.npmjs.com/package/#{package_name}" + end end end end diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index ecac4ab95f4..2368be6196c 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -112,6 +112,10 @@ class BulkImports::Entity < ApplicationRecord @export_relations_url_path ||= EXPORT_RELATIONS_URL % { resource: pluralized_name, full_path: encoded_source_full_path } end + def relation_download_url_path(relation) + "#{export_relations_url_path}/download?relation=#{relation}" + end + private def validate_parent_is_a_group diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb index ff3f2663b73..da7312df18b 100644 --- a/app/models/chat_name.rb +++ b/app/models/chat_name.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ChatName < ApplicationRecord + include LooseForeignKey + LAST_USED_AT_INTERVAL = 1.hour belongs_to :integration, foreign_key: :service_id @@ -14,6 +16,8 @@ class ChatName < ApplicationRecord validates :user_id, uniqueness: { scope: [:service_id] } validates :chat_id, uniqueness: { scope: [:service_id, :team_id] } + loose_foreign_key :ci_pipeline_chat_data, :chat_name_id, on_delete: :async_delete + # Updates the "last_used_timestamp" but only if it wasn't already updated # recently. # diff --git a/app/models/ci/application_record.rb b/app/models/ci/application_record.rb index 913e7a62c66..ea7b1104e36 100644 --- a/app/models/ci/application_record.rb +++ b/app/models/ci/application_record.rb @@ -2,9 +2,12 @@ module Ci class ApplicationRecord < ::ApplicationRecord - self.gitlab_schema = :gitlab_ci self.abstract_class = true + if Gitlab::Database.has_config?(:ci) + connects_to database: { writing: :ci, reading: :ci } + end + def self.table_name_prefix 'ci_' end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 990ef71a457..3fdc44bccf3 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -10,7 +10,6 @@ module Ci include Presentable include Importable include Ci::HasRef - include IgnorableColumns BuildArchivedError = Class.new(StandardError) @@ -70,9 +69,6 @@ module Ci delegate :gitlab_deploy_token, to: :project delegate :trigger_short_token, to: :trigger_request, allow_nil: true - ignore_columns :id_convert_to_bigint, remove_with: '14.5', remove_after: '2021-10-22' - ignore_columns :stage_id_convert_to_bigint, remove_with: '14.5', remove_after: '2021-10-22' - ## # Since Gitlab 11.5, deployments records started being created right after # `ci_builds` creation. We can look up a relevant `environment` through @@ -175,6 +171,7 @@ module Ci scope :with_live_trace, -> { where('EXISTS (?)', Ci::BuildTraceChunk.where('ci_builds.id = ci_build_trace_chunks.build_id').select(1)) } scope :with_stale_live_trace, -> { with_live_trace.finished_before(12.hours.ago) } scope :finished_before, -> (date) { finished.where('finished_at < ?', date) } + scope :license_management_jobs, -> { where(name: %i(license_management license_scanning)) } # handle license rename https://gitlab.com/gitlab-org/gitlab/issues/8911 scope :with_secure_reports_from_config_options, -> (job_types) do joins(:metadata).where("ci_builds_metadata.config_options -> 'artifacts' -> 'reports' ?| array[:job_types]", job_types: job_types) @@ -313,12 +310,6 @@ module Ci end after_transition pending: :running do |build| - unless build.update_deployment_after_transaction_commit? - Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do - build.deployment&.run - end - end - build.run_after_commit do build.pipeline.persistent_ref.create @@ -339,12 +330,6 @@ module Ci end after_transition any => [:success] do |build| - unless build.update_deployment_after_transaction_commit? - Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do - build.deployment&.succeed - end - end - build.run_after_commit do BuildSuccessWorker.perform_async(id) PagesWorker.perform_async(:deploy, id) if build.pages_generator? @@ -353,23 +338,6 @@ module Ci after_transition any => [:failed] do |build| next unless build.project - next unless build.deployment - - unless build.update_deployment_after_transaction_commit? - begin - Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do - build.deployment.drop! - end - rescue StandardError => e - Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e, build_id: build.id) - end - end - - true - end - - after_transition any => [:failed] do |build| - next unless build.project if build.auto_retry_allowed? begin @@ -380,25 +348,12 @@ module Ci end end - after_transition any => [:skipped, :canceled] do |build, transition| - unless build.update_deployment_after_transaction_commit? - Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do - if transition.to_name == :skipped - build.deployment&.skip - else - build.deployment&.cancel - end - end - end - end - # Synchronize Deployment Status # Please note that the data integirty is not assured because we can't use # a database transaction due to DB decomposition. after_transition do |build, transition| next if transition.loopback? next unless build.project - next unless build.update_deployment_after_transaction_commit? build.run_after_commit do build.deployment&.sync_status_with(build) @@ -585,7 +540,6 @@ module Ci .concat(persisted_variables) .concat(dependency_proxy_variables) .concat(job_jwt_variables) - .concat(kubernetes_variables) .concat(scoped_variables) .concat(job_variables) .concat(persisted_environment_variables) @@ -1120,12 +1074,6 @@ module Ci runner&.instance_type? end - def update_deployment_after_transaction_commit? - strong_memoize(:update_deployment_after_transaction_commit) do - Feature.enabled?(:update_deployment_after_transaction_commit, project, default_enabled: :yaml) - end - end - protected def run_status_commit_hooks! @@ -1213,10 +1161,6 @@ module Ci end end - def kubernetes_variables - [] # Overridden in EE - end - def conditionally_allow_failure!(exit_code) return unless exit_code diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 0d6d6f7a6a5..ca68989002c 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -23,6 +23,7 @@ module Ci serialize :config_options, Serializers::SymbolizedJson # rubocop:disable Cop/ActiveRecordSerialize serialize :config_variables, Serializers::SymbolizedJson # rubocop:disable Cop/ActiveRecordSerialize + serialize :runtime_runner_features, Serializers::SymbolizedJson # rubocop:disable Cop/ActiveRecordSerialize chronic_duration_attr_reader :timeout_human_readable, :timeout @@ -37,8 +38,7 @@ module Ci job_timeout_source: 4 } - ignore_column :build_id_convert_to_bigint, remove_with: '14.5', remove_after: '2021-10-22' - ignore_columns :id_convert_to_bigint, remove_with: '14.5', remove_after: '2021-10-22' + ignore_columns :runner_features, remove_with: '14.7', remove_after: '2021-11-22' def update_timeout_state timeout = timeout_with_highest_precedence @@ -48,6 +48,14 @@ module Ci update(timeout: timeout.value, timeout_source: timeout.source) end + def set_cancel_gracefully + runtime_runner_features.merge!( { cancel_gracefully: true } ) + end + + def cancel_gracefully? + runtime_runner_features[:cancel_gracefully] == true + end + private def set_build_project diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb index bf1470ca20f..d4cbbfac4ab 100644 --- a/app/models/ci/build_need.rb +++ b/app/models/ci/build_need.rb @@ -3,7 +3,6 @@ module Ci class BuildNeed < Ci::ApplicationRecord include BulkInsertSafe - include IgnorableColumns belongs_to :build, class_name: "Ci::Processable", foreign_key: :build_id, inverse_of: :needs @@ -13,12 +12,5 @@ module Ci scope :scoped_build, -> { where('ci_builds.id=ci_build_needs.build_id') } scope :artifacts, -> { where(artifacts: true) } - - # TODO: Remove once build_id_convert_to_bigint is not an "ignored" column anymore (see .ignore_columns above) - # There is a database-side trigger to populate this column. This is unexpected in the context - # of cloning an instance, e.g. when retrying the job. Hence we exclude the ignored column explicitly here. - def attributes - super.except('build_id_convert_to_bigint') - end end end diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb index e12c0f82c99..c6dbb5d0a43 100644 --- a/app/models/ci/build_runner_session.rb +++ b/app/models/ci/build_runner_session.rb @@ -4,8 +4,6 @@ module Ci # The purpose of this class is to store Build related runner session. # Data will be removed after transitioning from running to any state. class BuildRunnerSession < Ci::ApplicationRecord - include IgnorableColumns - TERMINAL_SUBPROTOCOL = 'terminal.gitlab.com' DEFAULT_SERVICE_NAME = 'build' DEFAULT_PORT_NAME = 'default_port' diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 6edb5ef4579..221a2284106 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -7,7 +7,6 @@ module Ci include ::Checksummable include ::Gitlab::ExclusiveLeaseHelpers include ::Gitlab::OptimisticLocking - include IgnorableColumns belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id diff --git a/app/models/ci/ci_database_record.rb b/app/models/ci/ci_database_record.rb deleted file mode 100644 index e2b832a28e7..00000000000 --- a/app/models/ci/ci_database_record.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Ci - # TODO: https://gitlab.com/groups/gitlab-org/-/epics/6168 - # - # Do not use this yet outside of `ci_instance_variables`. - # This class is part of a migration to move all CI classes to a new separate database. - # Initially we are only going to be moving the `Ci::InstanceVariable` model and it will be duplicated in the main and CI tables - # Do not extend this class in any other models. - class CiDatabaseRecord < Ci::ApplicationRecord - self.abstract_class = true - - if Gitlab::Database.has_config?(:ci) - connects_to database: { writing: :ci, reading: :ci } - end - end -end diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb index f4aa935b983..da9d4dea537 100644 --- a/app/models/ci/instance_variable.rb +++ b/app/models/ci/instance_variable.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class InstanceVariable < Ci::CiDatabaseRecord + class InstanceVariable < Ci::ApplicationRecord extend Gitlab::ProcessMemoryCache::Helper include Ci::NewHasVariable include Ci::Maskable diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index ad3e867f9d5..ec1137920ef 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -10,9 +10,7 @@ module Ci include Artifactable include FileStoreMounter include EachBatch - include IgnorableColumns - - ignore_columns %i[id_convert_to_bigint job_id_convert_to_bigint], remove_with: '14.5', remove_after: '2021-11-22' + include Gitlab::Utils::StrongMemoize TEST_REPORT_FILE_TYPES = %w[junit].freeze COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze @@ -124,6 +122,9 @@ module Ci mount_file_store_uploader JobArtifactUploader + skip_callback :save, :after, :store_file!, if: :store_after_commit? + after_commit :store_file_after_commit!, on: [:create, :update], if: :store_after_commit? + validates :file_format, presence: true, unless: :trace?, on: :create validate :validate_file_format!, unless: :trace?, on: :create before_save :set_size, if: :file_changed? @@ -338,8 +339,23 @@ module Ci } end + def store_after_commit? + strong_memoize(:store_after_commit) do + trace? && + JobArtifactUploader.direct_upload_enabled? && + Feature.enabled?(:ci_store_trace_outside_transaction, project, default_enabled: :yaml) + end + end + private + def store_file_after_commit! + return unless previous_changes.key?(:file) + + store_file! + update_file_store + end + def set_size self.size = file.size end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 0041ec5135c..a29aa756e38 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -71,7 +71,7 @@ module Ci has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent has_many :variables, class_name: 'Ci::PipelineVariable' has_many :deployments, through: :builds - has_many :environments, -> { distinct }, through: :deployments + has_many :environments, -> { distinct.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338658') }, through: :deployments has_many :latest_builds, -> { latest.with_project_and_metadata }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Build' has_many :downloadable_artifacts, -> do not_expired.or(where_exists(::Ci::Pipeline.artifacts_locked.where('ci_pipelines.id = ci_builds.commit_id'))).downloadable.with_job @@ -652,8 +652,15 @@ module Ci end def batch_lookup_report_artifact_for_file_type(file_type) + batch_lookup_report_artifact_for_file_types([file_type]) + end + + def batch_lookup_report_artifact_for_file_types(file_types) + file_types_to_search = [] + file_types.each { |file_type| file_types_to_search.append(*::Ci::JobArtifact.associated_file_types_for(file_type.to_s)) } + latest_report_artifacts - .values_at(*::Ci::JobArtifact.associated_file_types_for(file_type.to_s)) + .values_at(*file_types_to_search.uniq) .flatten .compact .last @@ -684,7 +691,9 @@ module Ci end def freeze_period? - Ci::FreezePeriodStatus.new(project: project).execute + strong_memoize(:freeze_period) do + Ci::FreezePeriodStatus.new(project: project).execute + end end def has_warnings? @@ -780,6 +789,10 @@ module Ci strong_memoize(:legacy_trigger) { trigger_requests.first } end + def variables_builder + @variables_builder ||= ::Gitlab::Ci::Variables::Builder.new(self) + end + def persisted_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| break variables unless persisted? @@ -796,20 +809,7 @@ module Ci variables.append(key: 'CI_PIPELINE_CREATED_AT', value: created_at&.iso8601) variables.concat(predefined_commit_variables) - - if merge_request? - variables.append(key: 'CI_MERGE_REQUEST_EVENT_TYPE', value: merge_request_event_type.to_s) - variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', value: source_sha.to_s) - variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_SHA', value: target_sha.to_s) - - diff = self.merge_request_diff - if diff.present? - variables.append(key: 'CI_MERGE_REQUEST_DIFF_ID', value: diff.id.to_s) - variables.append(key: 'CI_MERGE_REQUEST_DIFF_BASE_SHA', value: diff.base_commit_sha) - end - - variables.concat(merge_request.predefined_variables) - end + variables.concat(predefined_merge_request_variables) if open_merge_requests_refs.any? variables.append(key: 'CI_OPEN_MERGE_REQUESTS', value: open_merge_requests_refs.join(',')) @@ -825,27 +825,49 @@ module Ci end def predefined_commit_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - variables.append(key: 'CI_COMMIT_SHA', value: sha) - variables.append(key: 'CI_COMMIT_SHORT_SHA', value: short_sha) - variables.append(key: 'CI_COMMIT_BEFORE_SHA', value: before_sha) - variables.append(key: 'CI_COMMIT_REF_NAME', value: source_ref) - variables.append(key: 'CI_COMMIT_REF_SLUG', value: source_ref_slug) - variables.append(key: 'CI_COMMIT_BRANCH', value: ref) if branch? - variables.append(key: 'CI_COMMIT_TAG', value: ref) if tag? - variables.append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s) - variables.append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s) - variables.append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s) - variables.append(key: 'CI_COMMIT_REF_PROTECTED', value: (!!protected_ref?).to_s) - variables.append(key: 'CI_COMMIT_TIMESTAMP', value: git_commit_timestamp.to_s) - variables.append(key: 'CI_COMMIT_AUTHOR', value: git_author_full_text.to_s) - - # legacy variables - variables.append(key: 'CI_BUILD_REF', value: sha) - variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha) - variables.append(key: 'CI_BUILD_REF_NAME', value: source_ref) - variables.append(key: 'CI_BUILD_REF_SLUG', value: source_ref_slug) - variables.append(key: 'CI_BUILD_TAG', value: ref) if tag? + strong_memoize(:predefined_commit_variables) do + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_COMMIT_SHA', value: sha) + variables.append(key: 'CI_COMMIT_SHORT_SHA', value: short_sha) + variables.append(key: 'CI_COMMIT_BEFORE_SHA', value: before_sha) + variables.append(key: 'CI_COMMIT_REF_NAME', value: source_ref) + variables.append(key: 'CI_COMMIT_REF_SLUG', value: source_ref_slug) + variables.append(key: 'CI_COMMIT_BRANCH', value: ref) if branch? + variables.append(key: 'CI_COMMIT_TAG', value: ref) if tag? + variables.append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s) + variables.append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s) + variables.append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s) + variables.append(key: 'CI_COMMIT_REF_PROTECTED', value: (!!protected_ref?).to_s) + variables.append(key: 'CI_COMMIT_TIMESTAMP', value: git_commit_timestamp.to_s) + variables.append(key: 'CI_COMMIT_AUTHOR', value: git_author_full_text.to_s) + + # legacy variables + variables.append(key: 'CI_BUILD_REF', value: sha) + variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha) + variables.append(key: 'CI_BUILD_REF_NAME', value: source_ref) + variables.append(key: 'CI_BUILD_REF_SLUG', value: source_ref_slug) + variables.append(key: 'CI_BUILD_TAG', value: ref) if tag? + end + end + end + + def predefined_merge_request_variables + strong_memoize(:predefined_merge_request_variables) do + Gitlab::Ci::Variables::Collection.new.tap do |variables| + next variables unless merge_request? + + variables.append(key: 'CI_MERGE_REQUEST_EVENT_TYPE', value: merge_request_event_type.to_s) + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', value: source_sha.to_s) + variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_SHA', value: target_sha.to_s) + + diff = self.merge_request_diff + if diff.present? + variables.append(key: 'CI_MERGE_REQUEST_DIFF_ID', value: diff.id.to_s) + variables.append(key: 'CI_MERGE_REQUEST_DIFF_BASE_SHA', value: diff.base_commit_sha) + end + + variables.concat(merge_request.predefined_variables) + end end end @@ -1254,6 +1276,18 @@ module Ci self.builds.latest.build_matchers(project) end + def predefined_vars_in_builder_enabled? + strong_memoize(:predefined_vars_in_builder_enabled) do + Feature.enabled?(:ci_predefined_vars_in_builder, project, default_enabled: :yaml) + end + end + + def authorized_cluster_agents + strong_memoize(:authorized_cluster_agents) do + ::Clusters::AgentAuthorizationsFinder.new(project).execute.map(&:agent) + end + end + private def add_message(severity, content) diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 2f718ad7582..8a3025e5608 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -12,6 +12,7 @@ module Ci include Gitlab::Utils::StrongMemoize include TaggableQueries include Presentable + include LooseForeignKey add_authentication_token_field :token, encrypted: :optional @@ -82,7 +83,9 @@ module Ci groups = Gitlab::ObjectHierarchy.new(groups).base_and_ancestors end - joins(:runner_namespaces).where(ci_runner_namespaces: { namespace_id: groups }) + joins(:runner_namespaces) + .where(ci_runner_namespaces: { namespace_id: groups }) + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433') } scope :belonging_to_group_or_project, -> (group_id, project_id) { @@ -94,13 +97,16 @@ module Ci union_sql = ::Gitlab::SQL::Union.new([group_runners, project_runners]).to_sql from("(#{union_sql}) #{table_name}") + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433') } scope :belonging_to_parent_group_of_project, -> (project_id) { project_groups = ::Group.joins(:projects).where(projects: { id: project_id }) hierarchy_groups = Gitlab::ObjectHierarchy.new(project_groups).base_and_ancestors - joins(:groups).where(namespaces: { id: hierarchy_groups }) + joins(:groups) + .where(namespaces: { id: hierarchy_groups }) + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433') } scope :owned_or_instance_wide, -> (project_id) do @@ -111,7 +117,7 @@ module Ci instance_type ], remove_duplicates: false - ) + ).allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433') end scope :assignable_for, ->(project) do @@ -162,6 +168,8 @@ module Ci validates :config, json_schema: { filename: 'ci_runner_config' } + loose_foreign_key :clusters_applications_runners, :runner_id, on_delete: :async_nullify + # Searches for runners matching the given query. # # This method uses ILIKE on PostgreSQL for the description field and performs a full match on tokens. @@ -266,6 +274,14 @@ module Ci end def status + return :not_connected unless contacted_at + + online? ? :online : :offline + end + + # DEPRECATED + # TODO Remove in %15.0 in favor of `status` for REST calls, see https://gitlab.com/gitlab-org/gitlab/-/issues/344648 + def deprecated_rest_status if contacted_at.nil? :not_connected elsif active? @@ -436,10 +452,8 @@ module Ci end def no_groups - ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') do - if groups.any? - errors.add(:runner, 'cannot have groups assigned') - end + if runner_namespaces.any? + errors.add(:runner, 'cannot have groups assigned') end end @@ -450,10 +464,8 @@ module Ci end def exactly_one_group - ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') do - unless groups.one? - errors.add(:runner, 'needs to be assigned to exactly one group') - end + unless runner_namespaces.one? + errors.add(:runner, 'needs to be assigned to exactly one group') end end diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb index 95842d944f9..f78caf710a6 100644 --- a/app/models/ci/sources/pipeline.rb +++ b/app/models/ci/sources/pipeline.rb @@ -4,9 +4,6 @@ module Ci module Sources class Pipeline < Ci::ApplicationRecord include Ci::NamespacedModelName - include IgnorableColumns - - ignore_columns 'source_job_id_convert_to_bigint', remove_with: '14.5', remove_after: '2021-11-22' self.table_name = "ci_sources_pipelines" diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 131e18adf62..e2b15497638 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -6,7 +6,6 @@ module Ci include Ci::HasStatus include Gitlab::OptimisticLocking include Presentable - include IgnorableColumns enum status: Ci::HasStatus::STATUSES_ENUM diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 595315f14ab..5bf5ae51ec8 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -3,6 +3,10 @@ module Ci class Trigger < Ci::ApplicationRecord include Presentable + include Limitable + + self.limit_name = 'pipeline_triggers' + self.limit_scope = :project belongs_to :project belongs_to :owner, class_name: "User" diff --git a/app/models/clusters/agents/group_authorization.rb b/app/models/clusters/agents/group_authorization.rb index 28a711aaf17..58ba874ab53 100644 --- a/app/models/clusters/agents/group_authorization.rb +++ b/app/models/clusters/agents/group_authorization.rb @@ -3,6 +3,8 @@ module Clusters module Agents class GroupAuthorization < ApplicationRecord + include ::Clusters::Agents::AuthorizationConfigScopes + self.table_name = 'agent_group_authorizations' belongs_to :agent, class_name: 'Clusters::Agent', optional: false diff --git a/app/models/clusters/agents/project_authorization.rb b/app/models/clusters/agents/project_authorization.rb index f6d19086751..b9b44741936 100644 --- a/app/models/clusters/agents/project_authorization.rb +++ b/app/models/clusters/agents/project_authorization.rb @@ -3,6 +3,8 @@ module Clusters module Agents class ProjectAuthorization < ApplicationRecord + include ::Clusters::Agents::AuthorizationConfigScopes + self.table_name = 'agent_project_authorizations' belongs_to :agent, class_name: 'Clusters::Agent', optional: false diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 7cef92ce81a..59a9251d6b7 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.31.0' + VERSION = '0.34.0' self.table_name = 'clusters_applications_runners' @@ -70,7 +70,7 @@ module Clusters } if cluster.group_type? - attributes[:groups] = [group] + attributes[:runner_namespaces] = [::Ci::RunnerNamespace.new(namespace: group)] elsif cluster.project_type? attributes[:runner_projects] = [::Ci::RunnerProject.new(project: project)] end diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index feac7bbc363..87afa9f9491 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -139,8 +139,6 @@ module Clusters scope :with_available_elasticstack, -> { joins(:application_elastic_stack).merge(::Clusters::Applications::ElasticStack.available) } scope :with_available_cilium, -> { joins(:application_cilium).merge(::Clusters::Applications::Cilium.available) } scope :distinct_with_deployed_environments, -> { joins(:environments).merge(::Deployment.success).distinct } - scope :preload_elasticstack, -> { preload(:integration_elastic_stack) } - scope :preload_environments, -> { preload(:environments) } scope :managed, -> { where(managed: true) } scope :with_persisted_applications, -> { eager_load(*APPLICATIONS_ASSOCIATIONS) } @@ -150,9 +148,7 @@ module Clusters scope :for_project_namespace, -> (namespace_id) { joins(:projects).where(projects: { namespace_id: namespace_id }) } scope :with_name, -> (name) { where(name: name) } - # with_application_prometheus scope is deprecated, and scheduled for removal - # in %14.0. See https://gitlab.com/groups/gitlab-org/-/epics/4280 - scope :with_application_prometheus, -> { includes(:application_prometheus).joins(:application_prometheus) } + scope :with_integration_prometheus, -> { includes(:integration_prometheus).joins(:integration_prometheus) } scope :with_project_http_integrations, -> (project_ids) do conditions = { projects: :alert_management_http_integrations } includes(conditions).joins(conditions).where(projects: { id: project_ids }) @@ -311,7 +307,7 @@ module Clusters end def kubeclient - platform_kubernetes.kubeclient if kubernetes? + platform_kubernetes&.kubeclient if kubernetes? end def elastic_stack_adapter diff --git a/app/models/clusters/integrations/prometheus.rb b/app/models/clusters/integrations/prometheus.rb index d745a49afc1..8b21fa351a3 100644 --- a/app/models/clusters/integrations/prometheus.rb +++ b/app/models/clusters/integrations/prometheus.rb @@ -14,6 +14,13 @@ module Clusters validates :cluster, presence: true validates :enabled, inclusion: { in: [true, false] } + # Periodically checked and kept up to date for Monitor demo projects + enum health_status: { + unknown: 0, + healthy: 1, + unhealthy: 2 + } + attr_encrypted :alert_manager_token, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_32, diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 43427e2ebc7..d75f7984e2c 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -48,7 +48,7 @@ class CommitStatus < Ci::ApplicationRecord scope :ordered, -> { order(:name) } scope :ordered_by_stage, -> { order(stage_idx: :asc) } scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) } - scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) } + scope :retried_ordered, -> { retried.order(name: :asc, id: :desc).includes(project: :namespace) } scope :ordered_by_pipeline, -> { order(pipeline_id: :asc) } scope :before_stage, -> (index) { where('stage_idx < ?', index) } scope :for_stage, -> (index) { where(stage_idx: index) } diff --git a/app/models/concerns/alert_event_lifecycle.rb b/app/models/concerns/alert_event_lifecycle.rb index 4d2b717ead2..72fe7757b44 100644 --- a/app/models/concerns/alert_event_lifecycle.rb +++ b/app/models/concerns/alert_event_lifecycle.rb @@ -41,8 +41,6 @@ module AlertEventLifecycle scope :firing, -> { where(status: status_value_for(:firing)) } scope :resolved, -> { where(status: status_value_for(:resolved)) } - scope :count_by_project_id, -> { group(:project_id).count } - def self.status_value_for(name) state_machines[:status].states[name].value end diff --git a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb index 7462e1e828b..324e0fb57cb 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb @@ -5,6 +5,23 @@ module Analytics module StageEventModel extend ActiveSupport::Concern + included do + scope :by_stage_event_hash_id, ->(id) { where(stage_event_hash_id: id) } + scope :by_project_id, ->(id) { where(project_id: id) } + scope :by_group_id, ->(id) { where(group_id: id) } + scope :end_event_timestamp_after, -> (date) { where(arel_table[:end_event_timestamp].gteq(date)) } + scope :end_event_timestamp_before, -> (date) { where(arel_table[:end_event_timestamp].lteq(date)) } + scope :start_event_timestamp_after, -> (date) { where(arel_table[:start_event_timestamp].gteq(date)) } + scope :start_event_timestamp_before, -> (date) { where(arel_table[:start_event_timestamp].lteq(date)) } + scope :authored, ->(user) { where(author_id: user) } + scope :with_milestone_id, ->(milestone_id) { where(milestone_id: milestone_id) } + scope :end_event_is_not_happened_yet, -> { where(end_event_timestamp: nil) } + end + + def issuable_id + attributes[self.class.issuable_id_column.to_s] + end + class_methods do def upsert_data(data) upsert_values = data.map do |row| @@ -13,8 +30,9 @@ module Analytics :issuable_id, :group_id, :project_id, - :author_id, :milestone_id, + :author_id, + :state_id, :start_event_timestamp, :end_event_timestamp ) @@ -31,6 +49,7 @@ module Analytics project_id, milestone_id, author_id, + state_id, start_event_timestamp, end_event_timestamp ) @@ -39,10 +58,11 @@ module Analytics DO UPDATE SET group_id = excluded.group_id, project_id = excluded.project_id, - start_event_timestamp = excluded.start_event_timestamp, - end_event_timestamp = excluded.end_event_timestamp, milestone_id = excluded.milestone_id, - author_id = excluded.author_id + author_id = excluded.author_id, + state_id = excluded.state_id, + start_event_timestamp = excluded.start_event_timestamp, + end_event_timestamp = excluded.end_event_timestamp SQL result = connection.execute(query) diff --git a/app/models/concerns/cascading_namespace_setting_attribute.rb b/app/models/concerns/cascading_namespace_setting_attribute.rb index e58e5ddc966..731729a1ed5 100644 --- a/app/models/concerns/cascading_namespace_setting_attribute.rb +++ b/app/models/concerns/cascading_namespace_setting_attribute.rb @@ -127,7 +127,7 @@ module CascadingNamespaceSettingAttribute end def alias_boolean(attribute) - return unless Gitlab::Database.main.exists? && type_for_attribute(attribute).type == :boolean + return unless database.exists? && type_for_attribute(attribute).type == :boolean alias_method :"#{attribute}?", attribute end @@ -176,10 +176,10 @@ module CascadingNamespaceSettingAttribute private def locked_value(attribute) + return application_setting_value(attribute) if locked_by_application_setting?(attribute) + ancestor = locked_ancestor(attribute) return ancestor.read_attribute(attribute) if ancestor - - Gitlab::CurrentSettings.public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend end def locked_ancestor(attribute) diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb index 27a704c1de0..a9589cea5e9 100644 --- a/app/models/concerns/ci/contextable.rb +++ b/app/models/concerns/ci/contextable.rb @@ -10,11 +10,14 @@ module Ci # Variables in the environment name scope. # def scoped_variables(environment: expanded_environment_name, dependencies: true) - Gitlab::Ci::Variables::Collection.new.tap do |variables| - variables.concat(predefined_variables) + track_duration do + variables = pipeline.variables_builder.scoped_variables(self, environment: environment, dependencies: dependencies) + + variables.concat(predefined_variables) unless pipeline.predefined_vars_in_builder_enabled? variables.concat(project.predefined_variables) variables.concat(pipeline.predefined_variables) variables.concat(runner.predefined_variables) if runnable? && runner + variables.concat(kubernetes_variables) variables.concat(deployment_variables(environment: environment)) variables.concat(yaml_variables) variables.concat(user_variables) @@ -25,9 +28,23 @@ module Ci variables.concat(trigger_request.user_variables) if trigger_request variables.concat(pipeline.variables) variables.concat(pipeline.pipeline_schedule.job_variables) if pipeline.pipeline_schedule + + variables end end + def track_duration + start_time = ::Gitlab::Metrics::System.monotonic_time + result = yield + duration = ::Gitlab::Metrics::System.monotonic_time - start_time + + ::Gitlab::Ci::Pipeline::Metrics + .pipeline_builder_scoped_variables_histogram + .observe({}, duration.seconds) + + result + end + ## # Variables that do not depend on the environment name. # @@ -72,6 +89,18 @@ module Ci end end + def kubernetes_variables + ::Gitlab::Ci::Variables::Collection.new.tap do |collection| + # Should get merged with the cluster kubeconfig in deployment_variables, see + # https://gitlab.com/gitlab-org/gitlab/-/issues/335089 + template = ::Ci::GenerateKubeconfigService.new(self).execute + + if template.valid? + collection.append(key: 'KUBECONFIG', value: template.to_yaml, public: false, file: true) + end + end + end + def deployment_variables(environment:) return [] unless environment diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb index 8d715279da8..ccaccec3b6b 100644 --- a/app/models/concerns/ci/has_status.rb +++ b/app/models/concerns/ci/has_status.rb @@ -14,21 +14,8 @@ module Ci PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze EXCLUDE_IGNORED_STATUSES = %w[manual failed canceled].to_set.freeze STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3, - failed: 4, canceled: 5, skipped: 6, manual: 7, - scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze - STATUSES_DESCRIPTION = { - created: 'Pipeline has been created', - waiting_for_resource: 'A resource (for example, a runner) that the pipeline requires to run is unavailable', - preparing: 'Pipeline is preparing to run', - pending: 'Pipeline has not started running yet', - running: 'Pipeline is running', - failed: 'At least one stage of the pipeline failed', - success: 'Pipeline completed successfully', - canceled: 'Pipeline was canceled before completion', - skipped: 'Pipeline was skipped', - manual: 'Pipeline needs to be manually started', - scheduled: 'Pipeline is scheduled to run' - }.freeze + failed: 4, canceled: 5, skipped: 6, manual: 7, + scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze UnknownStatusError = Class.new(StandardError) diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index 344f5aa4cd5..611b27c722b 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -20,7 +20,8 @@ module Ci delegate :interruptible, to: :metadata, prefix: false, allow_nil: true delegate :has_exposed_artifacts?, to: :metadata, prefix: false, allow_nil: true delegate :environment_auto_stop_in, to: :metadata, prefix: false, allow_nil: true - delegate :runner_features, to: :metadata, prefix: false, allow_nil: false + delegate :set_cancel_gracefully, to: :metadata, prefix: false, allow_nil: false + delegate :cancel_gracefully?, to: :metadata, prefix: false, allow_nil: false before_create :ensure_metadata end diff --git a/app/models/concerns/clusters/agents/authorization_config_scopes.rb b/app/models/concerns/clusters/agents/authorization_config_scopes.rb new file mode 100644 index 00000000000..0a0406c3389 --- /dev/null +++ b/app/models/concerns/clusters/agents/authorization_config_scopes.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Clusters + module Agents + module AuthorizationConfigScopes + extend ActiveSupport::Concern + + included do + scope :with_available_ci_access_fields, ->(project) { + where("config->'access_as' IS NULL") + .or(where("config->'access_as' = '{}'")) + .or(where("config->'access_as' ?| array[:fields]", fields: available_ci_access_fields(project))) + } + end + + class_methods do + def available_ci_access_fields(_project) + %w(agent) + end + end + end + end +end + +Clusters::Agents::AuthorizationConfigScopes.prepend_mod diff --git a/app/models/concerns/database_reflection.rb b/app/models/concerns/database_reflection.rb new file mode 100644 index 00000000000..1842f5bf4ec --- /dev/null +++ b/app/models/concerns/database_reflection.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# A module that makes it easier/less verbose to reflect upon a database +# connection. +# +# Using this module you can write this: +# +# User.database.database_name +# +# Instead of this: +# +# Gitlab::Database::Reflection.new(User).database_name +module DatabaseReflection + extend ActiveSupport::Concern + + class_methods do + def database + @database_reflection ||= ::Gitlab::Database::Reflection.new(self) + end + end +end diff --git a/app/models/concerns/enums/vulnerability.rb b/app/models/concerns/enums/vulnerability.rb index 749d1ad65cd..4b325de61bc 100644 --- a/app/models/concerns/enums/vulnerability.rb +++ b/app/models/concerns/enums/vulnerability.rb @@ -37,6 +37,15 @@ module Enums security_audit: 4 }.with_indifferent_access.freeze + # keep the order of the values in the state enum, it is used in state_order method to properly order vulnerabilities based on state + # remember to recreate index_vulnerabilities_on_state_case_id index when you update or extend this enum + VULNERABILITY_STATES = { + detected: 1, + confirmed: 4, + resolved: 3, + dismissed: 2 + }.with_indifferent_access.freeze + def self.confidence_levels CONFIDENCE_LEVELS end @@ -52,6 +61,10 @@ module Enums def self.detection_methods DETECTION_METHODS end + + def self.vulnerability_states + VULNERABILITY_STATES + end end end diff --git a/app/models/concerns/file_store_mounter.rb b/app/models/concerns/file_store_mounter.rb index 9d4463e5297..bfcf8a1e7b9 100644 --- a/app/models/concerns/file_store_mounter.rb +++ b/app/models/concerns/file_store_mounter.rb @@ -7,15 +7,13 @@ module FileStoreMounter def mount_file_store_uploader(uploader) mount_uploader(:file, uploader) + # This hook is a no-op when the file is uploaded after_commit after_save :update_file_store, if: :saved_change_to_file? end end - private - def update_file_store - # The file.object_store is set during `uploader.store!` - # which happens after object is inserted/updated - self.update_column(:file_store, file.object_store) + # The file.object_store is set during `uploader.store!` and `uploader.migrate!` + update_column(:file_store, file.object_store) end end diff --git a/app/models/concerns/has_integrations.rb b/app/models/concerns/has_integrations.rb deleted file mode 100644 index 76e03d68600..00000000000 --- a/app/models/concerns/has_integrations.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module HasIntegrations - extend ActiveSupport::Concern - - class_methods do - def without_integration(integration) - integrations = Integration - .select('1') - .where("#{Integration.table_name}.project_id = projects.id") - .where(type: integration.type) - - Project - .where('NOT EXISTS (?)', integrations) - .where(pending_delete: false) - .where(archived: false) - end - end -end diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb index 4b4f9c0df84..28ee54afaa9 100644 --- a/app/models/concerns/has_user_type.rb +++ b/app/models/concerns/has_user_type.rb @@ -28,6 +28,7 @@ module HasUserType scope :non_internal, -> { humans.or(where(user_type: NON_INTERNAL_USER_TYPES)) } scope :without_ghosts, -> { humans.or(where.not(user_type: :ghost)) } scope :without_project_bot, -> { humans.or(where.not(user_type: :project_bot)) } + scope :human_or_service_user, -> { humans.or(where(user_type: :service_user)) } enum user_type: USER_TYPES diff --git a/app/models/concerns/integrations/push_data_validations.rb b/app/models/concerns/integrations/push_data_validations.rb new file mode 100644 index 00000000000..966fc94e289 --- /dev/null +++ b/app/models/concerns/integrations/push_data_validations.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# This concern is used by registered integrations such as Integrations::TeamCity and +# Integrations::DroneCi and adds methods to perform validations on the received +# data. +module Integrations + module PushDataValidations + extend ActiveSupport::Concern + + def merge_request_valid?(data) + data.dig(:object_attributes, :state) == 'opened' && merge_request_unchecked?(data) + end + + def push_valid?(data) + data[:total_commits_count] > 0 && + !branch_removed?(data) && + # prefer merge request trigger over push to avoid double builds + !opened_merge_requests?(data) + end + + def tag_push_valid?(data) + data[:total_commits_count] > 0 && !branch_removed?(data) + end + + private + + def branch_removed?(data) + Gitlab::Git.blank_ref?(data[:after]) + end + + def opened_merge_requests?(data) + project.merge_requests + .opened + .from_project(project) + .from_source_branches(Gitlab::Git.ref_name(data[:ref])) + .exists? + end + + def merge_request_unchecked?(data) + MergeRequest.state_machines[:merge_status] + .check_state?(data.dig(:object_attributes, :merge_status)) + end + end +end diff --git a/app/models/concerns/integrations/reactively_cached.rb b/app/models/concerns/integrations/reactively_cached.rb new file mode 100644 index 00000000000..62eff06c8e2 --- /dev/null +++ b/app/models/concerns/integrations/reactively_cached.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Integrations + module ReactivelyCached + extend ActiveSupport::Concern + + included do + include ::ReactiveCaching + + # Default cache key: class name + project_id + self.reactive_cache_key = ->(integration) { [integration.class.model_name.singular, integration.project_id] } + self.reactive_cache_work_type = :external_dependency + end + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 5c307158a9a..4273eb331a1 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -92,7 +92,6 @@ module Issuable scope :recent, -> { reorder(id: :desc) } scope :of_projects, ->(ids) { where(project_id: ids) } scope :opened, -> { with_state(:opened) } - scope :only_opened, -> { with_state(:opened) } scope :closed, -> { with_state(:closed) } # rubocop:disable GitlabSecurity/SqlInjection diff --git a/app/models/concerns/legacy_bulk_insert.rb b/app/models/concerns/legacy_bulk_insert.rb new file mode 100644 index 00000000000..1249dfb70cd --- /dev/null +++ b/app/models/concerns/legacy_bulk_insert.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module LegacyBulkInsert + extend ActiveSupport::Concern + + class_methods do + # Bulk inserts a number of rows into a table, optionally returning their + # IDs. + # + # This method is deprecated, and you should use the BulkInsertSafe module + # instead. + # + # table - The name of the table to insert the rows into. + # rows - An Array of Hash instances, each mapping the columns to their + # values. + # return_ids - When set to true the return value will be an Array of IDs of + # the inserted rows + # disable_quote - A key or an Array of keys to exclude from quoting (You + # become responsible for protection from SQL injection for + # these keys!) + # on_conflict - Defines an upsert. Values can be: :disabled (default) or + # :do_nothing + def legacy_bulk_insert(table, rows, return_ids: false, disable_quote: [], on_conflict: nil) + return if rows.empty? + + keys = rows.first.keys + columns = keys.map { |key| connection.quote_column_name(key) } + + disable_quote = Array(disable_quote).to_set + tuples = rows.map do |row| + keys.map do |k| + disable_quote.include?(k) ? row[k] : connection.quote(row[k]) + end + end + + sql = <<-EOF + INSERT INTO #{table} (#{columns.join(', ')}) + VALUES #{tuples.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')} + EOF + + sql = "#{sql} ON CONFLICT DO NOTHING" if on_conflict == :do_nothing + + sql = "#{sql} RETURNING id" if return_ids + + result = connection.execute(sql) + + if return_ids + result.values.map { |tuple| tuple[0].to_i } + else + [] + end + end + end +end diff --git a/app/models/concerns/loaded_in_group_list.rb b/app/models/concerns/loaded_in_group_list.rb index 848ef63f1c2..98f6ad58434 100644 --- a/app/models/concerns/loaded_in_group_list.rb +++ b/app/models/concerns/loaded_in_group_list.rb @@ -41,9 +41,11 @@ module LoadedInGroupList namespaces = Namespace.arel_table children = namespaces.alias('children') + # TODO 6473: remove the filtering of the Namespaces::ProjectNamespace see https://gitlab.com/groups/gitlab-org/-/epics/6473 namespaces.project(Arel.star.count.as('preloaded_subgroup_count')) .from(children) .where(children[:parent_id].eq(namespaces[:id])) + .where(children[:type].is_distinct_from(Namespaces::ProjectNamespace.sti_name)) end def member_count_sql diff --git a/app/models/concerns/loose_foreign_key.rb b/app/models/concerns/loose_foreign_key.rb index 4e822a04869..102292672b3 100644 --- a/app/models/concerns/loose_foreign_key.rb +++ b/app/models/concerns/loose_foreign_key.rb @@ -7,20 +7,18 @@ module LooseForeignKey # Loose foreign keys allow delayed processing of associated database records # with similar guarantees than a database foreign key. # - # TODO: finalize this later once the async job is in place - # # Prerequisites: # # To start using the concern, you'll need to install a database trigger to the parent # table in a standard DB migration (not post-migration). # - # > add_loose_foreign_key_support(:projects, :gitlab_main) + # > track_record_deletions(:projects) # # Usage: # # > class Ci::Build < ApplicationRecord # > - # > loose_foreign_key :security_scans, :build_id, on_delete: :async_delete, gitlab_schema: :gitlab_main + # > loose_foreign_key :security_scans, :build_id, on_delete: :async_delete # > # > # associations can be still defined, the dependent options is no longer necessary: # > has_many :security_scans, class_name: 'Security::Scan' @@ -32,14 +30,6 @@ module LooseForeignKey # - :async_delete - deletes the children rows via an asynchronous process. # - :async_nullify - sets the foreign key column to null via an asynchronous process. # - # Options for gitlab_schema: - # - # - :gitlab_ci - # - :gitlab_main - # - # The value can be determined by calling `Model.gitlab_schema` where the Model represents - # the model for the child table. - # # How it works: # # When adding loose foreign key support to the table, a DELETE trigger is installed @@ -69,23 +59,17 @@ module LooseForeignKey end on_delete_options = %i[async_delete async_nullify] - gitlab_schema_options = [ApplicationRecord.gitlab_schema, Ci::ApplicationRecord.gitlab_schema] unless on_delete_options.include?(symbolized_options[:on_delete]&.to_sym) raise "Invalid on_delete option given: #{symbolized_options[:on_delete]}. Valid options: #{on_delete_options.join(', ')}" end - unless gitlab_schema_options.include?(symbolized_options[:gitlab_schema]&.to_sym) - raise "Invalid gitlab_schema option given: #{symbolized_options[:gitlab_schema]}. Valid options: #{gitlab_schema_options.join(', ')}" - end - definition = ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new( table_name.to_s, to_table.to_s, { column: column.to_s, - on_delete: symbolized_options[:on_delete].to_sym, - gitlab_schema: symbolized_options[:gitlab_schema].to_sym + on_delete: symbolized_options[:on_delete].to_sym } ) diff --git a/app/models/concerns/merge_request_reviewer_state.rb b/app/models/concerns/merge_request_reviewer_state.rb new file mode 100644 index 00000000000..216a3a0bd64 --- /dev/null +++ b/app/models/concerns/merge_request_reviewer_state.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module MergeRequestReviewerState + extend ActiveSupport::Concern + + included do + enum state: { + unreviewed: 0, + reviewed: 1, + attention_requested: 2 + } + + validates :state, + presence: true, + inclusion: { in: self.states.keys } + + after_initialize :set_state, unless: :persisted? + + def set_state + if Feature.enabled?(:mr_attention_requests, self.merge_request&.project, default_enabled: :yaml) + self.state = :attention_requested + end + end + end +end diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb index c4f810ab9b1..12041b103f6 100644 --- a/app/models/concerns/milestoneable.rb +++ b/app/models/concerns/milestoneable.rb @@ -14,13 +14,12 @@ module Milestoneable validate :milestone_is_valid - scope :of_milestones, ->(ids) { where(milestone_id: ids) } scope :any_milestone, -> { where.not(milestone_id: nil) } scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } scope :without_particular_milestone, ->(title) { left_outer_joins(:milestone).where("milestones.title != ? OR milestone_id IS NULL", title) } scope :any_release, -> { joins_milestone_releases } scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) } - scope :without_particular_release, -> (tag, project_id) { joins_milestone_releases.where.not( milestones: { releases: { tag: tag, project_id: project_id } } ) } + scope :without_particular_release, -> (tag, project_id) { joins_milestone_releases.where.not(milestones: { releases: { tag: tag, project_id: project_id } }) } scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") } scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) } diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index f6d4e5bd27b..ea4fe5b27dc 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -98,6 +98,27 @@ module Noteable .order('MIN(created_at), MIN(id)') end + # This does not consider OutOfContextDiscussions in MRs + # where notes from commits are overriden so that they have + # the same discussion_id + def discussion_root_note_ids(notes_filter:) + relations = [] + + relations << discussion_notes.select( + "'notes' AS table_name", + 'discussion_id', + 'MIN(id) AS id', + 'MIN(created_at) AS created_at' + ).with_notes_filter(notes_filter) + .group(:discussion_id) + + if notes_filter != UserPreference::NOTES_FILTERS[:only_comments] + relations += synthetic_note_ids_relations + end + + Note.from_union(relations, remove_duplicates: false).fresh + end + def capped_notes_count(max) notes.limit(max).count end @@ -179,6 +200,18 @@ module Noteable project_email.sub('@', "-#{iid}@") end + + private + + # Synthetic system notes don't have discussion IDs because these are generated dynamically + # in Ruby. These are always root notes anyway so we don't need to group by discussion ID. + def synthetic_note_ids_relations + [ + resource_label_events.select("'resource_label_events'", "'NULL'", :id, :created_at), + resource_milestone_events.select("'resource_milestone_events'", "'NULL'", :id, :created_at), + resource_state_events.select("'resource_state_events'", "'NULL'", :id, :created_at) + ] + end end Noteable.extend(Noteable::ClassMethods) diff --git a/app/models/concerns/reactive_service.rb b/app/models/concerns/reactive_service.rb deleted file mode 100644 index c444f238944..00000000000 --- a/app/models/concerns/reactive_service.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module ReactiveService - extend ActiveSupport::Concern - - included do - include ReactiveCaching - - # Default cache key: class name + project_id - self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] } - self.reactive_cache_work_type = :external_dependency - end -end diff --git a/app/models/concerns/security/latest_pipeline_information.rb b/app/models/concerns/security/latest_pipeline_information.rb new file mode 100644 index 00000000000..87eae3cac68 --- /dev/null +++ b/app/models/concerns/security/latest_pipeline_information.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Security + module LatestPipelineInformation + private + + def scanner_enabled?(scan_type) + latest_builds_reports.include?(scan_type) + end + + def latest_builds_reports(only_successful_builds: false) + strong_memoize("latest_builds_reports_#{only_successful_builds}") do + builds = latest_security_builds + builds = builds.select { |build| build.status == 'success' } if only_successful_builds + builds.flat_map do |build| + build.options[:artifacts][:reports].keys + end + end + end + + def latest_security_builds + return [] unless latest_default_branch_pipeline + + ::Security::SecurityJobsFinder.new(pipeline: latest_default_branch_pipeline).execute + + ::Security::LicenseComplianceJobsFinder.new(pipeline: latest_default_branch_pipeline).execute + end + + def latest_default_branch_pipeline + strong_memoize(:pipeline) { latest_pipeline } + end + + def auto_devops_source? + latest_default_branch_pipeline&.auto_devops_source? + end + end +end diff --git a/app/models/concerns/service_push_data_validations.rb b/app/models/concerns/service_push_data_validations.rb deleted file mode 100644 index 451804a2c56..00000000000 --- a/app/models/concerns/service_push_data_validations.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -# This concern is used by registered integrations such as Integrations::TeamCity and -# Integrations::DroneCi and adds methods to perform validations on the received -# data. - -module ServicePushDataValidations - extend ActiveSupport::Concern - - def merge_request_valid?(data) - data.dig(:object_attributes, :state) == 'opened' && merge_request_unchecked?(data) - end - - def push_valid?(data) - data[:total_commits_count] > 0 && - !branch_removed?(data) && - # prefer merge request trigger over push to avoid double builds - !opened_merge_requests?(data) - end - - def tag_push_valid?(data) - data[:total_commits_count] > 0 && !branch_removed?(data) - end - - private - - def branch_removed?(data) - Gitlab::Git.blank_ref?(data[:after]) - end - - def opened_merge_requests?(data) - project.merge_requests - .opened - .from_project(project) - .from_source_branches(Gitlab::Git.ref_name(data[:ref])) - .exists? - end - - def merge_request_unchecked?(data) - MergeRequest.state_machines[:merge_status] - .check_state?(data.dig(:object_attributes, :merge_status)) - end -end diff --git a/app/models/concerns/sha256_attribute.rb b/app/models/concerns/sha256_attribute.rb index 17fda6c806c..3c906642b1a 100644 --- a/app/models/concerns/sha256_attribute.rb +++ b/app/models/concerns/sha256_attribute.rb @@ -39,7 +39,7 @@ module Sha256Attribute end def database_exists? - Gitlab::Database.main.exists? + database.exists? end end end diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb index 27277bc5296..ba7c6c0cd8b 100644 --- a/app/models/concerns/sha_attribute.rb +++ b/app/models/concerns/sha_attribute.rb @@ -32,7 +32,7 @@ module ShaAttribute end def database_exists? - Gitlab::Database.main.exists? + database.exists? end end end diff --git a/app/models/concerns/strip_attribute.rb b/app/models/concerns/strip_attribute.rb index 1c433a3275e..817a4465f91 100644 --- a/app/models/concerns/strip_attribute.rb +++ b/app/models/concerns/strip_attribute.rb @@ -2,7 +2,8 @@ # == Strip Attribute module # -# Contains functionality to clean attributes before validation +# Contains functionality to remove leading and trailing +# whitespace from the attribute before validation # # Usage: # diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb index 79cbe225e5a..3fe9d7f4d71 100644 --- a/app/models/concerns/timebox.rb +++ b/app/models/concerns/timebox.rb @@ -11,9 +11,7 @@ module Timebox include StripAttribute include FromUnion - TimeboxStruct = Struct.new(:title, :name, :id) do - include GlobalID::Identification - + TimeboxStruct = Struct.new(:title, :name, :id, :class_name) do # Ensure these models match the interface required for exporting def serializable_hash(_opts = {}) { title: title, name: name, id: id } @@ -22,6 +20,10 @@ module Timebox def self.declarative_policy_class "TimeboxPolicy" end + + def to_global_id + ::Gitlab::GlobalId.build(self, model_name: class_name, id: id) + end end # Represents a "No Timebox" state used for filtering Issues and Merge @@ -33,10 +35,10 @@ module Timebox included do # Defines the same constants above, but inside the including class. - const_set :None, TimeboxStruct.new("No #{self.name}", "No #{self.name}", 0) - const_set :Any, TimeboxStruct.new("Any #{self.name}", '', -1) - const_set :Upcoming, TimeboxStruct.new('Upcoming', '#upcoming', -2) - const_set :Started, TimeboxStruct.new('Started', '#started', -3) + const_set :None, TimeboxStruct.new("No #{self.name}", "No #{self.name}", 0, self.name) + const_set :Any, TimeboxStruct.new("Any #{self.name}", '', -1, self.name) + const_set :Upcoming, TimeboxStruct.new('Upcoming', '#upcoming', -2, self.name) + const_set :Started, TimeboxStruct.new('Started', '#started', -3, self.name) alias_method :timebox_id, :id diff --git a/app/models/concerns/transactions.rb b/app/models/concerns/transactions.rb new file mode 100644 index 00000000000..a186ebc8475 --- /dev/null +++ b/app/models/concerns/transactions.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Transactions + extend ActiveSupport::Concern + + class_methods do + # inside_transaction? will return true if the caller is running within a + # transaction. Handles special cases when running inside a test environment, + # where tests may be wrapped in transactions + def inside_transaction? + base = Rails.env.test? ? @open_transactions_baseline.to_i : 0 + + connection.open_transactions > base + end + + # These methods that access @open_transactions_baseline are not thread-safe. + # These are fine though because we only call these in RSpec's main thread. + # If we decide to run specs multi-threaded, we would need to use something + # like ThreadGroup to keep track of this value + def set_open_transactions_baseline + @open_transactions_baseline = connection.open_transactions + end + + def reset_open_transactions_baseline + @open_transactions_baseline = 0 + end + end +end diff --git a/app/models/concerns/ttl_expirable.rb b/app/models/concerns/ttl_expirable.rb index 00abe0a06e6..6d89521255c 100644 --- a/app/models/concerns/ttl_expirable.rb +++ b/app/models/concerns/ttl_expirable.rb @@ -5,10 +5,11 @@ module TtlExpirable included do validates :status, presence: true + default_value_for :read_at, Time.zone.now enum status: { default: 0, expired: 1, processing: 2, error: 3 } - scope :updated_before, ->(number_of_days) { where("updated_at <= ?", Time.zone.now - number_of_days.days) } + scope :read_before, ->(number_of_days) { where("read_at <= ?", Time.zone.now - number_of_days.days) } scope :active, -> { where(status: :default) } scope :lock_next_by, ->(sort) do @@ -17,4 +18,8 @@ module TtlExpirable .lock('FOR UPDATE SKIP LOCKED') end end + + def read! + self.update(read_at: Time.zone.now) + end end diff --git a/app/models/concerns/update_highest_role.rb b/app/models/concerns/update_highest_role.rb index 6432cc794a5..2b0ec5c7e21 100644 --- a/app/models/concerns/update_highest_role.rb +++ b/app/models/concerns/update_highest_role.rb @@ -15,7 +15,7 @@ module UpdateHighestRole # Schedule a Sidekiq job to update the highest role for a User # # The job will be called outside of a transaction in order to ensure the changes - # to be commited before attempting to update the highest role. + # to be committed before attempting to update the highest role. # The exlusive lease will not be released after completion to prevent multiple jobs # being executed during the defined timeout. def update_highest_role diff --git a/app/models/concerns/x509_serial_number_attribute.rb b/app/models/concerns/x509_serial_number_attribute.rb index dfb1e151b41..e51ed95bf70 100644 --- a/app/models/concerns/x509_serial_number_attribute.rb +++ b/app/models/concerns/x509_serial_number_attribute.rb @@ -39,7 +39,7 @@ module X509SerialNumberAttribute end def database_exists? - Gitlab::Database.main.exists? + database.exists? end end end diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index ecdac64b31b..173b38b2c63 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -31,7 +31,7 @@ class CustomEmoji < ApplicationRecord private def valid_emoji_name - if Gitlab::Emoji.emoji_exists?(name) + if TanukiEmoji.find_by_alpha_code(name) errors.add(:name, _('%{name} is already being used for another emoji') % { name: self.name }) end end diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb index c632f8e2efa..5898bc3412f 100644 --- a/app/models/customer_relations/contact.rb +++ b/app/models/customer_relations/contact.rb @@ -7,7 +7,8 @@ class CustomerRelations::Contact < ApplicationRecord belongs_to :group, -> { where(type: Group.sti_name) }, foreign_key: 'group_id' belongs_to :organization, optional: true - has_and_belongs_to_many :issues, join_table: :issue_customer_relations_contacts # rubocop: disable Rails/HasAndBelongsToMany + has_many :issue_contacts, inverse_of: :contact + has_many :issues, through: :issue_contacts, inverse_of: :customer_relations_contacts strip_attributes! :phone, :first_name, :last_name diff --git a/app/models/customer_relations/issue_contact.rb b/app/models/customer_relations/issue_contact.rb new file mode 100644 index 00000000000..98faf8d6644 --- /dev/null +++ b/app/models/customer_relations/issue_contact.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class CustomerRelations::IssueContact < ApplicationRecord + self.table_name = "issue_customer_relations_contacts" + + belongs_to :issue, optional: false, inverse_of: :customer_relations_contacts + belongs_to :contact, optional: false, inverse_of: :issue_contacts + + validate :contact_belongs_to_issue_group + + private + + def contact_belongs_to_issue_group + return unless contact&.group_id + return unless issue&.project&.namespace_id + return if contact.group_id == issue.project.namespace_id + + errors.add(:base, _('The contact does not belong to the same group as the issue')) + end +end diff --git a/app/models/data_list.rb b/app/models/data_list.rb index adad8e3013e..e99364b2709 100644 --- a/app/models/data_list.rb +++ b/app/models/data_list.rb @@ -1,22 +1,26 @@ # frozen_string_literal: true class DataList - def initialize(batch, data_fields_hash, klass) + def initialize(batch, data_fields_hash, data_fields_klass) @batch = batch @data_fields_hash = data_fields_hash - @klass = klass + @data_fields_klass = data_fields_klass end def to_array - [klass, columns, values] + [data_fields_klass, columns, values] end private - attr_reader :batch, :data_fields_hash, :klass + attr_reader :batch, :data_fields_hash, :data_fields_klass def columns - data_fields_hash.keys << 'service_id' + data_fields_hash.keys << data_fields_foreign_key + end + + def data_fields_foreign_key + data_fields_klass.reflections['integration'].foreign_key end def values diff --git a/app/models/dependency_proxy/blob.rb b/app/models/dependency_proxy/blob.rb index 7ca15652586..bd5c022e692 100644 --- a/app/models/dependency_proxy/blob.rb +++ b/app/models/dependency_proxy/blob.rb @@ -7,6 +7,8 @@ class DependencyProxy::Blob < ApplicationRecord belongs_to :group + MAX_FILE_SIZE = 5.gigabytes.freeze + validates :group, presence: true validates :file, presence: true validates :file_name, presence: true diff --git a/app/models/dependency_proxy/manifest.rb b/app/models/dependency_proxy/manifest.rb index b83047efe54..64f484942ef 100644 --- a/app/models/dependency_proxy/manifest.rb +++ b/app/models/dependency_proxy/manifest.rb @@ -7,17 +7,19 @@ class DependencyProxy::Manifest < ApplicationRecord belongs_to :group + MAX_FILE_SIZE = 10.megabytes.freeze + DIGEST_HEADER = 'Docker-Content-Digest' + validates :group, presence: true validates :file, presence: true validates :file_name, presence: true validates :digest, presence: true - mount_file_store_uploader DependencyProxy::FileUploader + scope :order_id_desc, -> { reorder(id: :desc) } - def self.find_or_initialize_by_file_name_or_digest(file_name:, digest:) - result = find_by(file_name: file_name) || find_by(digest: digest) - return result if result + mount_file_store_uploader DependencyProxy::FileUploader - new(file_name: file_name, digest: digest) + def self.find_by_file_name_or_digest(file_name:, digest:) + find_by(file_name: file_name) || find_by(digest: digest) end end diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb index 8f5a713af3f..4ed38f578ee 100644 --- a/app/models/deploy_key.rb +++ b/app/models/deploy_key.rb @@ -7,12 +7,16 @@ class DeployKey < Key has_many :deploy_keys_projects, inverse_of: :deploy_key, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :projects, through: :deploy_keys_projects + + has_many :deploy_keys_projects_with_write_access, -> { with_write_access }, class_name: "DeployKeysProject" + has_many :projects_with_write_access, -> { includes(:route) }, class_name: 'Project', through: :deploy_keys_projects_with_write_access, source: :project has_many :protected_branch_push_access_levels, class_name: '::ProtectedBranch::PushAccessLevel' scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where(deploy_keys_projects: { project_id: projects }) } scope :with_write_access, -> { joins(:deploy_keys_projects).merge(DeployKeysProject.with_write_access) } scope :are_public, -> { where(public: true) } scope :with_projects, -> { includes(deploy_keys_projects: { project: [:route, namespace: :route] }) } + scope :including_projects_with_write_access, -> { includes(:projects_with_write_access) } accepts_nested_attributes_for :deploy_keys_projects @@ -52,10 +56,6 @@ class DeployKey < Key end end - def projects_with_write_access - Project.with_route.where(id: deploy_keys_projects.with_write_access.select(:project_id)) - end - def self.with_write_access_for_project(project, deploy_key: nil) query = in_projects(project).with_write_access query = query.where(id: deploy_key) if deploy_key diff --git a/app/models/deployment.rb b/app/models/deployment.rb index f91700f764b..ade19ce02a8 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -8,11 +8,12 @@ class Deployment < ApplicationRecord include Importable include Gitlab::Utils::StrongMemoize include FastDestroyAll - include IgnorableColumns StatusUpdateError = Class.new(StandardError) StatusSyncError = Class.new(StandardError) + ARCHIVABLE_OFFSET = 50_000 + belongs_to :project, required: true belongs_to :environment, required: true belongs_to :cluster, class_name: 'Clusters::Cluster', optional: true @@ -54,6 +55,8 @@ class Deployment < ApplicationRecord scope :finished_after, ->(date) { where('finished_at >= ?', date) } scope :finished_before, ->(date) { where('finished_at < ?', date) } + scope :ordered, -> { order(finished_at: :desc) } + FINISHED_STATUSES = %i[success failed canceled].freeze state_machine :status, initial: :created do @@ -99,6 +102,10 @@ class Deployment < ApplicationRecord deployment.run_after_commit do Deployments::UpdateEnvironmentWorker.perform_async(id) Deployments::LinkMergeRequestWorker.perform_async(id) + + if ::Feature.enabled?(:deployments_archive, deployment.project, default_enabled: :yaml) + Deployments::ArchiveInProjectWorker.perform_async(deployment.project_id) + end end end @@ -132,6 +139,14 @@ class Deployment < ApplicationRecord skipped: 5 } + def self.archivables_in(project, limit:) + start_iid = project.deployments.order(iid: :desc).limit(1) + .select("(iid - #{ARCHIVABLE_OFFSET}) AS start_iid") + + project.deployments.preload(:environment).where('iid <= (?)', start_iid) + .where(archived: false).limit(limit) + end + def self.last_for_environment(environment) ids = self .for_environment(environment) @@ -299,7 +314,7 @@ class Deployment < ApplicationRecord "#{id} as deployment_id", "#{environment_id} as environment_id").to_sql - # We don't use `Gitlab::Database.main.bulk_insert` here so that we don't need to + # We don't use `ApplicationRecord.legacy_bulk_insert` here so that we don't need to # first pluck lots of IDs into memory. # # We also ignore any duplicates so this method can be called multiple times @@ -325,6 +340,7 @@ class Deployment < ApplicationRecord def sync_status_with(build) return false unless ::Deployment.statuses.include?(build.status) + return false if build.created? || build.status == self.status update_status!(build.status) rescue StandardError => e diff --git a/app/models/design_management/version.rb b/app/models/design_management/version.rb index 6cda03557d1..5819404efb9 100644 --- a/app/models/design_management/version.rb +++ b/app/models/design_management/version.rb @@ -88,7 +88,7 @@ module DesignManagement rows = design_actions.map { |action| action.row_attrs(version) } - Gitlab::Database.main.bulk_insert(::DesignManagement::Action.table_name, rows) # rubocop:disable Gitlab/BulkInsert + ApplicationRecord.legacy_bulk_insert(::DesignManagement::Action.table_name, rows) # rubocop:disable Gitlab/BulkInsert version.designs.reset version.validate! design_actions.each(&:performed) diff --git a/app/models/email.rb b/app/models/email.rb index 0140f784842..676e79406e9 100644 --- a/app/models/email.rb +++ b/app/models/email.rb @@ -29,7 +29,7 @@ class Email < ApplicationRecord end def unique_email - self.errors.add(:email, 'has already been taken') if User.exists?(email: self.email) + self.errors.add(:email, 'has already been taken') if primary_email_of_another_user? end def validate_email_format @@ -40,4 +40,14 @@ class Email < ApplicationRecord def update_invalid_gpg_signatures user.update_invalid_gpg_signatures if confirmed? end + + def user_primary_email? + email.casecmp?(user.email) + end + + private + + def primary_email_of_another_user? + User.where(email: email).where.not(id: user_id).exists? + end end diff --git a/app/models/environment.rb b/app/models/environment.rb index 31ab426728b..2618991c9e5 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -425,6 +425,14 @@ class Environment < ApplicationRecord clear_reactive_cache! end + def should_link_to_merge_requests? + unfoldered? || production? || staging? + end + + def unfoldered? + environment_type.nil? + end + private def rollout_status_available? diff --git a/app/models/error_tracking/error.rb b/app/models/error_tracking/error.rb index 2d6a4694def..efbb6adff70 100644 --- a/app/models/error_tracking/error.rb +++ b/app/models/error_tracking/error.rb @@ -18,9 +18,10 @@ class ErrorTracking::Error < ApplicationRecord scope :for_status, -> (status) { where(status: status) } validates :project, presence: true - validates :name, presence: true - validates :description, presence: true - validates :actor, presence: true + validates :name, presence: true, length: { maximum: 255 } + validates :description, presence: true, length: { maximum: 1024 } + validates :actor, presence: true, length: { maximum: 255 } + validates :platform, length: { maximum: 255 } validates :status, presence: true enum status: { diff --git a/app/models/error_tracking/error_event.rb b/app/models/error_tracking/error_event.rb index 686518a39fb..0b638f65768 100644 --- a/app/models/error_tracking/error_event.rb +++ b/app/models/error_tracking/error_event.rb @@ -6,7 +6,9 @@ class ErrorTracking::ErrorEvent < ApplicationRecord validates :payload, json_schema: { filename: 'error_tracking_event_payload' } validates :error, presence: true - validates :description, presence: true + validates :description, presence: true, length: { maximum: 1024 } + validates :level, length: { maximum: 255 } + validates :environment, length: { maximum: 255 } validates :occurred_at, presence: true def stacktrace @@ -61,9 +63,9 @@ class ErrorTracking::ErrorEvent < ApplicationRecord pre_context = entry['pre_context'] post_context = entry['post_context'] - context += lines_with_position(pre_context, error_line_no - pre_context.size) + context += lines_with_position(pre_context, error_line_no - pre_context.size) if pre_context context += lines_with_position([error_line], error_line_no) - context += lines_with_position(post_context, error_line_no + 1) + context += lines_with_position(post_context, error_line_no + 1) if post_context context.reject(&:blank?) end diff --git a/app/models/event.rb b/app/models/event.rb index d6588699d27..f6174589a84 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -9,9 +9,6 @@ class Event < ApplicationRecord include Gitlab::Utils::StrongMemoize include UsageStatistics include ShaAttribute - include IgnorableColumns - - ignore_columns :id_convert_to_bigint, remove_with: '14.5', remove_after: '2021-10-22' default_scope { reorder(nil) } # rubocop:disable Cop/DefaultScope diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb index 0c36e51120f..2775b520b2f 100644 --- a/app/models/gpg_signature.rb +++ b/app/models/gpg_signature.rb @@ -12,7 +12,8 @@ class GpgSignature < ApplicationRecord same_user_different_email: 2, other_user: 3, unverified_key: 4, - unknown_key: 5 + unknown_key: 5, + multiple_signatures: 6 } belongs_to :project diff --git a/app/models/group.rb b/app/models/group.rb index c5e119451e3..2dd20300ad2 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -56,6 +56,9 @@ class Group < Namespace has_many :boards has_many :badges, class_name: 'GroupBadge' + has_many :organizations, class_name: 'CustomerRelations::Organization', inverse_of: :group + has_many :contacts, class_name: 'CustomerRelations::Contact', inverse_of: :group + has_many :cluster_groups, class_name: 'Clusters::Group' has_many :clusters, through: :cluster_groups, class_name: 'Clusters::Cluster' @@ -194,13 +197,8 @@ class Group < Namespace def ids_with_disabled_email(groups) inner_groups = Group.where('id = namespaces_with_emails_disabled.id') - inner_ancestors = if Feature.enabled?(:linear_group_ancestor_scopes, default_enabled: :yaml) - inner_groups.self_and_ancestors - else - Gitlab::ObjectHierarchy.new(inner_groups).base_and_ancestors - end - - inner_query = inner_ancestors + inner_query = inner_groups + .self_and_ancestors .where(emails_disabled: true) .select('1') .limit(1) @@ -317,13 +315,15 @@ class Group < Namespace owners.include?(user) end - def add_users(users, access_level, current_user: nil, expires_at: nil) + def add_users(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil) Members::Groups::BulkCreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass self, users, access_level, current_user: current_user, - expires_at: expires_at + expires_at: expires_at, + tasks_to_be_done: tasks_to_be_done, + tasks_project_id: tasks_project_id ) end @@ -760,18 +760,6 @@ class Group < Namespace Timelog.in_group(self) end - def cached_issues_state_count_enabled? - Feature.enabled?(:cached_issues_state_count, self, default_enabled: :yaml) - end - - def organizations - ::CustomerRelations::Organization.where(group_id: self.id) - end - - def contacts - ::CustomerRelations::Contact.where(group_id: self.id) - end - def dependency_proxy_image_ttl_policy super || build_dependency_proxy_image_ttl_policy end diff --git a/app/models/integration.rb b/app/models/integration.rb index 158764bb783..d3059fa6d4a 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -14,7 +14,7 @@ class Integration < ApplicationRecord asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat irker jira mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email - pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack + pivotaltracker prometheus pushover redmine shimo slack slack_slash_commands teamcity unify_circuit webex_teams youtrack zentao ].freeze PROJECT_SPECIFIC_INTEGRATION_NAMES = %w[ @@ -373,7 +373,7 @@ class Integration < ApplicationRecord end def to_data_fields_hash - data_fields.as_json(only: data_fields.class.column_names).except('id', 'service_id') + data_fields.as_json(only: data_fields.class.column_names).except('id', 'service_id', 'integration_id') end def event_channel_names diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index 1a7cbaa34c7..0774b84b69f 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -3,7 +3,7 @@ module Integrations class Bamboo < BaseCi include ActionView::Helpers::UrlHelper - include ReactiveService + include ReactivelyCached prop_accessor :bamboo_url, :build_key, :username, :password diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb index c6335782b5e..ca72de47d30 100644 --- a/app/models/integrations/base_chat_notification.rb +++ b/app/models/integrations/base_chat_notification.rb @@ -73,7 +73,12 @@ module Integrations { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}", required: true }.freeze, { type: 'text', name: 'username', placeholder: 'GitLab-integration' }.freeze, { type: 'checkbox', name: 'notify_only_broken_pipelines', help: 'Do not send notifications for successful pipelines.' }.freeze, - { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }.freeze, + { + type: 'select', + name: 'branches_to_be_notified', + title: s_('Integrations|Branches for which notifications are to be sent'), + choices: branch_choices + }.freeze, { type: 'text', name: 'labels_to_be_notified', diff --git a/app/models/integrations/buildkite.rb b/app/models/integrations/buildkite.rb index 94a37f0c4f2..9fad3a42647 100644 --- a/app/models/integrations/buildkite.rb +++ b/app/models/integrations/buildkite.rb @@ -5,7 +5,7 @@ require "addressable/uri" module Integrations class Buildkite < BaseCi include HasWebHook - include ReactiveService + include ReactivelyCached extend Gitlab::Utils::Override ENDPOINT = "https://buildkite.com" diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb index 76160a61bc3..21993dd3c43 100644 --- a/app/models/integrations/discord.rb +++ b/app/models/integrations/discord.rb @@ -41,7 +41,12 @@ module Integrations [ { type: "text", name: "webhook", placeholder: "https://discordapp.com/api/webhooks/…", help: "URL to the webhook for the Discord channel." }, { type: "checkbox", name: "notify_only_broken_pipelines" }, - { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } + { + type: 'select', + name: 'branches_to_be_notified', + title: s_('Integrations|Branches for which notifications are to be sent'), + choices: branch_choices + } ] end diff --git a/app/models/integrations/drone_ci.rb b/app/models/integrations/drone_ci.rb index c93ae432fe9..856d14c022d 100644 --- a/app/models/integrations/drone_ci.rb +++ b/app/models/integrations/drone_ci.rb @@ -3,8 +3,8 @@ module Integrations class DroneCi < BaseCi include HasWebHook - include ReactiveService - include ServicePushDataValidations + include PushDataValidations + include ReactivelyCached extend Gitlab::Utils::Override prop_accessor :drone_url, :token diff --git a/app/models/integrations/emails_on_push.rb b/app/models/integrations/emails_on_push.rb index e277633664f..a9cd67550dc 100644 --- a/app/models/integrations/emails_on_push.rb +++ b/app/models/integrations/emails_on_push.rb @@ -76,7 +76,12 @@ module Integrations help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain matches the domain used by your GitLab instance (such as %{domains}).") % { domains: domains } }, { type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"), help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") }, - { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }, + { + type: 'select', + name: 'branches_to_be_notified', + title: s_('Integrations|Branches for which notifications are to be sent'), + choices: branch_choices + }, { type: 'textarea', name: 'recipients', @@ -92,7 +97,7 @@ module Integrations return if recipients.blank? if self.class.valid_recipients(recipients).size > RECIPIENTS_LIMIT - errors.add(:recipients, s_("EmailsOnPushService|can't exceed %{recipients_limit}") % { recipients_limit: RECIPIENTS_LIMIT }) + errors.add(:recipients, s_("Integrations|can't exceed %{recipients_limit}") % { recipients_limit: RECIPIENTS_LIMIT }) end end end diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb index d02cfe4ec56..0d6b9fb1019 100644 --- a/app/models/integrations/hangouts_chat.rb +++ b/app/models/integrations/hangouts_chat.rb @@ -40,7 +40,12 @@ module Integrations [ { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}" }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } + { + type: 'select', + name: 'branches_to_be_notified', + title: s_('Integrations|Branches for which notifications are to be sent'), + choices: branch_choices + } ] end diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index ec6adc87bf4..42c291abf55 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -89,7 +89,6 @@ module Integrations site: URI.join(url, '/').to_s.delete_suffix('/'), # Intended to find the root context_path: (url.path.presence || '/').delete_suffix('/'), auth_type: :basic, - read_timeout: 120, use_cookies: true, additional_cookies: ['OBBasicAuth=fromDialog'], use_ssl: url.scheme == 'https' @@ -303,6 +302,14 @@ module Integrations private + def branch_name(noteable) + if Feature.enabled?(:jira_use_first_ref_by_oid, project, default_enabled: :yaml) + noteable.first_ref_by_oid(project.repository) + else + noteable.ref_names(project.repository).first + end + end + def server_info strong_memoize(:server_info) do client_url.present? ? jira_request { client.ServerInfo.all.attrs } : nil @@ -496,7 +503,7 @@ module Integrations { id: noteable.short_id, description: noteable.safe_message, - branch: noteable.ref_names(project.repository).first + branch: branch_name(noteable) } elsif noteable.is_a?(MergeRequest) { @@ -521,7 +528,9 @@ module Integrations yield rescue StandardError => error @error = error - log_error("Error sending message", client_url: client_url, error: @error.message) + payload = { client_url: client_url } + Gitlab::ExceptionLogFormatter.format!(error, payload) + log_error("Error sending message", payload) nil end diff --git a/app/models/integrations/microsoft_teams.rb b/app/models/integrations/microsoft_teams.rb index 5aad25e8ddc..71cd4ddaf82 100644 --- a/app/models/integrations/microsoft_teams.rb +++ b/app/models/integrations/microsoft_teams.rb @@ -37,7 +37,12 @@ module Integrations [ { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}" }, { type: 'checkbox', name: 'notify_only_broken_pipelines', help: 'If selected, successful pipelines do not trigger a notification event.' }, - { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } + { + type: 'select', + name: 'branches_to_be_notified', + title: s_('Integrations|Branches for which notifications are to be sent'), + choices: branch_choices + } ] end diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb index efba35cc2a8..6dc41958daa 100644 --- a/app/models/integrations/pipelines_email.rb +++ b/app/models/integrations/pipelines_email.rb @@ -4,9 +4,12 @@ module Integrations class PipelinesEmail < Integration include NotificationBranchSelection + RECIPIENTS_LIMIT = 30 + prop_accessor :recipients, :branches_to_be_notified boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch validates :recipients, presence: true, if: :validate_recipients? + validate :number_of_recipients_within_limit, if: :validate_recipients? def initialize_properties if properties.nil? @@ -49,7 +52,7 @@ module Integrations return unless supported_events.include?(data[:object_kind]) return unless force || should_pipeline_be_notified?(data) - all_recipients = retrieve_recipients(data) + all_recipients = retrieve_recipients return unless all_recipients.any? @@ -71,6 +74,7 @@ module Integrations name: 'notify_only_broken_pipelines' }, { type: 'select', name: 'branches_to_be_notified', + title: s_('Integrations|Branches for which notifications are to be sent'), choices: branch_choices } ] end @@ -98,8 +102,18 @@ module Integrations end end - def retrieve_recipients(data) + def retrieve_recipients recipients.to_s.split(/[,\r\n ]+/).reject(&:empty?) end + + private + + def number_of_recipients_within_limit + return if recipients.blank? + + if retrieve_recipients.size > RECIPIENTS_LIMIT + errors.add(:recipients, s_("Integrations|can't exceed %{recipients_limit}") % { recipients_limit: RECIPIENTS_LIMIT }) + end + end end end diff --git a/app/models/integrations/shimo.rb b/app/models/integrations/shimo.rb new file mode 100644 index 00000000000..4f42fda2577 --- /dev/null +++ b/app/models/integrations/shimo.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Integrations + class Shimo < Integration + prop_accessor :external_wiki_url + validates :external_wiki_url, presence: true, public_url: true, if: :activated? + + def render? + valid? && activated? + end + + def title + s_('Shimo|Shimo') + end + + def description + s_('Shimo|Link to a Shimo Workspace from the sidebar.') + end + + def self.to_param + 'shimo' + end + + # support for `test` method + def execute(_data) + response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true, use_read_total_timeout: true) + response.body if response.code == 200 + rescue StandardError + nil + end + + def self.supported_events + %w() + end + + def fields + [ + { + type: 'text', + name: 'external_wiki_url', + title: s_('Shimo|Shimo Workspace URL'), + required: true + } + ] + end + end +end diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb index 3f868b57597..008b591c304 100644 --- a/app/models/integrations/teamcity.rb +++ b/app/models/integrations/teamcity.rb @@ -2,8 +2,8 @@ module Integrations class Teamcity < BaseCi - include ReactiveService - include ServicePushDataValidations + include PushDataValidations + include ReactivelyCached prop_accessor :teamcity_url, :build_type, :username, :password diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb index e3e180ae959..f085423d229 100644 --- a/app/models/integrations/unify_circuit.rb +++ b/app/models/integrations/unify_circuit.rb @@ -34,7 +34,12 @@ module Integrations [ { type: 'text', name: 'webhook', placeholder: "https://yourcircuit.com/rest/v2/webhooks/incoming/…", required: true }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } + { + type: 'select', + name: 'branches_to_be_notified', + title: s_('Integrations|Branches for which notifications are to be sent'), + choices: branch_choices + } ] end diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb index 6fd82a32035..7660eda6f83 100644 --- a/app/models/integrations/webex_teams.rb +++ b/app/models/integrations/webex_teams.rb @@ -36,7 +36,12 @@ module Integrations [ { type: 'text', name: 'webhook', placeholder: "https://api.ciscospark.com/v1/webhooks/incoming/...", required: true }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } + { + type: 'select', + name: 'branches_to_be_notified', + title: s_('Integrations|Branches for which notifications are to be sent'), + choices: branch_choices + } ] end diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb index 68c02f54c61..493d42cc40b 100644 --- a/app/models/integrations/zentao.rb +++ b/app/models/integrations/zentao.rb @@ -2,6 +2,8 @@ module Integrations class Zentao < Integration + include Gitlab::Routing + data_field :url, :api_url, :api_token, :zentao_product_xid validates :url, public_url: true, presence: true, if: :activated? @@ -9,16 +11,29 @@ module Integrations validates :api_token, presence: true, if: :activated? validates :zentao_product_xid, presence: true, if: :activated? + # License Level: EEP_FEATURES + def self.issues_license_available?(project) + project&.licensed_feature_available?(:zentao_issues_integration) + end + def data_fields zentao_tracker_data || self.build_zentao_tracker_data end def title - self.class.name.demodulize + 'ZenTao' end def description - s_("ZentaoIntegration|Use Zentao as this project's issue tracker.") + s_("ZentaoIntegration|Use ZenTao as this project's issue tracker.") + end + + def help + s_("ZentaoIntegration|Before you enable this integration, you must configure ZenTao. For more details, read the %{link_start}ZenTao integration documentation%{link_end}.") % { + link_start: '<a href="%{url}" target="_blank" rel="noopener noreferrer">' + .html_safe % { url: help_page_url('user/project/integrations/zentao') }, + link_end: '</a>'.html_safe + } end def self.to_param @@ -42,28 +57,29 @@ module Integrations { type: 'text', name: 'url', - title: s_('ZentaoIntegration|Zentao Web URL'), + title: s_('ZentaoIntegration|ZenTao Web URL'), placeholder: 'https://www.zentao.net', - help: s_('ZentaoIntegration|Base URL of the Zentao instance.'), + help: s_('ZentaoIntegration|Base URL of the ZenTao instance.'), required: true }, { type: 'text', name: 'api_url', - title: s_('ZentaoIntegration|Zentao API URL (optional)'), + title: s_('ZentaoIntegration|ZenTao API URL (optional)'), help: s_('ZentaoIntegration|If different from Web URL.') }, { type: 'password', name: 'api_token', - title: s_('ZentaoIntegration|Zentao API token'), - non_empty_password_title: s_('ZentaoIntegration|Enter API token'), + title: s_('ZentaoIntegration|ZenTao API token'), + non_empty_password_title: s_('ZentaoIntegration|Enter new ZenTao API token'), + non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'), required: true }, { type: 'text', name: 'zentao_product_xid', - title: s_('ZentaoIntegration|Zentao Product ID'), + title: s_('ZentaoIntegration|ZenTao Product ID'), required: true } ] @@ -76,3 +92,5 @@ module Integrations end end end + +::Integrations::Zentao.prepend_mod diff --git a/app/models/issue.rb b/app/models/issue.rb index 9c568414ec2..47dc084d69c 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -81,7 +81,8 @@ class Issue < ApplicationRecord has_and_belongs_to_many :self_managed_prometheus_alert_events, join_table: :issues_self_managed_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany has_and_belongs_to_many :prometheus_alert_events, join_table: :issues_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany has_many :prometheus_alerts, through: :prometheus_alert_events - has_and_belongs_to_many :customer_relations_contacts, join_table: :issue_customer_relations_contacts, class_name: 'CustomerRelations::Contact' # rubocop: disable Rails/HasAndBelongsToMany + has_many :issue_customer_relations_contacts, class_name: 'CustomerRelations::IssueContact', inverse_of: :issue + has_many :customer_relations_contacts, through: :issue_customer_relations_contacts, source: :contact, class_name: 'CustomerRelations::Contact', inverse_of: :issues accepts_nested_attributes_for :issuable_severity, update_only: true accepts_nested_attributes_for :sentry_issue @@ -203,6 +204,8 @@ class Issue < ApplicationRecord before_transition closed: :opened do |issue| issue.closed_at = nil issue.closed_by = nil + + issue.clear_closure_reason_references end end @@ -378,6 +381,11 @@ class Issue < ApplicationRecord !duplicated_to_id.nil? end + def clear_closure_reason_references + self.moved_to_id = nil + self.duplicated_to_id = nil + end + def can_move?(user, to_project = nil) if to_project return false unless user.can?(:admin_issue, to_project) diff --git a/app/models/key.rb b/app/models/key.rb index 64385953865..a478434538c 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -46,7 +46,7 @@ class Key < ApplicationRecord scope :order_last_used_at_desc, -> { reorder(::Gitlab::Database.nulls_last_order('last_used_at', 'DESC')) } # Date is set specifically in this scope to improve query time. - scope :expired_and_not_notified, -> { where(["date(expires_at AT TIME ZONE 'UTC') BETWEEN '2000-01-01' AND CURRENT_DATE AND expiry_notification_delivered_at IS NULL"]) } + scope :expired_today_and_not_notified, -> { where(["date(expires_at AT TIME ZONE 'UTC') = CURRENT_DATE AND expiry_notification_delivered_at IS NULL"]) } scope :expiring_soon_and_not_notified, -> { where(["date(expires_at AT TIME ZONE 'UTC') > CURRENT_DATE AND date(expires_at AT TIME ZONE 'UTC') < ? AND before_expiry_notification_delivered_at IS NULL", DAYS_TO_EXPIRE.days.from_now.to_date]) } def self.regular_keys diff --git a/app/models/label_link.rb b/app/models/label_link.rb index 4fb5fd8c58a..d326b07ad31 100644 --- a/app/models/label_link.rb +++ b/app/models/label_link.rb @@ -11,4 +11,16 @@ class LabelLink < ApplicationRecord validates :label, presence: true, unless: :importing? scope :for_target, -> (target_id, target_type) { where(target_id: target_id, target_type: target_type) } + + # Example: Issues has at least one label within a project + # > Issue.where(project_id: 100) # or any scope on issues + # > .where(LabelLink.by_target_for_exists_query('Issue', Issue.arel_table[:id]).arel.exists) + scope :by_target_for_exists_query, -> (target_type, arel_join_column, label_ids = nil) do + relation = LabelLink + .where(target_type: target_type) + .where(arel_table['target_id'].eq(arel_join_column)) + + relation = relation.where(label_id: label_ids) if label_ids + relation + end end diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb index ca5a2800a03..c3b3e76f67b 100644 --- a/app/models/loose_foreign_keys/deleted_record.rb +++ b/app/models/loose_foreign_keys/deleted_record.rb @@ -1,5 +1,32 @@ # frozen_string_literal: true class LooseForeignKeys::DeletedRecord < ApplicationRecord - extend SuppressCompositePrimaryKeyWarning + self.primary_key = :id + + scope :for_table, -> (table) { where(fully_qualified_table_name: table) } + scope :consume_order, -> { order(:partition, :consume_after, :id) } + + enum status: { pending: 1, processed: 2 }, _prefix: :status + + def self.load_batch_for_table(table, batch_size) + for_table(table) + .status_pending + .consume_order + .limit(batch_size) + .to_a + end + + def self.mark_records_processed(all_records) + # Run a query for each partition to optimize the row lookup by primary key (partition, id) + update_count = 0 + + all_records.group_by(&:partition).each do |partition, records_within_partition| + update_count += status_pending + .where(partition: partition) + .where(id: records_within_partition.pluck(:id)) + .update_all(status: :processed) + end + + update_count + end end diff --git a/app/models/loose_foreign_keys/modification_tracker.rb b/app/models/loose_foreign_keys/modification_tracker.rb new file mode 100644 index 00000000000..6eb04608cd9 --- /dev/null +++ b/app/models/loose_foreign_keys/modification_tracker.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module LooseForeignKeys + class ModificationTracker + MAX_DELETES = 100_000 + MAX_UPDATES = 50_000 + MAX_RUNTIME = 3.minutes + + delegate :monotonic_time, to: :'Gitlab::Metrics::System' + + def initialize + @delete_count_by_table = Hash.new { |h, k| h[k] = 0 } + @update_count_by_table = Hash.new { |h, k| h[k] = 0 } + @start_time = monotonic_time + @deletes_counter = Gitlab::Metrics.counter( + :loose_foreign_key_deletions, + 'The number of loose foreign key deletions' + ) + @updates_counter = Gitlab::Metrics.counter( + :loose_foreign_key_updates, + 'The number of loose foreign key updates' + ) + end + + def add_deletions(table, count) + @delete_count_by_table[table] += count + @deletes_counter.increment({ table: table }, count) + end + + def add_updates(table, count) + @update_count_by_table[table] += count + @updates_counter.increment({ table: table }, count) + end + + def over_limit? + @delete_count_by_table.values.sum >= MAX_DELETES || + @update_count_by_table.values.sum >= MAX_UPDATES || + monotonic_time - @start_time >= MAX_RUNTIME + end + + def stats + { + over_limit: over_limit?, + delete_count_by_table: @delete_count_by_table, + update_count_by_table: @update_count_by_table, + delete_count: @delete_count_by_table.values.sum, + update_count: @update_count_by_table.values.sum + } + end + end +end diff --git a/app/models/member.rb b/app/models/member.rb index 21fd4aebd7b..11f67a77ee2 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -13,6 +13,7 @@ class Member < ApplicationRecord include FromUnion include UpdateHighestRole include RestrictedSignup + include Gitlab::Experiment::Dsl AVATAR_SIZE = 40 ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10 @@ -22,8 +23,10 @@ class Member < ApplicationRecord belongs_to :created_by, class_name: "User" belongs_to :user belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations + has_one :member_task delegate :name, :username, :email, to: :user, prefix: true + delegate :tasks_to_be_done, to: :member_task, allow_nil: true validates :expires_at, allow_blank: true, future_date: true validates :user, presence: true, unless: :invite? @@ -413,6 +416,14 @@ class Member < ApplicationRecord def after_accept_invite post_create_hook + + if experiment(:invite_members_for_task).enabled? + run_after_commit_or_now do + if member_task + TasksToBeDone::CreateWorker.perform_async(member_task.id, created_by_id, [user_id.to_i]) + end + end + end end def after_decline_invite diff --git a/app/models/members/member_task.rb b/app/models/members/member_task.rb new file mode 100644 index 00000000000..f093619ff36 --- /dev/null +++ b/app/models/members/member_task.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class MemberTask < ApplicationRecord + TASKS = { + code: 0, + ci: 1, + issues: 2 + }.freeze + + belongs_to :member + belongs_to :project + + validates :member, :project, presence: true + validates :tasks, inclusion: { in: TASKS.values } + validate :tasks_uniqueness + validate :project_in_member_source + + scope :for_members, -> (members) { joins(:member).where(member: members) } + + def tasks_to_be_done + Array(self[:tasks]).map { |task| TASKS.key(task) } + end + + def tasks_to_be_done=(tasks) + self[:tasks] = Array(tasks).map do |task| + TASKS[task.to_sym] + end.uniq + end + + private + + def tasks_uniqueness + errors.add(:tasks, 'are not unique') unless Array(tasks).length == Array(tasks).uniq.length + end + + def project_in_member_source + if member.is_a?(GroupMember) + errors.add(:project, _('is not in the member group')) unless project.namespace == member.source + elsif member.is_a?(ProjectMember) + errors.add(:project, _('is not the member project')) unless project == member.source + end + end +end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index eec46b3493e..89b72508e84 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -94,7 +94,6 @@ class ProjectMember < Member override :refresh_member_authorized_projects def refresh_member_authorized_projects(blocking:) - return super unless Feature.enabled?(:specialized_service_for_project_member_auth_refresh) return unless user # rubocop:disable CodeReuse/ServiceClass diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 15862fb2bfa..0cd8f12088c 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -268,7 +268,6 @@ class MergeRequest < ApplicationRecord from_fork.where('source_project_id = ? OR target_project_id = ?', project.id, project.id) end scope :merged, -> { with_state(:merged) } - scope :closed_and_merged, -> { with_states(:closed, :merged) } scope :open_and_closed, -> { with_states(:opened, :closed) } scope :drafts, -> { where(draft: true) } scope :from_source_branches, ->(branches) { where(source_branch: branches) } @@ -663,7 +662,7 @@ class MergeRequest < ApplicationRecord # updates `merge_jid` with the MergeWorker#jid. # This helps tracking enqueued and ongoing merge jobs. def merge_async(user_id, params) - jid = MergeWorker.perform_async(id, user_id, params.to_h) + jid = MergeWorker.with_status.perform_async(id, user_id, params.to_h) update_column(:merge_jid, jid) # merge_ongoing? depends on merge_jid @@ -682,7 +681,7 @@ class MergeRequest < ApplicationRecord # attribute is set *and* that the sidekiq job is still running. So a JID # for a completed RebaseWorker is equivalent to a nil JID. jid = Sidekiq::Worker.skipping_transaction_check do - RebaseWorker.perform_async(id, user_id, skip_ci) + RebaseWorker.with_status.perform_async(id, user_id, skip_ci) end update_column(:rebase_jid, jid) @@ -1317,6 +1316,10 @@ class MergeRequest < ApplicationRecord end def default_merge_commit_message(include_description: false) + if self.target_project.merge_commit_template.present? && !include_description + return ::Gitlab::MergeRequests::MergeCommitMessage.new(merge_request: self).message + end + closes_issues_references = visible_closing_issues_for.map do |issue| issue.to_reference(target_project) end @@ -1409,7 +1412,15 @@ class MergeRequest < ApplicationRecord def environments return Environment.none unless actual_head_pipeline&.merge_request? - actual_head_pipeline.environments + build_for_actual_head_pipeline = Ci::Build.latest.where(pipeline: actual_head_pipeline) + + environments = build_for_actual_head_pipeline.joins(:metadata) + .where.not('ci_builds_metadata.expanded_environment_name' => nil) + .distinct('ci_builds_metadata.expanded_environment_name') + .limit(100) + .pluck(:expanded_environment_name) + + Environment.where(project: project, name: environments) end def fetch_ref! @@ -1907,6 +1918,10 @@ class MergeRequest < ApplicationRecord true end + def find_assignee(user) + merge_request_assignees.find_by(user_id: user.id) + end + def find_reviewer(user) merge_request_reviewers.find_by(user_id: user.id) end @@ -1930,6 +1945,10 @@ class MergeRequest < ApplicationRecord end end + def attention_requested_enabled? + Feature.enabled?(:mr_attention_requests, project, default_enabled: :yaml) + end + private def set_draft_status diff --git a/app/models/merge_request_assignee.rb b/app/models/merge_request_assignee.rb index 86bf950ae19..fd8e5860040 100644 --- a/app/models/merge_request_assignee.rb +++ b/app/models/merge_request_assignee.rb @@ -1,10 +1,16 @@ # frozen_string_literal: true class MergeRequestAssignee < ApplicationRecord + include MergeRequestReviewerState + belongs_to :merge_request, touch: true belongs_to :assignee, class_name: "User", foreign_key: :user_id, inverse_of: :merge_request_assignees validates :assignee, uniqueness: { scope: :merge_request_id } scope :in_projects, ->(project_ids) { joins(:merge_request).where(merge_requests: { target_project_id: project_ids }) } + + def cache_key + [model_name.cache_key, id, state, assignee.cache_key] + end end diff --git a/app/models/merge_request_context_commit.rb b/app/models/merge_request_context_commit.rb index 09824ed4468..ebbdecf8aa7 100644 --- a/app/models/merge_request_context_commit.rb +++ b/app/models/merge_request_context_commit.rb @@ -26,7 +26,7 @@ class MergeRequestContextCommit < ApplicationRecord # create MergeRequestContextCommit by given commit sha and it's diff file record def self.bulk_insert(rows, **args) - Gitlab::Database.main.bulk_insert('merge_request_context_commits', rows, **args) # rubocop:disable Gitlab/BulkInsert + ApplicationRecord.legacy_bulk_insert('merge_request_context_commits', rows, **args) # rubocop:disable Gitlab/BulkInsert end def to_commit diff --git a/app/models/merge_request_context_commit_diff_file.rb b/app/models/merge_request_context_commit_diff_file.rb index b9efebe3af2..fdf57068928 100644 --- a/app/models/merge_request_context_commit_diff_file.rb +++ b/app/models/merge_request_context_commit_diff_file.rb @@ -14,7 +14,7 @@ class MergeRequestContextCommitDiffFile < ApplicationRecord # create MergeRequestContextCommitDiffFile by given diff file record(s) def self.bulk_insert(*args) - Gitlab::Database.main.bulk_insert('merge_request_context_commit_diff_files', *args) # rubocop:disable Gitlab/BulkInsert + ApplicationRecord.legacy_bulk_insert('merge_request_context_commit_diff_files', *args) # rubocop:disable Gitlab/BulkInsert end def path diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index bd94c0ad30e..2516ff05bda 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -515,7 +515,7 @@ class MergeRequestDiff < ApplicationRecord transaction do MergeRequestDiffFile.where(merge_request_diff_id: id).delete_all - Gitlab::Database.main.bulk_insert('merge_request_diff_files', rows) # rubocop:disable Gitlab/BulkInsert + ApplicationRecord.legacy_bulk_insert('merge_request_diff_files', rows) # rubocop:disable Gitlab/BulkInsert save! end @@ -535,7 +535,7 @@ class MergeRequestDiff < ApplicationRecord transaction do MergeRequestDiffFile.where(merge_request_diff_id: id).delete_all - Gitlab::Database.main.bulk_insert('merge_request_diff_files', rows) # rubocop:disable Gitlab/BulkInsert + ApplicationRecord.legacy_bulk_insert('merge_request_diff_files', rows) # rubocop:disable Gitlab/BulkInsert update!(stored_externally: false) end @@ -595,7 +595,7 @@ class MergeRequestDiff < ApplicationRecord rows = build_external_merge_request_diff_files(rows) if use_external_diff? # Faster inserts - Gitlab::Database.main.bulk_insert('merge_request_diff_files', rows) # rubocop:disable Gitlab/BulkInsert + ApplicationRecord.legacy_bulk_insert('merge_request_diff_files', rows) # rubocop:disable Gitlab/BulkInsert end def build_external_diff_tempfile(rows) @@ -710,7 +710,7 @@ class MergeRequestDiff < ApplicationRecord end CommitCollection - .new(merge_request.source_project, commits, merge_request.source_branch) + .new(merge_request.target_project, commits, merge_request.target_branch) end def save_diffs diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb index d9a1784cdda..66f1e45fd49 100644 --- a/app/models/merge_request_diff_commit.rb +++ b/app/models/merge_request_diff_commit.rb @@ -6,6 +6,12 @@ class MergeRequestDiffCommit < ApplicationRecord include BulkInsertSafe include ShaAttribute include CachedCommit + include IgnorableColumns + include FromUnion + + ignore_column %i[author_name author_email committer_name committer_email], + remove_with: '14.6', + remove_after: '2021-11-22' belongs_to :merge_request_diff @@ -51,9 +57,14 @@ class MergeRequestDiffCommit < ApplicationRecord committer = users[[commit_hash[:committer_name], commit_hash[:committer_email]]] + # These fields are only used to determine the author/committer IDs, we + # don't store them in the DB. + commit_hash = commit_hash + .except(:author_name, :author_email, :committer_name, :committer_email) + commit_hash.merge( - commit_author_id: author&.id, - committer_id: committer&.id, + commit_author_id: author.id, + committer_id: committer.id, merge_request_diff_id: merge_request_diff_id, relative_order: index, sha: Gitlab::Database::ShaAttribute.serialize(sha), # rubocop:disable Cop/ActiveRecordSerialize @@ -63,7 +74,7 @@ class MergeRequestDiffCommit < ApplicationRecord ) end - Gitlab::Database.main.bulk_insert(self.table_name, rows) # rubocop:disable Gitlab/BulkInsert + ApplicationRecord.legacy_bulk_insert(self.table_name, rows) # rubocop:disable Gitlab/BulkInsert end def self.prepare_commits_for_bulk_insert(commits) @@ -104,18 +115,18 @@ class MergeRequestDiffCommit < ApplicationRecord end def author_name - commit_author_id ? commit_author.name : super + commit_author&.name end def author_email - commit_author_id ? commit_author.email : super + commit_author&.email end def committer_name - committer_id ? committer.name : super + committer&.name end def committer_email - committer_id ? committer.email : super + committer&.email end end diff --git a/app/models/merge_request_reviewer.rb b/app/models/merge_request_reviewer.rb index 4a1f31a7f39..4abf0fa09f0 100644 --- a/app/models/merge_request_reviewer.rb +++ b/app/models/merge_request_reviewer.rb @@ -1,15 +1,12 @@ # frozen_string_literal: true class MergeRequestReviewer < ApplicationRecord - enum state: { - unreviewed: 0, - reviewed: 1 - } - - validates :state, - presence: true, - inclusion: { in: MergeRequestReviewer.states.keys } + include MergeRequestReviewerState belongs_to :merge_request belongs_to :reviewer, class_name: 'User', foreign_key: :user_id, inverse_of: :merge_request_reviewers + + def cache_key + [model_name.cache_key, id, state, reviewer.cache_key] + end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 07f9bb99952..353a896b3fe 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -16,9 +16,11 @@ class Namespace < ApplicationRecord include Namespaces::Traversal::Linear include EachBatch - ignore_column :delayed_project_removal, remove_with: '14.1', remove_after: '2021-05-22' + # Temporary column used for back-filling project namespaces. + # Remove it once the back-filling of all project namespaces is done. + ignore_column :tmp_project_id, remove_with: '14.7', remove_after: '2022-01-22' - # Tells ActiveRecord not to store the full class name, in order to space some space + # Tells ActiveRecord not to store the full class name, in order to save some space # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69794 self.store_full_sti_class = false self.store_full_class_name = false @@ -54,7 +56,7 @@ class Namespace < ApplicationRecord belongs_to :owner, class_name: "User" belongs_to :parent, class_name: "Namespace" - has_many :children, class_name: "Namespace", foreign_key: :parent_id + has_many :children, -> { where(type: Group.sti_name) }, class_name: "Namespace", foreign_key: :parent_id has_many :custom_emoji, inverse_of: :namespace has_one :chat_team, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_one :root_storage_statistics, class_name: 'Namespace::RootStorageStatistics' @@ -95,9 +97,11 @@ class Namespace < ApplicationRecord validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true } validate :validate_parent_type, if: -> { Feature.enabled?(:validate_namespace_parent_type, default_enabled: :yaml) } - validate :nesting_level_allowed - validate :changing_shared_runners_enabled_is_allowed - validate :changing_allow_descendants_override_disabled_shared_runners_is_allowed + + # ProjectNamespaces excluded as they are not meant to appear in the group hierarchy at the moment. + validate :nesting_level_allowed, unless: -> { project_namespace? } + validate :changing_shared_runners_enabled_is_allowed, unless: -> { project_namespace? } + validate :changing_allow_descendants_override_disabled_shared_runners_is_allowed, unless: -> { project_namespace? } delegate :name, to: :owner, allow_nil: true, prefix: true delegate :avatar_url, to: :owner, allow_nil: true @@ -123,7 +127,7 @@ class Namespace < ApplicationRecord scope :user_namespaces, -> { where(type: [nil, Namespaces::UserNamespace.sti_name]) } # TODO: this can be simplified with `type != 'Project'` when working on issue # https://gitlab.com/gitlab-org/gitlab/-/issues/341070 - scope :without_project_namespaces, -> { where("type IS DISTINCT FROM ?", Namespaces::ProjectNamespace.sti_name) } + scope :without_project_namespaces, -> { where(Namespace.arel_table[:type].is_distinct_from(Namespaces::ProjectNamespace.sti_name)) } scope :sort_by_type, -> { order(Gitlab::Database.nulls_first_order(:type)) } scope :include_route, -> { includes(:route) } scope :by_parent, -> (parent) { where(parent_id: parent) } @@ -140,6 +144,7 @@ class Namespace < ApplicationRecord 'COALESCE(SUM(ps.snippets_size), 0) AS snippets_size', 'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size', 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size', + 'COALESCE(SUM(ps.pipeline_artifacts_size), 0) AS pipeline_artifacts_size', 'COALESCE(SUM(ps.packages_size), 0) AS packages_size', 'COALESCE(SUM(ps.uploads_size), 0) AS uploads_size' ) @@ -189,9 +194,9 @@ class Namespace < ApplicationRecord # Returns an ActiveRecord::Relation. def search(query, include_parents: false) if include_parents - where(id: Route.for_routable_type(Namespace.name).fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name]]).select(:source_id)) + without_project_namespaces.where(id: Route.for_routable_type(Namespace.name).fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name]]).select(:source_id)) else - fuzzy_search(query, [:path, :name]) + without_project_namespaces.fuzzy_search(query, [:path, :name]) end end @@ -494,6 +499,10 @@ class Namespace < ApplicationRecord Feature.enabled?(:block_issue_repositioning, self, type: :ops, default_enabled: :yaml) end + def project_namespace_creation_enabled? + Feature.enabled?(:create_project_namespace_on_project_create, self, default_enabled: :yaml) + end + private def expire_child_caches @@ -535,21 +544,23 @@ class Namespace < ApplicationRecord # Until we compare the inconsistency rates of the new specialized worker and # the old approach, we still run AuthorizedProjectsWorker # but with some delay and lower urgency as a safety net. - Group - .joins(project_group_links: :project) - .where(projects: { namespace_id: id }) - .distinct - .find_each do |group| - group.refresh_members_authorized_projects( - blocking: false, - priority: UserProjectAccessChangedService::LOW_PRIORITY - ) - end + enqueue_jobs_for_groups_requiring_authorizations_refresh(priority: UserProjectAccessChangedService::LOW_PRIORITY) else - Group - .joins(project_group_links: :project) - .where(projects: { namespace_id: id }) - .find_each(&:refresh_members_authorized_projects) + enqueue_jobs_for_groups_requiring_authorizations_refresh(priority: UserProjectAccessChangedService::HIGH_PRIORITY) + end + end + + def enqueue_jobs_for_groups_requiring_authorizations_refresh(priority:) + groups_requiring_authorizations_refresh = Group + .joins(project_group_links: :project) + .where(projects: { namespace_id: id }) + .distinct + + groups_requiring_authorizations_refresh.find_each do |group| + group.refresh_members_authorized_projects( + blocking: false, + priority: priority + ) end end @@ -573,7 +584,7 @@ class Namespace < ApplicationRecord end if user_namespace? - errors.add(:parent_id, _('cannot not be used for user namespace')) + errors.add(:parent_id, _('cannot be used for user namespace')) elsif group_namespace? errors.add(:parent_id, _('user namespace cannot be the parent of another namespace')) if parent.user_namespace? end diff --git a/app/models/namespaces/project_namespace.rb b/app/models/namespaces/project_namespace.rb index d1806c1c088..22ec550dee2 100644 --- a/app/models/namespaces/project_namespace.rb +++ b/app/models/namespaces/project_namespace.rb @@ -4,8 +4,6 @@ module Namespaces class ProjectNamespace < Namespace has_one :project, foreign_key: :project_namespace_id, inverse_of: :project_namespace - validates :project, presence: true - def self.sti_name 'Project' end diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb index d7130322ed1..1736fe82ca5 100644 --- a/app/models/namespaces/traversal/linear.rb +++ b/app/models/namespaces/traversal/linear.rb @@ -161,7 +161,7 @@ module Namespaces def lineage(top: nil, bottom: nil, hierarchy_order: nil) raise UnboundedSearch, 'Must bound search by either top or bottom' unless top || bottom - skope = self.class.without_sti_condition + skope = self.class if top skope = skope.where("traversal_ids @> ('{?}')", top.id) @@ -181,7 +181,6 @@ module Namespaces # standard SELECT to avoid mismatched attribute errors when trying to # chain future ActiveRelation commands, and retain the ordering. skope = self.class - .without_sti_condition .from(skope, self.class.table_name) .select(skope.arel_table[Arel.star]) .order(depth: hierarchy_order) diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb index 2da0e48c2da..f5c44171c42 100644 --- a/app/models/namespaces/traversal/linear_scopes.rb +++ b/app/models/namespaces/traversal/linear_scopes.rb @@ -15,12 +15,18 @@ module Namespaces select('namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id') end + def roots + return super unless use_traversal_ids_roots? + + root_ids = all.select("#{quoted_table_name}.traversal_ids[1]").distinct + unscoped.where(id: root_ids) + end + def self_and_ancestors(include_self: true, hierarchy_order: nil) return super unless use_traversal_ids_for_ancestor_scopes? records = unscoped - .without_sti_condition - .where(id: without_sti_condition.select('unnest(traversal_ids)')) + .where(id: select('unnest(traversal_ids)')) .order_by_depth(hierarchy_order) .normal_select @@ -40,24 +46,24 @@ module Namespaces def self_and_descendants(include_self: true) return super unless use_traversal_ids? - records = self_and_descendants_with_duplicates(include_self: include_self) - - distinct = records.select('DISTINCT on(namespaces.id) namespaces.*') - - distinct.normal_select + if Feature.enabled?(:traversal_ids_btree, default_enabled: :yaml) + self_and_descendants_with_comparison_operators(include_self: include_self) + else + records = self_and_descendants_with_duplicates_with_array_operator(include_self: include_self) + distinct = records.select('DISTINCT on(namespaces.id) namespaces.*') + distinct.normal_select + end end def self_and_descendant_ids(include_self: true) return super unless use_traversal_ids? - self_and_descendants_with_duplicates(include_self: include_self) - .select('DISTINCT namespaces.id') - end - - # Make sure we drop the STI `type = 'Group'` condition for better performance. - # Logically equivalent so long as hierarchies remain homogeneous. - def without_sti_condition - unscope(where: :type) + if Feature.enabled?(:traversal_ids_btree, default_enabled: :yaml) + self_and_descendants_with_comparison_operators(include_self: include_self).as_ids + else + self_and_descendants_with_duplicates_with_array_operator(include_self: include_self) + .select('DISTINCT namespaces.id') + end end def order_by_depth(hierarchy_order) @@ -75,7 +81,7 @@ module Namespaces # When we have queries that break this SELECT * format we can run in to errors. # For example `SELECT DISTINCT on(...)` will fail when we chain a `.count` c def normal_select - unscoped.without_sti_condition.from(all, :namespaces) + unscoped.from(all, :namespaces) end private @@ -84,16 +90,52 @@ module Namespaces Feature.enabled?(:use_traversal_ids, default_enabled: :yaml) end + def use_traversal_ids_roots? + Feature.enabled?(:use_traversal_ids_roots, default_enabled: :yaml) && + use_traversal_ids? + end + def use_traversal_ids_for_ancestor_scopes? Feature.enabled?(:use_traversal_ids_for_ancestor_scopes, default_enabled: :yaml) && use_traversal_ids? end - def self_and_descendants_with_duplicates(include_self: true) + def self_and_descendants_with_comparison_operators(include_self: true) + base = all.select( + :traversal_ids, + 'LEAD (namespaces.traversal_ids, 1) OVER (ORDER BY namespaces.traversal_ids ASC) next_traversal_ids' + ) + cte = Gitlab::SQL::CTE.new(:base_cte, base) + + namespaces = Arel::Table.new(:namespaces) + records = unscoped + .with(cte.to_arel) + .from([cte.table, namespaces]) + + # Bound the search space to ourselves (optional) and descendants. + # + # WHERE (base_cte.next_traversal_ids IS NULL OR base_cte.next_traversal_ids > namespaces.traversal_ids) + # AND next_traversal_ids_sibling(base_cte.traversal_ids) > namespaces.traversal_ids + records = records + .where(cte.table[:next_traversal_ids].eq(nil).or(cte.table[:next_traversal_ids].gt(namespaces[:traversal_ids]))) + .where(next_sibling_func(cte.table[:traversal_ids]).gt(namespaces[:traversal_ids])) + + # AND base_cte.traversal_ids <= namespaces.traversal_ids + if include_self + records.where(cte.table[:traversal_ids].lteq(namespaces[:traversal_ids])) + else + records.where(cte.table[:traversal_ids].lt(namespaces[:traversal_ids])) + end + end + + def next_sibling_func(*args) + Arel::Nodes::NamedFunction.new('next_traversal_ids_sibling', args) + end + + def self_and_descendants_with_duplicates_with_array_operator(include_self: true) base_ids = select(:id) records = unscoped - .without_sti_condition .from("namespaces, (#{base_ids.to_sql}) base") .where('namespaces.traversal_ids @> ARRAY[base.id]') diff --git a/app/models/namespaces/traversal/recursive_scopes.rb b/app/models/namespaces/traversal/recursive_scopes.rb index 6659cefe095..925d9b8bb0c 100644 --- a/app/models/namespaces/traversal/recursive_scopes.rb +++ b/app/models/namespaces/traversal/recursive_scopes.rb @@ -10,6 +10,13 @@ module Namespaces select('id') end + def roots + Gitlab::ObjectHierarchy + .new(all) + .base_and_ancestors + .where(namespaces: { parent_id: nil }) + end + def self_and_ancestors(include_self: true, hierarchy_order: nil) records = Gitlab::ObjectHierarchy.new(all).base_and_ancestors(hierarchy_order: hierarchy_order) diff --git a/app/models/namespaces/user_namespace.rb b/app/models/namespaces/user_namespace.rb index 22b7a0a3b2b..d4d7d352e71 100644 --- a/app/models/namespaces/user_namespace.rb +++ b/app/models/namespaces/user_namespace.rb @@ -3,6 +3,26 @@ # TODO: currently not created/mapped in the database, will be done in another issue # https://gitlab.com/gitlab-org/gitlab/-/issues/341070 module Namespaces + #################################################################### + # PLEASE DO NOT OVERRIDE METHODS IN THIS CLASS! + # + # This class is a placeholder for STI. But we also want to ensure + # tests using `:namespace` factory are still testing the same functionality. + # + # Many legacy tests use `:namespace` which has a slight semantic + # mismatch as it always has been a User (personal) namespace. + # + # If you need to make a change here, please ping the + # Manage/Workspaces group so we can ensure that the + # changes do not break existing functionality. + # + # As Namespaces evolve we may be able to relax this restriction + # but for now, please check in with us <3 + # + # For details, see the discussion in + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74152 + #################################################################### + class UserNamespace < Namespace def self.sti_name 'User' diff --git a/app/models/note.rb b/app/models/note.rb index 37473518892..cb285028203 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -114,6 +114,7 @@ class Note < ApplicationRecord scope :fresh, -> { order_created_asc.with_order_id_asc } scope :updated_after, ->(time) { where('updated_at > ?', time) } scope :with_updated_at, ->(time) { where(updated_at: time) } + scope :with_discussion_ids, ->(discussion_ids) { where(discussion_id: discussion_ids) } scope :with_suggestions, -> { joins(:suggestions) } scope :inc_author, -> { includes(:author) } scope :with_api_entity_associations, -> { preload(:note_diff_file, :author) } diff --git a/app/models/packages/npm.rb b/app/models/packages/npm.rb index e49199d911c..9221187d92a 100644 --- a/app/models/packages/npm.rb +++ b/app/models/packages/npm.rb @@ -9,5 +9,9 @@ module Packages package_name.match(Gitlab::Regex.npm_package_name_regex)&.captures&.first end + + def self.table_name_prefix + 'packages_npm_' + end end end diff --git a/app/models/packages/npm/metadatum.rb b/app/models/packages/npm/metadatum.rb new file mode 100644 index 00000000000..7388c4bdbd2 --- /dev/null +++ b/app/models/packages/npm/metadatum.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Packages::Npm::Metadatum < ApplicationRecord + belongs_to :package, -> { where(package_type: :npm) }, inverse_of: :npm_metadatum + + validates :package, presence: true + # From https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md#abbreviated-version-object + validates :package_json, json_schema: { filename: "npm_package_json" } + validate :ensure_npm_package_type + validate :ensure_package_json_size + + private + + def ensure_npm_package_type + return if package&.npm? + + errors.add(:base, _('Package type must be NPM')) + end + + def ensure_package_json_size + return if package_json.to_s.size < 20000 + + errors.add(:package_json, _('structure is too large')) + end +end diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index 34eae6ab5dc..962a1057a22 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -39,8 +39,9 @@ class Packages::Package < ApplicationRecord has_one :nuget_metadatum, inverse_of: :package, class_name: 'Packages::Nuget::Metadatum' has_one :composer_metadatum, inverse_of: :package, class_name: 'Packages::Composer::Metadatum' has_one :rubygems_metadatum, inverse_of: :package, class_name: 'Packages::Rubygems::Metadatum' + has_one :npm_metadatum, inverse_of: :package, class_name: 'Packages::Npm::Metadatum' has_many :build_infos, inverse_of: :package - has_many :pipelines, through: :build_infos + has_many :pipelines, through: :build_infos, disable_joins: true has_one :debian_publication, inverse_of: :package, class_name: 'Packages::Debian::Publication' has_one :debian_distribution, through: :debian_publication, source: :distribution, inverse_of: :packages, class_name: 'Packages::Debian::ProjectDistribution' @@ -102,7 +103,6 @@ class Packages::Package < ApplicationRecord scope :with_status, ->(status) { where(status: status) } scope :displayable, -> { with_status(DISPLAYABLE_STATUSES) } scope :installable, -> { with_status(INSTALLABLE_STATUSES) } - scope :including_build_info, -> { includes(pipelines: :user) } scope :including_project_route, -> { includes(project: { namespace: :route }) } scope :including_tags, -> { includes(:tags) } scope :including_dependency_links, -> { includes(dependency_links: :dependency) } @@ -126,11 +126,13 @@ class Packages::Package < ApplicationRecord .where(Packages::Composer::Metadatum.table_name => { target_sha: target }) end scope :preload_composer, -> { preload(:composer_metadatum) } + scope :preload_npm_metadatum, -> { preload(:npm_metadatum) } scope :without_nuget_temporary_name, -> { where.not(name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) } scope :has_version, -> { where.not(version: nil) } scope :preload_files, -> { preload(:package_files) } + scope :preload_pipelines, -> { preload(pipelines: :user) } scope :last_of_each_version, -> { where(id: all.select('MAX(id) AS id').group(:version)) } scope :limit_recent, ->(limit) { order_created_desc.limit(limit) } scope :select_distinct_name, -> { select(:name).distinct } @@ -245,7 +247,7 @@ class Packages::Package < ApplicationRecord def versions project.packages - .including_build_info + .preload_pipelines .including_tags .with_name(name) .where.not(version: version) diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb index 14701b8a800..87c9f56cc41 100644 --- a/app/models/packages/package_file.rb +++ b/app/models/packages/package_file.rb @@ -15,7 +15,7 @@ class Packages::PackageFile < ApplicationRecord has_one :conan_file_metadatum, inverse_of: :package_file, class_name: 'Packages::Conan::FileMetadatum' has_many :package_file_build_infos, inverse_of: :package_file, class_name: 'Packages::PackageFileBuildInfo' - has_many :pipelines, through: :package_file_build_infos + has_many :pipelines, through: :package_file_build_infos, disable_joins: true has_one :debian_file_metadatum, inverse_of: :package_file, class_name: 'Packages::Debian::FileMetadatum' has_one :helm_file_metadatum, inverse_of: :package_file, class_name: 'Packages::Helm::FileMetadatum' @@ -38,6 +38,7 @@ class Packages::PackageFile < ApplicationRecord scope :with_format, ->(format) { where(::Packages::PackageFile.arel_table[:file_name].matches("%.#{format}")) } scope :preload_package, -> { preload(:package) } + scope :preload_pipelines, -> { preload(pipelines: :user) } scope :preload_conan_file_metadata, -> { preload(:conan_file_metadatum) } scope :preload_debian_file_metadata, -> { preload(:debian_file_metadatum) } scope :preload_helm_file_metadata, -> { preload(:helm_file_metadatum) } diff --git a/app/models/preloaders/group_policy_preloader.rb b/app/models/preloaders/group_policy_preloader.rb new file mode 100644 index 00000000000..95d6e0b5c1f --- /dev/null +++ b/app/models/preloaders/group_policy_preloader.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Preloaders + class GroupPolicyPreloader + def initialize(groups, current_user) + @groups = groups + @current_user = current_user + end + + def execute + Preloaders::UserMaxAccessLevelInGroupsPreloader.new(@groups, @current_user).execute + Preloaders::GroupRootAncestorPreloader.new(@groups, root_ancestor_preloads).execute + end + + private + + def root_ancestor_preloads + [] + end + end +end + +Preloaders::GroupPolicyPreloader.prepend_mod_with('Preloaders::GroupPolicyPreloader') diff --git a/app/models/preloaders/group_root_ancestor_preloader.rb b/app/models/preloaders/group_root_ancestor_preloader.rb new file mode 100644 index 00000000000..3ca713d9635 --- /dev/null +++ b/app/models/preloaders/group_root_ancestor_preloader.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Preloaders + class GroupRootAncestorPreloader + def initialize(groups, root_ancestor_preloads = []) + @groups = groups + @root_ancestor_preloads = root_ancestor_preloads + end + + def execute + return unless ::Feature.enabled?(:use_traversal_ids, default_enabled: :yaml) + + # type == 'Group' condition located on subquery to prevent a filter in the query + root_query = Namespace.joins("INNER JOIN (#{join_sql}) as root_query ON root_query.root_id = namespaces.id") + .select('namespaces.*, root_query.id as source_id') + + root_query = root_query.preload(*@root_ancestor_preloads) if @root_ancestor_preloads.any? + + root_ancestors_by_id = root_query.group_by(&:source_id) + + @groups.each do |group| + group.root_ancestor = root_ancestors_by_id[group.id].first + end + end + + private + + def join_sql + Group.select('id, traversal_ids[1] as root_id').where(id: @groups.map(&:id)).to_sql + end + end +end diff --git a/app/models/preloaders/user_max_access_level_in_groups_preloader.rb b/app/models/preloaders/user_max_access_level_in_groups_preloader.rb index 14f1d271572..bdd76d39ec1 100644 --- a/app/models/preloaders/user_max_access_level_in_groups_preloader.rb +++ b/app/models/preloaders/user_max_access_level_in_groups_preloader.rb @@ -3,7 +3,6 @@ module Preloaders # This class preloads the max access level (role) for the user within the given groups and # stores the values in requests store. - # Will only be able to preload max access level for groups where the user is a direct member class UserMaxAccessLevelInGroupsPreloader include BulkMemberAccessLoad @@ -13,8 +12,17 @@ module Preloaders end def execute + if ::Feature.enabled?(:use_traversal_ids, default_enabled: :yaml) + preload_with_traversal_ids + else + preload_direct_memberships + end + end + + private + + def preload_direct_memberships group_memberships = GroupMember.active_without_invites_and_requests - .non_minimal_access .where(user: @user, source_id: @groups) .group(:source_id) .maximum(:access_level) @@ -23,5 +31,22 @@ module Preloaders merge_value_to_request_store(User, @user.id, group_id, max_access_level) end end + + def preload_with_traversal_ids + max_access_levels = GroupMember.active_without_invites_and_requests + .where(user: @user) + .joins("INNER JOIN (#{traversal_join_sql}) as hierarchy ON members.source_id = hierarchy.traversal_id") + .group('hierarchy.id') + .maximum(:access_level) + + @groups.each do |group| + max_access_level = max_access_levels[group.id] || Gitlab::Access::NO_ACCESS + merge_value_to_request_store(User, @user.id, group.id, max_access_level) + end + end + + def traversal_join_sql + Namespace.select('id, unnest(traversal_ids) as traversal_id').where(id: @groups.map(&:id)).to_sql + end end end diff --git a/app/models/project.rb b/app/models/project.rb index 2ceba10e86e..2288850553c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -19,7 +19,6 @@ class Project < ApplicationRecord include Presentable include HasRepository include HasWiki - include HasIntegrations include CanMoveRepositoryStorage include Routable include GroupDescendant @@ -98,7 +97,7 @@ class Project < ApplicationRecord before_validation :mark_remote_mirrors_for_removal, if: -> { RemoteMirror.table_exists? } before_save :ensure_runners_token - before_save :ensure_project_namespace_in_sync + before_validation :ensure_project_namespace_in_sync after_save :update_project_statistics, if: :saved_change_to_namespace_id? @@ -147,7 +146,7 @@ class Project < ApplicationRecord belongs_to :namespace # Sync deletion via DB Trigger to ensure we do not have # a project without a project_namespace (or vice-versa) - belongs_to :project_namespace, autosave: true, class_name: 'Namespaces::ProjectNamespace', foreign_key: 'project_namespace_id', inverse_of: :project + belongs_to :project_namespace, autosave: true, class_name: 'Namespaces::ProjectNamespace', foreign_key: 'project_namespace_id' alias_method :parent, :namespace alias_attribute :parent_id, :namespace_id @@ -189,6 +188,7 @@ class Project < ApplicationRecord has_one :prometheus_integration, class_name: 'Integrations::Prometheus', inverse_of: :project has_one :pushover_integration, class_name: 'Integrations::Pushover' has_one :redmine_integration, class_name: 'Integrations::Redmine' + has_one :shimo_integration, class_name: 'Integrations::Shimo' has_one :slack_integration, class_name: 'Integrations::Slack' has_one :slack_slash_commands_integration, class_name: 'Integrations::SlackSlashCommands' has_one :teamcity_integration, class_name: 'Integrations::Teamcity' @@ -451,6 +451,7 @@ class Project < ApplicationRecord :allow_merge_on_skipped_pipeline=, :has_confluence?, to: :project_setting delegate :active?, to: :prometheus_integration, allow_nil: true, prefix: true + delegate :merge_commit_template, :merge_commit_template=, to: :project_setting, allow_nil: true delegate :log_jira_dvcs_integration_usage, :jira_dvcs_server_last_sync_at, :jira_dvcs_cloud_last_sync_at, to: :feature_usage @@ -475,6 +476,7 @@ class Project < ApplicationRecord validates :project_feature, presence: true validates :namespace, presence: true + validates :project_namespace, presence: true, if: -> { self.namespace && self.root_namespace.project_namespace_creation_enabled? } validates :name, uniqueness: { scope: :namespace_id } validates :import_url, public_url: { schemes: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS }, ports: ->(project) { project.persisted? ? VALID_MIRROR_PORTS : VALID_IMPORT_PORTS }, @@ -492,6 +494,7 @@ class Project < ApplicationRecord validates :variables, nested_attributes_duplicates: { scope: :environment_scope } validates :bfg_object_map, file_size: { maximum: :max_attachment_size } validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true } + validates :suggestion_commit_message, length: { maximum: 255 } # Scopes scope :pending_delete, -> { where(pending_delete: true) } @@ -857,6 +860,18 @@ class Project < ApplicationRecord rescue ActionController::RoutingError, URI::InvalidURIError nil end + + def without_integration(integration) + integrations = Integration + .select('1') + .where("#{Integration.table_name}.project_id = projects.id") + .where(type: integration.type) + + Project + .where('NOT EXISTS (?)', integrations) + .where(pending_delete: false) + .where(archived: false) + end end def initialize(attributes = nil) @@ -1453,7 +1468,7 @@ class Project < ApplicationRecord end def disabled_integrations - [:zentao] + [:shimo] end def find_or_initialize_integration(name) @@ -1777,10 +1792,12 @@ class Project < ApplicationRecord def all_runners Ci::Runner.from_union([runners, group_runners, shared_runners]) + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/339937') end def all_available_runners Ci::Runner.from_union([runners, group_runners, available_shared_runners]) + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/339937') end # Once issue 339937 is fixed, please search for all mentioned of @@ -2051,14 +2068,16 @@ class Project < ApplicationRecord end def predefined_variables - Gitlab::Ci::Variables::Collection.new - .concat(predefined_ci_server_variables) - .concat(predefined_project_variables) - .concat(pages_variables) - .concat(container_registry_variables) - .concat(dependency_proxy_variables) - .concat(auto_devops_variables) - .concat(api_variables) + strong_memoize(:predefined_variables) do + Gitlab::Ci::Variables::Collection.new + .concat(predefined_ci_server_variables) + .concat(predefined_project_variables) + .concat(pages_variables) + .concat(container_registry_variables) + .concat(dependency_proxy_variables) + .concat(auto_devops_variables) + .concat(api_variables) + end end def predefined_project_variables @@ -2579,18 +2598,21 @@ class Project < ApplicationRecord config = Gitlab.config.incoming_email wildcard = Gitlab::IncomingEmail::WILDCARD_PLACEHOLDER - config.address&.gsub(wildcard, "#{full_path_slug}-#{id}-issue-") + config.address&.gsub(wildcard, "#{full_path_slug}-#{default_service_desk_suffix}") end def service_desk_custom_address return unless Gitlab::ServiceDeskEmail.enabled? - key = service_desk_setting&.project_key - return unless key.present? + key = service_desk_setting&.project_key || default_service_desk_suffix Gitlab::ServiceDeskEmail.address_for_key("#{full_path_slug}-#{key}") end + def default_service_desk_suffix + "#{id}-issue-" + end + def root_namespace if namespace.has_parent? namespace.root_ancestor @@ -2911,12 +2933,28 @@ class Project < ApplicationRecord end def ensure_project_namespace_in_sync - if changes.keys & [:name, :path, :namespace_id, :visibility_level] && project_namespace.present? - project_namespace.name = name - project_namespace.path = path - project_namespace.parent = namespace - project_namespace.visibility_level = visibility_level - end + # create project_namespace when project is created if create_project_namespace_on_project_create FF is enabled + build_project_namespace if project_namespace_creation_enabled? + + # regardless of create_project_namespace_on_project_create FF we need + # to keep project and project namespace in sync if there is one + sync_attributes(project_namespace) if sync_project_namespace? + end + + def project_namespace_creation_enabled? + new_record? && !project_namespace && self.namespace && self.root_namespace.project_namespace_creation_enabled? + end + + def sync_project_namespace? + (changes.keys & %w(name path namespace_id namespace visibility_level shared_runners_enabled)).any? && project_namespace.present? + end + + def sync_attributes(project_namespace) + project_namespace.name = name + project_namespace.path = path + project_namespace.parent = namespace + project_namespace.shared_runners_enabled = shared_runners_enabled + project_namespace.visibility_level = visibility_level end end diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index 64e768007ee..fed19a37a16 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -24,8 +24,9 @@ class ProjectAuthorization < ApplicationRecord end connection.execute <<-EOF.strip_heredoc - INSERT INTO project_authorizations (user_id, project_id, access_level) - VALUES #{tuples.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')} + INSERT INTO project_authorizations (user_id, project_id, access_level) + VALUES #{tuples.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')} + ON CONFLICT DO NOTHING EOF end end diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index 24d892290a6..6c8d2226bc9 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -12,6 +12,8 @@ class ProjectSetting < ApplicationRecord self.primary_key = :project_id + validates :merge_commit_template, length: { maximum: 500 } + def squash_enabled_by_default? %w[always default_on].include?(squash_option) end diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 774d81156b7..94904e9792f 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -41,13 +41,15 @@ class ProjectTeam member end - def add_users(users, access_level, current_user: nil, expires_at: nil) + def add_users(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil) Members::Projects::BulkCreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass project, users, access_level, current_user: current_user, - expires_at: expires_at + expires_at: expires_at, + tasks_to_be_done: tasks_to_be_done, + tasks_project_id: tasks_project_id ) end diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb index f3352ecc5ee..8d6f8c3a9ca 100644 --- a/app/models/projects/topic.rb +++ b/app/models/projects/topic.rb @@ -28,3 +28,5 @@ module Projects end end end + +::Projects::Topic.prepend_mod_with('Projects::Topic') diff --git a/app/models/push_event_payload.rb b/app/models/push_event_payload.rb index 441b94e1855..8358be35470 100644 --- a/app/models/push_event_payload.rb +++ b/app/models/push_event_payload.rb @@ -2,9 +2,6 @@ class PushEventPayload < ApplicationRecord extend SuppressCompositePrimaryKeyWarning - include IgnorableColumns - - ignore_columns :event_id_convert_to_bigint, remove_with: '14.4', remove_after: '2021-10-22' include ShaAttribute diff --git a/app/models/release.rb b/app/models/release.rb index eac6346cc60..0fda6940249 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -34,6 +34,7 @@ class Release < ApplicationRecord project: [:project_feature, :route, { namespace: :route }]) } scope :with_milestones, -> { joins(:milestone_releases) } + scope :with_group_milestones, -> { joins(:milestones).where.not(milestones: { group_id: nil }) } scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) } scope :without_evidence, -> { left_joins(:evidences).where(::Releases::Evidence.arel_table[:id].eq(nil)) } scope :released_within_2hrs, -> { where(released_at: Time.zone.now - 1.hour..Time.zone.now + 1.hour) } diff --git a/app/models/repository.rb b/app/models/repository.rb index 119d874a6e1..47482f04bca 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -731,10 +731,8 @@ class Repository raw_repository.local_branches(sort_by: sort_by, pagination_params: pagination_params) end - def tags_sorted_by(value) - return raw_repository.tags(sort_by: value) if Feature.enabled?(:tags_finder_gitaly, project, default_enabled: :yaml) - - tags_ruby_sort(value) + def tags_sorted_by(value, pagination_params = nil) + raw_repository.tags(sort_by: value, pagination_params: pagination_params) end # Params: @@ -1091,6 +1089,13 @@ class Repository after_create true + rescue Gitlab::Git::Repository::RepositoryExists + # We do not want to call `#after_create` given that we didn't create the + # repo, but we obviously have a mismatch between what's in our exists cache + # and actual on-disk state as seen by Gitaly. Let's thus expire our caches. + expire_status_cache + + nil end def create_from_bundle(bundle_path) @@ -1163,34 +1168,6 @@ class Repository @request_store_cache ||= Gitlab::RepositoryCache.new(self, backend: Gitlab::SafeRequestStore) end - # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/339741 - def tags_ruby_sort(value) - case value - when 'name_asc' - VersionSorter.sort(tags) { |tag| tag.name } - when 'name_desc' - VersionSorter.rsort(tags) { |tag| tag.name } - when 'updated_desc' - tags_sorted_by_committed_date.reverse - when 'updated_asc' - tags_sorted_by_committed_date - else - tags - end - end - - # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/339741 - def tags_sorted_by_committed_date - # Annotated tags can point to any object (e.g. a blob), but generally - # tags point to a commit. If we don't have a commit, then just default - # to putting the tag at the end of the list. - default = Time.current - - tags.sort_by do |tag| - tag.dereferenced_target&.committed_date || default - end - end - def repository_event(event, tags = {}) Gitlab::Metrics.add_event(event, tags) end diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb index ff564d87449..f1ca5c23997 100644 --- a/app/models/suggestion.rb +++ b/app/models/suggestion.rb @@ -50,6 +50,7 @@ class Suggestion < ApplicationRecord next _("Can't apply as the source branch was deleted.") unless noteable.source_branch_exists? next outdated_reason if outdated?(cached: cached) || !note.active? next _("This suggestion already matches its content.") unless different_content? + next _("This file was modified for readability, and can't accept suggestions. Edit it directly.") if file_path.end_with? "ipynb" end end diff --git a/app/models/todo.rb b/app/models/todo.rb index 94a99603848..742b8fd2a9d 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -18,6 +18,7 @@ class Todo < ApplicationRecord DIRECTLY_ADDRESSED = 7 MERGE_TRAIN_REMOVED = 8 # This is an EE-only feature REVIEW_REQUESTED = 9 + ATTENTION_REQUESTED = 10 ACTION_NAMES = { ASSIGNED => :assigned, @@ -28,7 +29,8 @@ class Todo < ApplicationRecord APPROVAL_REQUIRED => :approval_required, UNMERGEABLE => :unmergeable, DIRECTLY_ADDRESSED => :directly_addressed, - MERGE_TRAIN_REMOVED => :merge_train_removed + MERGE_TRAIN_REMOVED => :merge_train_removed, + ATTENTION_REQUESTED => :attention_requested }.freeze belongs_to :author, class_name: "User" @@ -189,6 +191,10 @@ class Todo < ApplicationRecord action == REVIEW_REQUESTED end + def attention_requested? + action == ATTENTION_REQUESTED + end + def merge_train_removed? action == MERGE_TRAIN_REMOVED end diff --git a/app/models/upload.rb b/app/models/upload.rb index c1a3df82457..ac7ebb31abc 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -2,6 +2,7 @@ class Upload < ApplicationRecord include Checksummable + # Upper limit for foreground checksum processing CHECKSUM_THRESHOLD = 100.megabytes @@ -51,9 +52,9 @@ class Upload < ApplicationRecord ## # FastDestroyAll concerns - def finalize_fast_destroy(keys) - keys.each do |store_class, paths| - store_class.new.delete_keys_async(paths) + def finalize_fast_destroy(items_to_remove) + items_to_remove.each do |store_class, keys| + store_class.new.delete_keys_async(keys) end end end @@ -65,6 +66,10 @@ class Upload < ApplicationRecord uploader_class.absolute_path(self) end + def relative_path + uploader_class.relative_path(self) + end + def calculate_checksum! self.checksum = nil return unless needs_checksum? diff --git a/app/models/uploads/fog.rb b/app/models/uploads/fog.rb index b44e273e9ab..5d57b644dbe 100644 --- a/app/models/uploads/fog.rb +++ b/app/models/uploads/fog.rb @@ -15,13 +15,21 @@ module Uploads end def delete_keys(keys) - keys.each do |key| - connection.delete_object(bucket_name, key) - end + keys.each { |key| delete_object(key) } end private + def delete_object(key) + connection.delete_object(bucket_name, key) + + # So far, only GoogleCloudStorage raises an exception when the file is not found. + # Other providers support idempotent requests and does not raise an error + # when the file is missing. + rescue ::Google::Apis::ClientError => e + Gitlab::ErrorTracking.log_exception(e) + end + def object_store Gitlab.config.uploads.object_store end diff --git a/app/models/uploads/local.rb b/app/models/uploads/local.rb index bd295a66838..9df69998991 100644 --- a/app/models/uploads/local.rb +++ b/app/models/uploads/local.rb @@ -55,3 +55,5 @@ module Uploads end end end + +Uploads::Local.prepend_mod diff --git a/app/models/user.rb b/app/models/user.rb index 0e19e6e4a79..3ab5b7ee364 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -123,7 +123,7 @@ class User < ApplicationRecord # Profile has_many :keys, -> { regular_keys }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :expired_and_unnotified_keys, -> { expired_and_not_notified }, class_name: 'Key' + has_many :expired_today_and_unnotified_keys, -> { expired_today_and_not_notified }, class_name: 'Key' has_many :expiring_soon_and_unnotified_keys, -> { expiring_soon_and_not_notified }, class_name: 'Key' has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent has_many :group_deploy_keys @@ -274,14 +274,21 @@ class User < ApplicationRecord after_update :username_changed_hook, if: :saved_change_to_username? after_destroy :post_destroy_hook after_destroy :remove_key_cache + after_create :add_primary_email_to_emails!, if: :confirmed? after_commit(on: :update) do if previous_changes.key?('email') - # Grab previous_email here since previous_changes changes after - # #update_emails_with_primary_email and #update_notification_email are called + # Add the old primary email to Emails if not added already - this should be removed + # after the background migration for MR https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70872/ has completed, + # as the primary email is now added to Emails upon confirmation + # Issue to remove that: https://gitlab.com/gitlab-org/gitlab/-/issues/344134 previous_confirmed_at = previous_changes.key?('confirmed_at') ? previous_changes['confirmed_at'][0] : confirmed_at previous_email = previous_changes[:email][0] + if previous_confirmed_at && !emails.exists?(email: previous_email) + # rubocop: disable CodeReuse/ServiceClass + Emails::CreateService.new(self, user: self, email: previous_email).execute(confirmed_at: previous_confirmed_at) + # rubocop: enable CodeReuse/ServiceClass + end - update_emails_with_primary_email(previous_confirmed_at, previous_email) update_invalid_gpg_signatures end end @@ -454,8 +461,8 @@ class User < ApplicationRecord scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) } scope :order_oldest_last_activity, -> { reorder(Gitlab::Database.nulls_first_order('last_activity_on', 'ASC')) } scope :by_id_and_login, ->(id, login) { where(id: id).where('username = LOWER(:login) OR email = LOWER(:login)', login: login) } - scope :dormant, -> { active.where('last_activity_on <= ?', MINIMUM_INACTIVE_DAYS.day.ago.to_date) } - scope :with_no_activity, -> { active.where(last_activity_on: nil) } + scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', MINIMUM_INACTIVE_DAYS.day.ago.to_date) } + scope :with_no_activity, -> { with_state(:active).human_or_service_user.where(last_activity_on: nil) } scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) } scope :get_ids_by_username, -> (username) { where(username: username).pluck(:id) } @@ -466,7 +473,11 @@ class User < ApplicationRecord end def active_for_authentication? - super && can?(:log_in) + return false unless super + + check_ldap_if_ldap_blocked! + + can?(:log_in) end # The messages for these keys are defined in `devise.en.yml` @@ -935,6 +946,8 @@ class User < ApplicationRecord end def unique_email + return if errors.added?(:email, _('has already been taken')) + if !emails.exists?(email: email) && Email.exists?(email: email) errors.add(:email, _('has already been taken')) end @@ -963,24 +976,6 @@ class User < ApplicationRecord skip_reconfirmation! if emails.confirmed.where(email: self.email).any? end - # Note: the use of the Emails services will cause `saves` on the user object, running - # through the callbacks again and can have side effects, such as the `previous_changes` - # hash and `_was` variables getting munged. - # By using an `after_commit` instead of `after_update`, we avoid the recursive callback - # scenario, though it then requires us to use the `previous_changes` hash - # rubocop: disable CodeReuse/ServiceClass - def update_emails_with_primary_email(previous_confirmed_at, previous_email) - primary_email_record = emails.find_by(email: email) - Emails::DestroyService.new(self, user: self).execute(primary_email_record) if primary_email_record - - # the original primary email was confirmed, and we want that to carry over. We don't - # have access to the original confirmation values at this point, so just set confirmed_at - Emails::CreateService.new(self, user: self, email: previous_email).execute(confirmed_at: previous_confirmed_at) - - update_columns(confirmed_at: primary_email_record.confirmed_at) if primary_email_record&.confirmed_at - end - # rubocop: enable CodeReuse/ServiceClass - def update_invalid_gpg_signatures gpg_keys.each(&:update_invalid_gpg_signatures) end @@ -1025,8 +1020,10 @@ class User < ApplicationRecord end # rubocop: enable CodeReuse/ServiceClass - def remove_project_authorizations(project_ids) - project_authorizations.where(project_id: project_ids).delete_all + def remove_project_authorizations(project_ids, per_batch = 1000) + project_ids.each_slice(per_batch) do |project_ids_batch| + project_authorizations.where(project_id: project_ids_batch).delete_all + end end def authorized_projects(min_access_level = nil) @@ -1389,7 +1386,7 @@ class User < ApplicationRecord all_emails << email unless temp_oauth_email? all_emails << private_commit_email if include_private_email all_emails.concat(emails.map(&:email)) - all_emails + all_emails.uniq end def verified_emails(include_private_email: true) @@ -1397,7 +1394,7 @@ class User < ApplicationRecord verified_emails << email if primary_email_verified? verified_emails << private_commit_email if include_private_email verified_emails.concat(emails.confirmed.pluck(:email)) - verified_emails + verified_emails.uniq end def public_verified_emails @@ -1610,8 +1607,6 @@ class User < ApplicationRecord true end - # TODO Please check all callers and remove allow_cross_joins_across_databases, - # when https://gitlab.com/gitlab-org/gitlab/-/issues/336436 is done. def ci_owned_runners @ci_owned_runners ||= begin project_runners = Ci::RunnerProject @@ -1624,7 +1619,7 @@ class User < ApplicationRecord .joins(:runner) .select('ci_runners.*') - Ci::Runner.from_union([project_runners, group_runners]) + Ci::Runner.from_union([project_runners, group_runners]).allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336436') end end @@ -1980,6 +1975,37 @@ class User < ApplicationRecord ci_job_token_scope.present? end + # override from Devise::Confirmable + # + # Add the primary email to user.emails (or confirm it if it was already + # present) when the primary email is confirmed. + def confirm(*args) + saved = super(*args) + return false unless saved + + email_to_confirm = self.emails.find_by(email: self.email) + + if email_to_confirm.present? + email_to_confirm.confirm(*args) + else + add_primary_email_to_emails! + end + + saved + end + + def user_project + strong_memoize(:user_project) do + personal_projects.find_by(path: username, visibility_level: Gitlab::VisibilityLevel::PUBLIC) + end + end + + def user_readme + strong_memoize(:user_readme) do + user_project&.repository&.readme + end + end + protected # override, from Devise::Validatable @@ -2020,6 +2046,12 @@ class User < ApplicationRecord 'en' end + # rubocop: disable CodeReuse/ServiceClass + def add_primary_email_to_emails! + Emails::CreateService.new(self, user: self, email: self.email).execute(confirmed_at: self.confirmed_at) + end + # rubocop: enable CodeReuse/ServiceClass + def notification_email_verified return if notification_email.blank? || temp_oauth_email? @@ -2153,6 +2185,13 @@ class User < ApplicationRecord def ci_job_token_scope_cache_key "users:#{id}:ci:job_token_scope" end + + # An `ldap_blocked` user will be unblocked if LDAP indicates they are allowed. + def check_ldap_if_ldap_blocked! + return unless ::Gitlab::Auth::Ldap::Config.enabled? && ldap_blocked? + + ::Gitlab::Auth::Ldap::Access.allowed?(self) + end end User.prepend_mod_with('User') diff --git a/app/models/user_status.rb b/app/models/user_status.rb index 1c8634e47c3..7a803e8f1f6 100644 --- a/app/models/user_status.rb +++ b/app/models/user_status.rb @@ -22,7 +22,7 @@ class UserStatus < ApplicationRecord enum availability: { not_set: 0, busy: 1 } validates :user, presence: true - validates :emoji, inclusion: { in: Gitlab::Emoji.emojis_names } + validates :emoji, 'gitlab/emoji_name': true validates :message, length: { maximum: 100 }, allow_blank: true scope :scheduled_for_cleanup, -> { where(arel_table[:clear_status_at].lteq(Time.current)) } @@ -33,3 +33,5 @@ class UserStatus < ApplicationRecord self.clear_status_at = CLEAR_STATUS_QUICK_OPTIONS[value]&.from_now end end + +UserStatus.prepend_mod_with('UserStatus') diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb index a4cc43d1f13..556ee03605d 100644 --- a/app/models/users/credit_card_validation.rb +++ b/app/models/users/credit_card_validation.rb @@ -9,6 +9,7 @@ module Users belongs_to :user validates :holder_name, length: { maximum: 26 } + validates :network, length: { maximum: 32 } validates :last_digits, allow_nil: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 9999 } @@ -17,7 +18,7 @@ module Users self.class.where( expiration_date: expiration_date, last_digits: last_digits, - holder_name: holder_name + network: network ).order(credit_card_validated_at: :desc).includes(:user) end end diff --git a/app/models/users/in_product_marketing_email.rb b/app/models/users/in_product_marketing_email.rb index 8fe52ac7ecc..1f1eaacfe5c 100644 --- a/app/models/users/in_product_marketing_email.rb +++ b/app/models/users/in_product_marketing_email.rb @@ -22,7 +22,8 @@ module Users experience: 4, team_short: 5, trial_short: 6, - admin_verify: 7 + admin_verify: 7, + invite_team: 8 }, _suffix: true scope :without_track_and_series, -> (track, series) do diff --git a/app/models/users_statistics.rb b/app/models/users_statistics.rb index a903541f69a..a314ae8920b 100644 --- a/app/models/users_statistics.rb +++ b/app/models/users_statistics.rb @@ -3,12 +3,6 @@ class UsersStatistics < ApplicationRecord scope :order_created_at_desc, -> { order(created_at: :desc) } - class << self - def latest - order_created_at_desc.first - end - end - def active [ without_groups_and_projects, @@ -26,30 +20,26 @@ class UsersStatistics < ApplicationRecord end class << self - def create_current_stats! - stats_by_role = highest_role_stats + def latest + order_created_at_desc.first + end - create!( - without_groups_and_projects: without_groups_and_projects_stats, - with_highest_role_guest: stats_by_role[:guest], - with_highest_role_reporter: stats_by_role[:reporter], - with_highest_role_developer: stats_by_role[:developer], - with_highest_role_maintainer: stats_by_role[:maintainer], - with_highest_role_owner: stats_by_role[:owner], - bots: bot_stats, - blocked: blocked_stats - ) + def create_current_stats! + create!(highest_role_stats) end private def highest_role_stats { - owner: batch_count_for_access_level(Gitlab::Access::OWNER), - maintainer: batch_count_for_access_level(Gitlab::Access::MAINTAINER), - developer: batch_count_for_access_level(Gitlab::Access::DEVELOPER), - reporter: batch_count_for_access_level(Gitlab::Access::REPORTER), - guest: batch_count_for_access_level(Gitlab::Access::GUEST) + without_groups_and_projects: without_groups_and_projects_stats, + with_highest_role_guest: batch_count_for_access_level(Gitlab::Access::GUEST), + with_highest_role_reporter: batch_count_for_access_level(Gitlab::Access::REPORTER), + with_highest_role_developer: batch_count_for_access_level(Gitlab::Access::DEVELOPER), + with_highest_role_maintainer: batch_count_for_access_level(Gitlab::Access::MAINTAINER), + with_highest_role_owner: batch_count_for_access_level(Gitlab::Access::OWNER), + bots: bot_stats, + blocked: blocked_stats } end diff --git a/app/models/webauthn_registration.rb b/app/models/webauthn_registration.rb index 76f8faa11c7..71b50192e29 100644 --- a/app/models/webauthn_registration.rb +++ b/app/models/webauthn_registration.rb @@ -5,7 +5,8 @@ class WebauthnRegistration < ApplicationRecord belongs_to :user - validates :credential_xid, :public_key, :name, :counter, presence: true + validates :credential_xid, :public_key, :counter, presence: true + validates :name, length: { minimum: 0, allow_nil: false } validates :counter, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**32 - 1 } end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 64395f69c42..833d5b9bd34 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -75,6 +75,8 @@ class GroupPolicy < BasePolicy with_scope :subject condition(:has_project_with_service_desk_enabled) { @subject.has_project_with_service_desk_enabled? } + condition(:crm_enabled, score: 0, scope: :subject) { Feature.enabled?(:customer_relations, @subject) } + rule { can?(:read_group) & design_management_enabled }.policy do enable :read_design_activity end @@ -113,8 +115,8 @@ class GroupPolicy < BasePolicy enable :read_group_member enable :read_custom_emoji enable :read_counts - enable :read_organization - enable :read_contact + enable :read_crm_organization + enable :read_crm_contact end rule { ~public_group & ~has_access }.prevent :read_counts @@ -134,8 +136,8 @@ class GroupPolicy < BasePolicy enable :create_package enable :create_package_settings enable :developer_access - enable :admin_organization - enable :admin_contact + enable :admin_crm_organization + enable :admin_crm_contact end rule { reporter }.policy do @@ -252,6 +254,13 @@ class GroupPolicy < BasePolicy enable :read_label end + rule { ~crm_enabled }.policy do + prevent :read_crm_contact + prevent :read_crm_organization + prevent :admin_crm_contact + prevent :admin_crm_organization + end + def access_level(for_any_session: false) return GroupMember::NO_ACCESS if @user.nil? return GroupMember::NO_ACCESS unless user_is_user? diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index 575e532c615..c9c13b29643 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -12,6 +12,9 @@ class IssuePolicy < IssuablePolicy @user && IssueCollection.new([@subject]).visible_to(@user).any? end + desc "User can read contacts belonging to the issue group" + condition(:can_read_crm_contacts, scope: :subject) { @user.can?(:read_crm_contact, @subject.project.group) } + desc "Issue is confidential" condition(:confidential, scope: :subject) { @subject.confidential? } @@ -77,6 +80,10 @@ class IssuePolicy < IssuablePolicy rule { ~persisted & can?(:create_issue) }.policy do enable :set_confidentiality end + + rule { can?(:set_issue_metadata) & can_read_crm_contacts }.policy do + enable :set_issue_crm_contacts + end end IssuePolicy.prepend_mod_with('IssuePolicy') diff --git a/app/policies/packages/helm/file_metadatum_policy.rb b/app/policies/packages/helm/file_metadatum_policy.rb new file mode 100644 index 00000000000..4e0cb9046bf --- /dev/null +++ b/app/policies/packages/helm/file_metadatum_policy.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +module Packages + module Helm + class FileMetadatumPolicy < BasePolicy + delegate { @subject.package_file.package } + end + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 87573c9ad13..d81db357162 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -47,6 +47,9 @@ class ProjectPolicy < BasePolicy desc "Project is archived" condition(:archived, scope: :subject, score: 0) { project.archived? } + desc "Project is in the process of being deleted" + condition(:pending_delete) { project.pending_delete? } + condition(:default_issues_tracker, scope: :subject) { project.default_issues_tracker? } desc "Container registry is disabled" @@ -248,7 +251,7 @@ class ProjectPolicy < BasePolicy enable :read_insights end - rule { can?(:guest_access) & can?(:create_issue) }.enable :create_incident + rule { can?(:reporter_access) & can?(:create_issue) }.enable :create_incident # These abilities are not allowed to admins that are not members of the project, # that's why they are defined separately. @@ -439,7 +442,7 @@ class ProjectPolicy < BasePolicy enable :destroy_freeze_period enable :admin_feature_flags_client enable :update_runners_registration_token - enable :manage_project_google_cloud + enable :admin_project_google_cloud end rule { public_project & metrics_dashboard_allowed }.policy do @@ -457,7 +460,13 @@ class ProjectPolicy < BasePolicy prevent(*readonly_abilities) readonly_features.each do |feature| - prevent(*create_update_admin_destroy(feature)) + prevent(*create_update_admin(feature)) + end + end + + rule { archived & ~pending_delete }.policy do + readonly_features.each do |feature| + prevent(:"destroy_#{feature}") end end diff --git a/app/presenters/award_emoji_presenter.rb b/app/presenters/award_emoji_presenter.rb index 8a7b58e0aba..9a1c8d6934d 100644 --- a/app/presenters/award_emoji_presenter.rb +++ b/app/presenters/award_emoji_presenter.rb @@ -4,24 +4,24 @@ class AwardEmojiPresenter < Gitlab::View::Presenter::Delegated presents ::AwardEmoji, as: :award_emoji def description - as_emoji['description'] + as_emoji&.description end def unicode - as_emoji['unicode'] + as_emoji&.hex end def emoji - as_emoji['moji'] + as_emoji&.codepoints end def unicode_version - Gitlab::Emoji.emoji_unicode_version(award_emoji.name) + as_emoji&.unicode_version end private def as_emoji - @emoji ||= Gitlab::Emoji.emojis[award_emoji.name] || {} + @emoji ||= TanukiEmoji.find_by_alpha_code(award_emoji.name) end end diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index c198859aa4c..5835a77d0b9 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'ipynbdiff' class BlobPresenter < Gitlab::View::Presenter::Delegated include ApplicationHelper @@ -20,6 +21,17 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated ) end + def highlight_transformed(plain: nil) + load_all_blob_data + + Gitlab::Highlight.highlight( + blob.path, + transformed_blob_data, + language: transformed_blob_language, + plain: plain + ) + end + def plain_data return if blob.binary? @@ -50,6 +62,10 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated url_helpers.project_create_blob_path(project, ref_qualified_path) end + def pipeline_editor_path + project_ci_pipeline_editor_path(project, branch_name: blob.commit_id) if can_collaborate_with_project?(project) && blob.path == project.ci_config_path_or_default + end + def fork_and_edit_path fork_path_for_current_user(project, edit_blob_path) end @@ -107,4 +123,21 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated def language blob.language_from_gitattributes end + + def transformed_blob_language + @transformed_blob_language ||= blob.path.ends_with?('.ipynb') ? 'md' : language + end + + def transformed_blob_data + @transformed_blob ||= if blob.path.ends_with?('.ipynb') && blob.transformed_for_diff + IpynbDiff.transform(blob.data, + raise_errors: true, + options: { include_metadata: false, cell_decorator: :percent }) + end + + @transformed_blob ||= blob.data + rescue IpynbDiff::InvalidNotebookError => e + Gitlab::ErrorTracking.log_exception(e) + blob.data + end end diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index b0066e2d7f0..8e1b675d051 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -33,11 +33,7 @@ module Ci end def runner_variables - if Feature.enabled?(:variable_inside_variable, project, default_enabled: :yaml) - variables.sort_and_expand_all(project, keep_undefined: true).to_runner_variables - else - variables.to_runner_variables - end + variables.sort_and_expand_all(keep_undefined: true).to_runner_variables end def refspecs diff --git a/app/presenters/packages/npm/package_presenter.rb b/app/presenters/packages/npm/package_presenter.rb index b9595eb6647..9e3308c2573 100644 --- a/app/presenters/packages/npm/package_presenter.rb +++ b/app/presenters/packages/npm/package_presenter.rb @@ -5,26 +5,37 @@ module Packages class PackagePresenter include API::Helpers::RelatedResourcesHelpers + # Allowed fields are those defined in the abbreviated form + # defined here: https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md#abbreviated-version-object + # except: name, version, dist, dependencies and xDependencies. Those are generated by this presenter. + PACKAGE_JSON_ALLOWED_FIELDS = %w[deprecated bin directories dist engines _hasShrinkwrap].freeze + attr_reader :name, :packages - def initialize(name, packages) + def initialize(name, packages, include_metadata: false) @name = name @packages = packages + @include_metadata = include_metadata end def versions package_versions = {} packages.each_batch do |relation| - relation.including_dependency_links - .preload_files - .each do |package| - package_file = package.package_files.last + batched_packages = relation.including_dependency_links + .preload_files + + if @include_metadata + batched_packages = batched_packages.preload_npm_metadatum + end + + batched_packages.each do |package| + package_file = package.package_files.last - next unless package_file + next unless package_file - package_versions[package.version] = build_package_version(package, package_file) - end + package_versions[package.version] = build_package_version(package, package_file) + end end package_versions @@ -41,14 +52,14 @@ module Packages end def build_package_version(package, package_file) - { + abbreviated_package_json(package).merge( name: package.name, version: package.version, dist: { shasum: package_file.file_sha1, tarball: tarball_url(package, package_file) } - }.tap do |package_version| + ).tap do |package_version| package_version.merge!(build_package_dependencies(package)) end end @@ -79,6 +90,13 @@ module Packages Packages::Tag.for_packages(packages) .preload_package end + + def abbreviated_package_json(package) + return {} unless @include_metadata + + json = package.npm_metadatum&.package_json || {} + json.slice(*PACKAGE_JSON_ALLOWED_FIELDS) + end end end end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index bbd8c715f5c..ec66f9bdd4f 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -249,33 +249,23 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated strong_memoize(:upload_anchor_data) do next unless can_current_user_push_to_default_branch? - experiment(:empty_repo_upload, project: project) do |e| - e.use {} - e.try do - AnchorData.new(false, - statistic_icon('upload') + _('Upload file'), - '#modal-upload-blob', - 'js-upload-file-experiment-trigger', - nil, - nil, - { - 'target_branch' => default_branch_or_main, - 'original_branch' => default_branch_or_main, - 'can_push_code' => 'true', - 'path' => project_create_blob_path(project, default_branch_or_main), - 'project_path' => project.full_path - } - ) - end - e.run - end + AnchorData.new(false, + statistic_icon('upload') + _('Upload file'), + '#modal-upload-blob', + 'js-upload-file-trigger', + nil, + nil, + { + 'target_branch' => default_branch_or_main, + 'original_branch' => default_branch_or_main, + 'can_push_code' => 'true', + 'path' => project_create_blob_path(project, default_branch_or_main), + 'project_path' => project.full_path + } + ) end end - def empty_repo_upload_experiment? - upload_anchor_data.present? - end - def new_file_anchor_data if can_current_user_push_to_default_branch? new_file_path = empty_repo? ? ide_edit_path(project, default_branch_or_main) : project_new_blob_path(project, default_branch_or_main) diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb index c919c7f4c60..dac42af38bf 100644 --- a/app/presenters/release_presenter.rb +++ b/app/presenters/release_presenter.rb @@ -22,8 +22,6 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated end def self_url - return unless can_download_code? - project_release_url(project, release) end @@ -64,7 +62,7 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated delegator_override :name def name - can_download_code? ? release.name : "Release-#{release.id}" + release.name end def download_url(filepath) diff --git a/app/serializers/alert_management/alert_entity.rb b/app/serializers/alert_management/alert_entity.rb new file mode 100644 index 00000000000..6871da44887 --- /dev/null +++ b/app/serializers/alert_management/alert_entity.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module AlertManagement + class AlertEntity < Grape::Entity + expose :iid + expose :title + end +end diff --git a/app/serializers/alert_management/alert_serializer.rb b/app/serializers/alert_management/alert_serializer.rb new file mode 100644 index 00000000000..89815bf6510 --- /dev/null +++ b/app/serializers/alert_management/alert_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module AlertManagement + class AlertSerializer < BaseSerializer + entity AlertManagement::AlertEntity + end +end diff --git a/app/serializers/analytics_summary_entity.rb b/app/serializers/analytics_summary_entity.rb index 62828fc1428..dc42d7f52ad 100644 --- a/app/serializers/analytics_summary_entity.rb +++ b/app/serializers/analytics_summary_entity.rb @@ -4,6 +4,7 @@ class AnalyticsSummaryEntity < Grape::Entity expose :value, safe: true expose :title expose :unit, if: { with_unit: true } + expose :links private diff --git a/app/serializers/merge_request_sidebar_extras_entity.rb b/app/serializers/merge_request_sidebar_extras_entity.rb index 1a0111fe5d0..c211f9c1608 100644 --- a/app/serializers/merge_request_sidebar_extras_entity.rb +++ b/app/serializers/merge_request_sidebar_extras_entity.rb @@ -2,10 +2,10 @@ class MergeRequestSidebarExtrasEntity < IssuableSidebarExtrasEntity expose :assignees do |merge_request, options| - MergeRequestUserEntity.represent(merge_request.assignees, options.merge(merge_request: merge_request)) + MergeRequestUserEntity.represent(merge_request.assignees, options.merge(merge_request: merge_request, type: :assignees)) end expose :reviewers do |merge_request, options| - MergeRequestUserEntity.represent(merge_request.reviewers, options.merge(merge_request: merge_request)) + MergeRequestUserEntity.represent(merge_request.reviewers, options.merge(merge_request: merge_request, type: :reviewers)) end end diff --git a/app/serializers/merge_request_user_entity.rb b/app/serializers/merge_request_user_entity.rb index 66672494bd9..97912656bbb 100644 --- a/app/serializers/merge_request_user_entity.rb +++ b/app/serializers/merge_request_user_entity.rb @@ -16,10 +16,12 @@ class MergeRequestUserEntity < ::API::Entities::UserBasic request.current_user&.can?(:update_merge_request, options[:merge_request]) end - expose :reviewed, if: satisfies(:present?, :allows_reviewers?) do |reviewer, options| - reviewer = options[:merge_request].find_reviewer(reviewer) + expose :reviewed, if: satisfies(:present?, :allows_reviewers?) do |user, options| + find_reviewer_or_assignee(user, options)&.reviewed? + end - reviewer&.reviewed? + expose :attention_requested, if: satisfies(:present?, :allows_reviewers?, :attention_requested_enabled?) do |user, options| + find_reviewer_or_assignee(user, options)&.attention_requested? end expose :approved, if: satisfies(:present?) do |user, options| @@ -27,6 +29,16 @@ class MergeRequestUserEntity < ::API::Entities::UserBasic # makes one query per merge request, whereas #approved_by? makes one per user options[:merge_request].approvals.any? { |app| app.user_id == user.id } end + + private + + def find_reviewer_or_assignee(user, options) + if options[:type] == :reviewers + options[:merge_request].find_reviewer(user) + else + options[:merge_request].find_assignee(user) + end + end end MergeRequestUserEntity.prepend_mod_with('MergeRequestUserEntity') diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 1e4289ce774..bd60d60c8db 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -48,7 +48,7 @@ class MergeRequestWidgetEntity < Grape::Entity end expose :conflicts_docs_path do |merge_request| - help_page_path('user/project/merge_requests/resolve_conflicts.md') + help_page_path('user/project/merge_requests/conflicts.md') end expose :reviewing_and_managing_merge_requests_docs_path do |merge_request| @@ -64,14 +64,12 @@ class MergeRequestWidgetEntity < Grape::Entity end expose :merge_request_add_ci_config_path, if: ->(mr, _) { can_add_ci_config_path?(mr) } do |merge_request| - project_new_blob_path( - merge_request.source_project, - merge_request.source_branch, - file_name: '.gitlab-ci.yml', - commit_message: s_("CommitMessage|Add %{file_name}") % { file_name: Gitlab::FileDetector::PATTERNS[:gitlab_ci] }, - mr_path: merge_request_path(merge_request), - suggest_gitlab_ci_yml: true - ) + project = merge_request.source_project + params = { + branch_name: merge_request.source_branch, + add_new_config_file: true + } + project_ci_pipeline_editor_path(project, params) end expose :user_callouts_path do |_merge_request| @@ -177,7 +175,6 @@ class MergeRequestWidgetEntity < Grape::Entity def can_add_ci_config_path?(merge_request) merge_request.open? && merge_request.source_branch_exists? && - merge_request.source_project&.uses_default_ci_config? && !merge_request.source_project.has_ci? && merge_request.commits_count > 0 && can?(current_user, :read_build, merge_request.source_project) && diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index 8308e954c06..8f189f14dea 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -51,6 +51,10 @@ class NoteEntity < API::Entities::Note SystemNoteHelper.system_note_icon_name(note) end + expose :outdated_line_change_path, if: -> (note, _) { note.system? && note.change_position&.line_range && Feature.enabled?(:display_outdated_line_diff, note.project, default_enabled: :yaml) } do |note| + outdated_line_change_namespace_project_note_path(namespace_id: note.project.namespace, project_id: note.project, id: note) + end + expose :is_noteable_author do |note| note.noteable_author?(request.noteable) end diff --git a/app/serializers/service_field_entity.rb b/app/serializers/service_field_entity.rb index 960e216906e..aad9db5ffea 100644 --- a/app/serializers/service_field_entity.rb +++ b/app/serializers/service_field_entity.rb @@ -4,7 +4,7 @@ class ServiceFieldEntity < Grape::Entity include RequestAwareEntity include Gitlab::Utils::StrongMemoize - expose :type, :name, :placeholder, :required, :choices + expose :type, :name, :placeholder, :required, :choices, :checkbox_label expose :title do |field| non_empty_password?(field) ? field[:non_empty_password_title] : field[:title] diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb index 605ab7a1869..1b377a3d367 100644 --- a/app/services/alert_management/process_prometheus_alert_service.rb +++ b/app/services/alert_management/process_prometheus_alert_service.rb @@ -4,6 +4,7 @@ module AlertManagement class ProcessPrometheusAlertService extend ::Gitlab::Utils::Override include ::AlertManagement::AlertProcessing + include ::AlertManagement::Responses def initialize(project, payload) @project = project @@ -18,7 +19,7 @@ module AlertManagement complete_post_processing_tasks - ServiceResponse.success + success(alert) end private @@ -40,9 +41,5 @@ module AlertManagement def resolving_alert? incoming_payload.resolved? end - - def bad_request - ServiceResponse.error(message: 'Bad Request', http_status: :bad_request) - end end end diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb index 558798c830d..563d4a924fc 100644 --- a/app/services/audit_event_service.rb +++ b/app/services/audit_event_service.rb @@ -119,6 +119,10 @@ class AuditEventService event end + def stream_event_to_external_destinations(_event) + # Defined in EE + end + def log_authentication_event_to_database return unless Gitlab::Database.read_write? && authentication_event? @@ -130,6 +134,7 @@ class AuditEventService def save_or_track(event) event.save! + stream_event_to_external_destinations(event) rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, audit_event_type: event.class.to_s) end diff --git a/app/services/authorized_project_update/project_access_changed_service.rb b/app/services/authorized_project_update/project_access_changed_service.rb new file mode 100644 index 00000000000..62bf4ced1ae --- /dev/null +++ b/app/services/authorized_project_update/project_access_changed_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module AuthorizedProjectUpdate + class ProjectAccessChangedService + def initialize(project_ids) + @project_ids = Array.wrap(project_ids) + end + + def execute(blocking: true) + bulk_args = @project_ids.map { |id| [id] } + + if blocking + AuthorizedProjectUpdate::ProjectRecalculateWorker.bulk_perform_and_wait(bulk_args) + else + AuthorizedProjectUpdate::ProjectRecalculateWorker.bulk_perform_async(bulk_args) # rubocop:disable Scalability/BulkPerformWithContext + end + end + end +end diff --git a/app/services/award_emojis/base_service.rb b/app/services/award_emojis/base_service.rb index a677d03a221..626e26d63b5 100644 --- a/app/services/award_emojis/base_service.rb +++ b/app/services/award_emojis/base_service.rb @@ -14,7 +14,7 @@ module AwardEmojis private def normalize_name(name) - Gitlab::Emoji.normalize_emoji_name(name) + TanukiEmoji.find_by_alpha_code(name)&.name || name end # Provide more error state data than what BaseService allows. diff --git a/app/services/base_service.rb b/app/services/base_service.rb index 275ebcc7bcd..c7380768e32 100644 --- a/app/services/base_service.rb +++ b/app/services/base_service.rb @@ -6,13 +6,7 @@ # and existing service will use these one by one. # After all are migrated, we can remove this class. # -# New services should consider inheriting from: -# -# - BaseContainerService for services scoped by container (project or group) -# - BaseProjectService for services scoped to projects -# - BaseGroupService for services scoped to groups -# -# or, create a new base class and update this comment. +# For new services, please see https://docs.gitlab.com/ee/development/reusing_abstractions.html#service-classes class BaseService include BaseServiceUtility include Gitlab::Experiment::Dsl diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb index 9a301c260a9..d08dc72e30b 100644 --- a/app/services/bulk_imports/file_download_service.rb +++ b/app/services/bulk_imports/file_download_service.rb @@ -7,8 +7,16 @@ module BulkImports REMOTE_FILENAME_PATTERN = %r{filename="(?<filename>[^"]+)"}.freeze FILENAME_SIZE_LIMIT = 255 # chars before the extension - - def initialize(configuration:, relative_url:, dir:, file_size_limit:, allowed_content_types:, filename: nil) + DEFAULT_FILE_SIZE_LIMIT = 5.gigabytes + DEFAULT_ALLOWED_CONTENT_TYPES = %w(application/gzip application/octet-stream).freeze + + def initialize( + configuration:, + relative_url:, + dir:, + file_size_limit: DEFAULT_FILE_SIZE_LIMIT, + allowed_content_types: DEFAULT_ALLOWED_CONTENT_TYPES, + filename: nil) @configuration = configuration @relative_url = relative_url @filename = filename diff --git a/app/services/bulk_update_integration_service.rb b/app/services/bulk_update_integration_service.rb index 45465ba3946..29c4d0cc220 100644 --- a/app/services/bulk_update_integration_service.rb +++ b/app/services/bulk_update_integration_service.rb @@ -12,7 +12,7 @@ class BulkUpdateIntegrationService Integration.where(id: batch_ids).update_all(integration_hash) if integration.data_fields_present? - integration.data_fields.class.where(service_id: batch_ids).update_all(data_fields_hash) + integration.data_fields.class.where(data_fields_foreign_key => batch_ids).update_all(data_fields_hash) end end end @@ -22,6 +22,11 @@ class BulkUpdateIntegrationService attr_reader :integration, :batch + # service_id or integration_id + def data_fields_foreign_key + integration.data_fields.class.reflections['integration'].foreign_key + end + def integration_hash integration.to_integration_hash.tap { |json| json['inherit_from_id'] = integration.inherit_from_id || integration.id } end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index ba9665555cc..540e8f7b970 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -25,6 +25,7 @@ module Ci Gitlab::Ci::Pipeline::Chain::Populate, Gitlab::Ci::Pipeline::Chain::StopDryRun, Gitlab::Ci::Pipeline::Chain::Create, + Gitlab::Ci::Pipeline::Chain::CreateCrossDatabaseAssociations, Gitlab::Ci::Pipeline::Chain::Limit::Activity, Gitlab::Ci::Pipeline::Chain::Limit::JobActivity, Gitlab::Ci::Pipeline::Chain::CancelPendingPipelines, diff --git a/app/services/ci/destroy_pipeline_service.rb b/app/services/ci/destroy_pipeline_service.rb index 476c7523d60..6fbde5d291c 100644 --- a/app/services/ci/destroy_pipeline_service.rb +++ b/app/services/ci/destroy_pipeline_service.rb @@ -12,7 +12,9 @@ module Ci # Ci::Pipeline#destroy triggers `use_fast_destroy :job_artifacts` and # ci_builds has ON DELETE CASCADE to ci_pipelines. The pipeline, the builds, # job and pipeline artifacts all get destroyed here. - pipeline.reset.destroy! + ::Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/345664') do + pipeline.reset.destroy! + end ServiceResponse.success(message: 'Pipeline not found') rescue ActiveRecord::RecordNotFound diff --git a/app/services/ci/external_pull_requests/create_pipeline_service.rb b/app/services/ci/external_pull_requests/create_pipeline_service.rb index dd93ca4708e..66127c94d35 100644 --- a/app/services/ci/external_pull_requests/create_pipeline_service.rb +++ b/app/services/ci/external_pull_requests/create_pipeline_service.rb @@ -16,14 +16,9 @@ module Ci private def create_pipeline_for(pull_request) - if ::Feature.enabled?(:ci_create_external_pr_pipeline_async, project, default_enabled: :yaml) - Ci::ExternalPullRequests::CreatePipelineWorker.perform_async( - project.id, current_user.id, pull_request.id - ) - else - Ci::CreatePipelineService.new(project, current_user, create_params(pull_request)) - .execute(:external_pull_request_event, external_pull_request: pull_request) - end + Ci::ExternalPullRequests::CreatePipelineWorker.perform_async( + project.id, current_user.id, pull_request.id + ) end def create_params(pull_request) diff --git a/app/services/ci/generate_kubeconfig_service.rb b/app/services/ci/generate_kubeconfig_service.rb new file mode 100644 index 00000000000..18f68c0ff09 --- /dev/null +++ b/app/services/ci/generate_kubeconfig_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Ci + class GenerateKubeconfigService + def initialize(build) + @build = build + @template = Gitlab::Kubernetes::Kubeconfig::Template.new + end + + def execute + template.add_cluster( + name: cluster_name, + url: Gitlab::Kas.tunnel_url + ) + + agents.each do |agent| + user = user_name(agent) + + template.add_user( + name: user, + token: agent_token(agent) + ) + + template.add_context( + name: context_name(agent), + cluster: cluster_name, + user: user + ) + end + + template + end + + private + + attr_reader :build, :template + + def agents + build.pipeline.authorized_cluster_agents + end + + def cluster_name + 'gitlab' + end + + def user_name(agent) + ['agent', agent.id].join(delimiter) + end + + def context_name(agent) + [agent.project.full_path, agent.name].join(delimiter) + end + + def agent_token(agent) + ['ci', agent.id, build.token].join(delimiter) + end + + def delimiter + ':' + end + end +end diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb index 9fc7c3b4d40..7c67a2e175d 100644 --- a/app/services/ci/job_artifacts/create_service.rb +++ b/app/services/ci/job_artifacts/create_service.rb @@ -19,6 +19,7 @@ module Ci def initialize(job) @job = job @project = job.project + @pipeline = job.pipeline if ::Feature.enabled?(:ci_update_unlocked_job_artifacts, @project) end def authorize(artifact_type:, filesize: nil) @@ -53,7 +54,7 @@ module Ci private - attr_reader :job, :project + attr_reader :job, :project, :pipeline def validate_requirements(artifact_type:, filesize:) return too_large_error if too_large?(artifact_type, filesize) @@ -85,34 +86,38 @@ module Ci expire_in = params['expire_in'] || Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in - artifact = Ci::JobArtifact.new( + artifact_attributes = { job_id: job.id, project: project, - file: artifacts_file, - file_type: params[:artifact_type], - file_format: params[:artifact_format], - file_sha256: artifacts_file.sha256, - expire_in: expire_in) + expire_in: expire_in + } + + artifact_attributes[:locked] = pipeline.locked if ::Feature.enabled?(:ci_update_unlocked_job_artifacts, project) + + artifact = Ci::JobArtifact.new( + artifact_attributes.merge( + file: artifacts_file, + file_type: params[:artifact_type], + file_format: params[:artifact_format], + file_sha256: artifacts_file.sha256 + ) + ) artifact_metadata = if metadata_file Ci::JobArtifact.new( - job_id: job.id, - project: project, - file: metadata_file, - file_type: :metadata, - file_format: :gzip, - file_sha256: metadata_file.sha256, - expire_in: expire_in) + artifact_attributes.merge( + file: metadata_file, + file_type: :metadata, + file_format: :gzip, + file_sha256: metadata_file.sha256 + ) + ) end [artifact, artifact_metadata] end def parse_artifact(artifact) - unless Feature.enabled?(:ci_synchronous_artifact_parsing, project, default_enabled: true) - return success - end - case artifact.file_type when 'dotenv' then parse_dotenv_artifact(artifact) else success diff --git a/app/services/ci/job_artifacts/destroy_all_expired_service.rb b/app/services/ci/job_artifacts/destroy_all_expired_service.rb index 3e9cc95d135..e4f65736a58 100644 --- a/app/services/ci/job_artifacts/destroy_all_expired_service.rb +++ b/app/services/ci/job_artifacts/destroy_all_expired_service.rb @@ -24,7 +24,11 @@ module Ci # which is scheduled every 7 minutes. def execute in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do - destroy_job_artifacts_with_slow_iteration(Time.current) + if ::Feature.enabled?(:ci_destroy_unlocked_job_artifacts) + destroy_unlocked_job_artifacts(Time.current) + else + destroy_job_artifacts_with_slow_iteration(Time.current) + end end @removed_artifacts_count @@ -32,13 +36,21 @@ module Ci private + def destroy_unlocked_job_artifacts(start_at) + loop_until(timeout: LOOP_TIMEOUT, limit: LOOP_LIMIT) do + artifacts = Ci::JobArtifact.expired_before(start_at).artifact_unlocked.limit(BATCH_SIZE) + service_response = destroy_batch(artifacts) + @removed_artifacts_count += service_response[:destroyed_artifacts_count] + end + end + def destroy_job_artifacts_with_slow_iteration(start_at) Ci::JobArtifact.expired_before(start_at).each_batch(of: BATCH_SIZE, column: :expire_at, order: :desc) do |relation, index| # For performance reasons, join with ci_pipelines after the batch is queried. # See: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47496 artifacts = relation.unlocked - service_response = destroy_batch_async(artifacts) + service_response = destroy_batch(artifacts) @removed_artifacts_count += service_response[:destroyed_artifacts_count] break if loop_timeout?(start_at) @@ -46,7 +58,7 @@ module Ci end end - def destroy_batch_async(artifacts) + def destroy_batch(artifacts) Ci::JobArtifacts::DestroyBatchService.new(artifacts).execute end diff --git a/app/services/ci/job_artifacts/destroy_batch_service.rb b/app/services/ci/job_artifacts/destroy_batch_service.rb index 8536b88ccc0..866b40c32d8 100644 --- a/app/services/ci/job_artifacts/destroy_batch_service.rb +++ b/app/services/ci/job_artifacts/destroy_batch_service.rb @@ -26,15 +26,18 @@ module Ci def execute(update_stats: true) return success(destroyed_artifacts_count: 0, statistics_updates: {}) if @job_artifacts.empty? + destroy_related_records(@job_artifacts) + Ci::DeletedObject.transaction do Ci::DeletedObject.bulk_import(@job_artifacts, @pick_up_at) Ci::JobArtifact.id_in(@job_artifacts.map(&:id)).delete_all - destroy_related_records(@job_artifacts) end + after_batch_destroy_hook(@job_artifacts) + # This is executed outside of the transaction because it depends on Redis update_project_statistics! if update_stats - increment_monitoring_statistics(artifacts_count) + increment_monitoring_statistics(artifacts_count, artifacts_bytes) success(destroyed_artifacts_count: artifacts_count, statistics_updates: affected_project_statistics) @@ -43,9 +46,12 @@ module Ci private - # This method is implemented in EE and it must do only database work + # Overriden in EE def destroy_related_records(artifacts); end + # Overriden in EE + def after_batch_destroy_hook(artifacts); end + # using ! here since this can't be called inside a transaction def update_project_statistics! affected_project_statistics.each do |project, delta| @@ -63,8 +69,9 @@ module Ci end end - def increment_monitoring_statistics(size) - metrics.increment_destroyed_artifacts(size) + def increment_monitoring_statistics(size, bytes) + metrics.increment_destroyed_artifacts_count(size) + metrics.increment_destroyed_artifacts_bytes(bytes) end def metrics @@ -76,6 +83,12 @@ module Ci @job_artifacts.count end end + + def artifacts_bytes + strong_memoize(:artifacts_bytes) do + @job_artifacts.sum { |artifact| artifact.try(:size) || 0 } + end + end end end end diff --git a/app/services/ci/parse_dotenv_artifact_service.rb b/app/services/ci/parse_dotenv_artifact_service.rb index 2ee9be476bb..725ecbcce5d 100644 --- a/app/services/ci/parse_dotenv_artifact_service.rb +++ b/app/services/ci/parse_dotenv_artifact_service.rb @@ -2,8 +2,7 @@ module Ci class ParseDotenvArtifactService < ::BaseService - MAX_ACCEPTABLE_DOTENV_SIZE = 5.kilobytes - MAX_ACCEPTABLE_VARIABLES_COUNT = 20 + include ::Gitlab::Utils::StrongMemoize SizeLimitError = Class.new(StandardError) ParserError = Class.new(StandardError) @@ -27,9 +26,9 @@ module Ci raise ArgumentError, 'Artifact is not dotenv file type' end - unless artifact.file.size < MAX_ACCEPTABLE_DOTENV_SIZE + unless artifact.file.size < dotenv_size_limit raise SizeLimitError, - "Dotenv Artifact Too Big. Maximum Allowable Size: #{MAX_ACCEPTABLE_DOTENV_SIZE}" + "Dotenv Artifact Too Big. Maximum Allowable Size: #{dotenv_size_limit}" end end @@ -45,9 +44,9 @@ module Ci end end - if variables.size > MAX_ACCEPTABLE_VARIABLES_COUNT + if variables.size > dotenv_variable_limit raise SizeLimitError, - "Dotenv files cannot have more than #{MAX_ACCEPTABLE_VARIABLES_COUNT} variables" + "Dotenv files cannot have more than #{dotenv_variable_limit} variables" end variables @@ -60,5 +59,13 @@ module Ci result.each(&:strip!) end + + def dotenv_variable_limit + strong_memoize(:dotenv_variable_limit) { project.actual_limits.dotenv_variables } + end + + def dotenv_size_limit + strong_memoize(:dotenv_size_limit) { project.actual_limits.dotenv_size } + end end end diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index 07cfbb9ce3c..ebb07de9d29 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -63,7 +63,7 @@ module Ci def clone_build(build) project.builds.new(build_attributes(build)).tap do |new_build| - new_build.assign_attributes(::Gitlab::Ci::Pipeline::Seed::Build.environment_attributes_for(new_build)) + new_build.assign_attributes(deployment_attributes_for(new_build, build)) end end @@ -75,6 +75,11 @@ module Ci attributes[:user] = current_user attributes end + + def deployment_attributes_for(new_build, old_build) + ::Gitlab::Ci::Pipeline::Seed::Build + .deployment_attributes_for(new_build, old_build.persisted_environment) + end end end diff --git a/app/services/ci/unlock_artifacts_service.rb b/app/services/ci/unlock_artifacts_service.rb index 7c169cb8395..30da31ba8ec 100644 --- a/app/services/ci/unlock_artifacts_service.rb +++ b/app/services/ci/unlock_artifacts_service.rb @@ -5,22 +5,84 @@ module Ci BATCH_SIZE = 100 def execute(ci_ref, before_pipeline = nil) - query = <<~SQL.squish - UPDATE "ci_pipelines" - SET "locked" = #{::Ci::Pipeline.lockeds[:unlocked]} - WHERE "ci_pipelines"."id" in ( - #{collect_pipelines(ci_ref, before_pipeline).select(:id).to_sql} - LIMIT #{BATCH_SIZE} - FOR UPDATE SKIP LOCKED - ) - RETURNING "ci_pipelines"."id"; - SQL - - loop do - break if Ci::Pipeline.connection.exec_query(query).empty? + results = { + unlocked_pipelines: 0, + unlocked_job_artifacts: 0 + } + + if ::Feature.enabled?(:ci_update_unlocked_job_artifacts, ci_ref.project) + loop do + unlocked_pipelines = [] + unlocked_job_artifacts = [] + + ::Ci::Pipeline.transaction do + unlocked_pipelines = unlock_pipelines(ci_ref, before_pipeline) + unlocked_job_artifacts = unlock_job_artifacts(unlocked_pipelines) + end + + break if unlocked_pipelines.empty? + + results[:unlocked_pipelines] += unlocked_pipelines.length + results[:unlocked_job_artifacts] += unlocked_job_artifacts.length + end + else + query = <<~SQL.squish + UPDATE "ci_pipelines" + SET "locked" = #{::Ci::Pipeline.lockeds[:unlocked]} + WHERE "ci_pipelines"."id" in ( + #{collect_pipelines(ci_ref, before_pipeline).select(:id).to_sql} + LIMIT #{BATCH_SIZE} + FOR UPDATE SKIP LOCKED + ) + RETURNING "ci_pipelines"."id"; + SQL + + loop do + unlocked_pipelines = Ci::Pipeline.connection.exec_query(query) + + break if unlocked_pipelines.empty? + + results[:unlocked_pipelines] += unlocked_pipelines.length + end end + + results end + # rubocop:disable CodeReuse/ActiveRecord + def unlock_job_artifacts_query(pipeline_ids) + ci_job_artifacts = ::Ci::JobArtifact.arel_table + + build_ids = ::Ci::Build.select(:id).where(commit_id: pipeline_ids) + + returning = Arel::Nodes::Grouping.new(ci_job_artifacts[:id]) + + Arel::UpdateManager.new + .table(ci_job_artifacts) + .where(ci_job_artifacts[:job_id].in(Arel.sql(build_ids.to_sql))) + .set([[ci_job_artifacts[:locked], ::Ci::JobArtifact.lockeds[:unlocked]]]) + .to_sql + " RETURNING #{returning.to_sql}" + end + # rubocop:enable CodeReuse/ActiveRecord + + # rubocop:disable CodeReuse/ActiveRecord + def unlock_pipelines_query(ci_ref, before_pipeline) + ci_pipelines = ::Ci::Pipeline.arel_table + + pipelines_scope = ci_ref.pipelines.artifacts_locked + pipelines_scope = pipelines_scope.before_pipeline(before_pipeline) if before_pipeline + pipelines_scope = pipelines_scope.select(:id).limit(BATCH_SIZE).lock('FOR UPDATE SKIP LOCKED') + + returning = Arel::Nodes::Grouping.new(ci_pipelines[:id]) + + Arel::UpdateManager.new + .table(ci_pipelines) + .where(ci_pipelines[:id].in(Arel.sql(pipelines_scope.to_sql))) + .set([[ci_pipelines[:locked], ::Ci::Pipeline.lockeds[:unlocked]]]) + .to_sql + " RETURNING #{returning.to_sql}" + end + # rubocop:enable CodeReuse/ActiveRecord + private def collect_pipelines(ci_ref, before_pipeline) @@ -29,5 +91,17 @@ module Ci pipeline_scope.artifacts_locked end + + def unlock_job_artifacts(pipelines) + return if pipelines.empty? + + ::Ci::JobArtifact.connection.exec_query( + unlock_job_artifacts_query(pipelines.rows.flatten) + ) + end + + def unlock_pipelines(ci_ref, before_pipeline) + ::Ci::Pipeline.connection.exec_query(unlock_pipelines_query(ci_ref, before_pipeline)) + end end end diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb index 3b403f92486..826d9a2eda3 100644 --- a/app/services/ci/update_build_state_service.rb +++ b/app/services/ci/update_build_state_service.rb @@ -73,11 +73,11 @@ module Ci ::Gitlab::Ci::Trace::Checksum.new(build).then do |checksum| unless checksum.valid? metrics.increment_trace_operation(operation: :invalid) - metrics.increment_error_counter(type: :chunks_invalid_checksum) + metrics.increment_error_counter(error_reason: :chunks_invalid_checksum) if checksum.corrupted? metrics.increment_trace_operation(operation: :corrupted) - metrics.increment_error_counter(type: :chunks_invalid_size) + metrics.increment_error_counter(error_reason: :chunks_invalid_size) end next unless log_invalid_chunks? diff --git a/app/services/clusters/agents/refresh_authorization_service.rb b/app/services/clusters/agents/refresh_authorization_service.rb index 7f401eef720..54b90a7304c 100644 --- a/app/services/clusters/agents/refresh_authorization_service.rb +++ b/app/services/clusters/agents/refresh_authorization_service.rb @@ -86,7 +86,7 @@ module Clusters if group_root_ancestor? root_ancestor.all_projects else - ::Project.none + ::Project.id_in(project.id) end end diff --git a/app/services/clusters/applications/prometheus_health_check_service.rb b/app/services/clusters/applications/prometheus_health_check_service.rb deleted file mode 100644 index eda47f56e72..00000000000 --- a/app/services/clusters/applications/prometheus_health_check_service.rb +++ /dev/null @@ -1,87 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class PrometheusHealthCheckService - include Gitlab::Utils::StrongMemoize - include Gitlab::Routing - - def initialize(cluster) - @cluster = cluster - @logger = Gitlab::AppJsonLogger.build - end - - def execute - raise 'Invalid cluster type. Only project types are allowed.' unless @cluster.project_type? - - return unless prometheus_application.installed? - - project = @cluster.clusterable - - @logger.info( - message: 'Prometheus health check', - cluster_id: @cluster.id, - newly_unhealthy: became_unhealthy?, - currently_healthy: currently_healthy?, - was_healthy: was_healthy? - ) - - send_notification(project) if became_unhealthy? - - prometheus_application.update_columns(healthy: currently_healthy?) if health_changed? - end - - private - - def prometheus_application - strong_memoize(:prometheus_application) do - @cluster.application_prometheus - end - end - - def currently_healthy? - strong_memoize(:currently_healthy) do - prometheus_application.prometheus_client.healthy? - end - end - - def became_unhealthy? - strong_memoize(:became_unhealthy) do - (was_healthy? || was_healthy?.nil?) && !currently_healthy? - end - end - - def was_healthy? - strong_memoize(:was_healthy) do - prometheus_application.healthy - end - end - - def health_changed? - was_healthy? != currently_healthy? - end - - def send_notification(project) - notification_payload = build_notification_payload(project) - integration = project.alert_management_http_integrations.active.first - - Projects::Alerting::NotifyService.new(project, notification_payload).execute(integration&.token, integration) - - @logger.info(message: 'Successfully notified of Prometheus newly unhealthy', cluster_id: @cluster.id, project_id: project.id) - end - - def build_notification_payload(project) - cluster_path = namespace_project_cluster_path( - project_id: project.path, - namespace_id: project.namespace.path, - id: @cluster.id - ) - - { - title: "Prometheus is Unhealthy. Cluster Name: #{@cluster.name}", - description: "Prometheus is unhealthy for the cluster: [#{@cluster.name}](#{cluster_path}) attached to project #{project.name}." - } - end - end - end -end diff --git a/app/services/clusters/cleanup/project_namespace_service.rb b/app/services/clusters/cleanup/project_namespace_service.rb index 16254041306..0173f93f625 100644 --- a/app/services/clusters/cleanup/project_namespace_service.rb +++ b/app/services/clusters/cleanup/project_namespace_service.rb @@ -35,9 +35,11 @@ module Clusters end def kubeclient_delete_namespace(kubernetes_namespace) - cluster.kubeclient.delete_namespace(kubernetes_namespace.namespace) + cluster.kubeclient&.delete_namespace(kubernetes_namespace.namespace) rescue Kubeclient::ResourceNotFoundError - # no-op: nothing to delete + # The resources have already been deleted, possibly on a previous attempt that timed out + rescue Gitlab::UrlBlocker::BlockedUrlError + # User gave an invalid cluster from the start, or deleted the endpoint before this job ran end end end diff --git a/app/services/clusters/cleanup/service_account_service.rb b/app/services/clusters/cleanup/service_account_service.rb index baac9e4a9e7..53f968cd409 100644 --- a/app/services/clusters/cleanup/service_account_service.rb +++ b/app/services/clusters/cleanup/service_account_service.rb @@ -16,11 +16,14 @@ module Clusters def delete_gitlab_service_account log_event(:deleting_gitlab_service_account) - cluster.kubeclient.delete_service_account( + cluster.kubeclient&.delete_service_account( ::Clusters::Kubernetes::GITLAB_SERVICE_ACCOUNT_NAME, ::Clusters::Kubernetes::GITLAB_SERVICE_ACCOUNT_NAMESPACE ) rescue Kubeclient::ResourceNotFoundError + # The resources have already been deleted, possibly on a previous attempt that timed out + rescue Gitlab::UrlBlocker::BlockedUrlError + # User gave an invalid cluster from the start, or deleted the endpoint before this job ran end end end diff --git a/app/services/clusters/integrations/prometheus_health_check_service.rb b/app/services/clusters/integrations/prometheus_health_check_service.rb new file mode 100644 index 00000000000..cd06e59449c --- /dev/null +++ b/app/services/clusters/integrations/prometheus_health_check_service.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Clusters + module Integrations + class PrometheusHealthCheckService + include Gitlab::Utils::StrongMemoize + include Gitlab::Routing + + def initialize(cluster) + @cluster = cluster + @logger = Gitlab::AppJsonLogger.build + end + + def execute + raise 'Invalid cluster type. Only project types are allowed.' unless @cluster.project_type? + + return unless prometheus_integration.enabled + + project = @cluster.clusterable + + @logger.info( + message: 'Prometheus health check', + cluster_id: @cluster.id, + newly_unhealthy: became_unhealthy?, + currently_healthy: currently_healthy?, + was_healthy: was_healthy? + ) + + send_notification(project) if became_unhealthy? + + prometheus_integration.update_columns(health_status: current_health_status) if health_changed? + end + + private + + def prometheus_integration + strong_memoize(:prometheus_integration) do + @cluster.integration_prometheus + end + end + + def current_health_status + if currently_healthy? + :healthy + else + :unhealthy + end + end + + def currently_healthy? + strong_memoize(:currently_healthy) do + prometheus_integration.prometheus_client.healthy? + end + end + + def became_unhealthy? + strong_memoize(:became_unhealthy) do + (was_healthy? || was_unknown?) && !currently_healthy? + end + end + + def was_healthy? + strong_memoize(:was_healthy) do + prometheus_integration.healthy? + end + end + + def was_unknown? + strong_memoize(:was_unknown) do + prometheus_integration.unknown? + end + end + + def health_changed? + was_healthy? != currently_healthy? + end + + def send_notification(project) + notification_payload = build_notification_payload(project) + integration = project.alert_management_http_integrations.active.first + + Projects::Alerting::NotifyService.new(project, notification_payload).execute(integration&.token, integration) + + @logger.info(message: 'Successfully notified of Prometheus newly unhealthy', cluster_id: @cluster.id, project_id: project.id) + end + + def build_notification_payload(project) + cluster_path = namespace_project_cluster_path( + project_id: project.path, + namespace_id: project.namespace.path, + id: @cluster.id + ) + + { + title: "Prometheus is Unhealthy. Cluster Name: #{@cluster.name}", + description: "Prometheus is unhealthy for the cluster: [#{@cluster.name}](#{cluster_path}) attached to project #{project.name}." + } + end + end + end +end diff --git a/app/services/concerns/alert_management/responses.rb b/app/services/concerns/alert_management/responses.rb new file mode 100644 index 00000000000..183a831a00a --- /dev/null +++ b/app/services/concerns/alert_management/responses.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module AlertManagement + # Module to hold common response logic for AlertManagement services. + module Responses + def success(alerts) + ServiceResponse.success(payload: { alerts: Array(alerts) }) + end + + def bad_request + ServiceResponse.error(message: 'Bad Request', http_status: :bad_request) + end + + def unauthorized + ServiceResponse.error(message: 'Unauthorized', http_status: :unauthorized) + end + + def unprocessable_entity + ServiceResponse.error(message: 'Unprocessable Entity', http_status: :unprocessable_entity) + end + + def forbidden + ServiceResponse.error(message: 'Forbidden', http_status: :forbidden) + end + end +end diff --git a/app/services/concerns/issues/issue_type_helpers.rb b/app/services/concerns/issues/issue_type_helpers.rb new file mode 100644 index 00000000000..44c20d20ff1 --- /dev/null +++ b/app/services/concerns/issues/issue_type_helpers.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Issues + module IssueTypeHelpers + # @param object [Issue, Project] + # @param issue_type [String, Symbol] + def create_issue_type_allowed?(object, issue_type) + WorkItem::Type.base_types.key?(issue_type.to_s) && + can?(current_user, :"create_#{issue_type}", object) + end + end +end diff --git a/app/services/concerns/members/bulk_create_users.rb b/app/services/concerns/members/bulk_create_users.rb index 4498f40c396..b98917f1396 100644 --- a/app/services/concerns/members/bulk_create_users.rb +++ b/app/services/concerns/members/bulk_create_users.rb @@ -6,7 +6,7 @@ module Members included do class << self - def add_users(source, users, access_level, current_user: nil, expires_at: nil) + def add_users(source, users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil) return [] unless users.present? emails, users, existing_members = parse_users_list(source, users) @@ -18,7 +18,9 @@ module Members access_level, existing_members: existing_members, current_user: current_user, - expires_at: expires_at) + expires_at: expires_at, + tasks_to_be_done: tasks_to_be_done, + tasks_project_id: tasks_project_id) .execute end end diff --git a/app/services/customer_relations/contacts/base_service.rb b/app/services/customer_relations/contacts/base_service.rb index 89f6f2c3f1f..1797e5021a1 100644 --- a/app/services/customer_relations/contacts/base_service.rb +++ b/app/services/customer_relations/contacts/base_service.rb @@ -6,7 +6,7 @@ module CustomerRelations private def allowed? - current_user&.can?(:admin_contact, group) + current_user&.can?(:admin_crm_contact, group) end def error(message) diff --git a/app/services/customer_relations/organizations/base_service.rb b/app/services/customer_relations/organizations/base_service.rb index 8f8480d697c..9b4ccafcea6 100644 --- a/app/services/customer_relations/organizations/base_service.rb +++ b/app/services/customer_relations/organizations/base_service.rb @@ -6,7 +6,7 @@ module CustomerRelations private def allowed? - current_user&.can?(:admin_organization, group) + current_user&.can?(:admin_crm_organization, group) end def error(message) diff --git a/app/services/dependency_proxy/find_or_create_blob_service.rb b/app/services/dependency_proxy/find_or_create_blob_service.rb index 0a6db6e3d34..1b43263a3ba 100644 --- a/app/services/dependency_proxy/find_or_create_blob_service.rb +++ b/app/services/dependency_proxy/find_or_create_blob_service.rb @@ -30,8 +30,7 @@ module DependencyProxy blob.save! end - # Technical debt: change to read_at https://gitlab.com/gitlab-org/gitlab/-/issues/341536 - blob.touch if from_cache + blob.read! if from_cache success(blob: blob, from_cache: from_cache) end diff --git a/app/services/dependency_proxy/find_or_create_manifest_service.rb b/app/services/dependency_proxy/find_or_create_manifest_service.rb index 1976d4d47f4..aeb62be9f3a 100644 --- a/app/services/dependency_proxy/find_or_create_manifest_service.rb +++ b/app/services/dependency_proxy/find_or_create_manifest_service.rb @@ -14,18 +14,18 @@ module DependencyProxy def execute @manifest = @group.dependency_proxy_manifests .active - .find_or_initialize_by_file_name_or_digest(file_name: @file_name, digest: @tag) + .find_by_file_name_or_digest(file_name: @file_name, digest: @tag) head_result = DependencyProxy::HeadManifestService.new(@image, @tag, @token).execute - if cached_manifest_matches?(head_result) - @manifest.touch + return respond if cached_manifest_matches?(head_result) - return success(manifest: @manifest, from_cache: true) + if Feature.enabled?(:dependency_proxy_manifest_workhorse, @group, default_enabled: :yaml) + success(manifest: nil, from_cache: false) + else + pull_new_manifest + respond(from_cache: false) end - - pull_new_manifest - respond(from_cache: false) rescue Timeout::Error, *Gitlab::HTTP::HTTP_ERRORS respond end @@ -34,12 +34,19 @@ module DependencyProxy def pull_new_manifest DependencyProxy::PullManifestService.new(@image, @tag, @token).execute_with_manifest do |new_manifest| - @manifest.update!( + params = { + file_name: @file_name, content_type: new_manifest[:content_type], digest: new_manifest[:digest], file: new_manifest[:file], size: new_manifest[:file].size - ) + } + + if @manifest + @manifest.update!(params) + else + @manifest = @group.dependency_proxy_manifests.create!(params) + end end end @@ -50,9 +57,8 @@ module DependencyProxy end def respond(from_cache: true) - if @manifest.persisted? - # Technical debt: change to read_at https://gitlab.com/gitlab-org/gitlab/-/issues/341536 - @manifest.touch if from_cache + if @manifest + @manifest.read! success(manifest: @manifest, from_cache: from_cache) else diff --git a/app/services/dependency_proxy/head_manifest_service.rb b/app/services/dependency_proxy/head_manifest_service.rb index ecc3eb77399..cd575b83a98 100644 --- a/app/services/dependency_proxy/head_manifest_service.rb +++ b/app/services/dependency_proxy/head_manifest_service.rb @@ -14,7 +14,10 @@ module DependencyProxy response = Gitlab::HTTP.head(manifest_url, headers: auth_headers.merge(Accept: ACCEPT_HEADERS)) if response.success? - success(digest: response.headers['docker-content-digest'], content_type: response.headers['content-type']) + success( + digest: response.headers[DependencyProxy::Manifest::DIGEST_HEADER], + content_type: response.headers['content-type'] + ) else error(response.body, response.code) end diff --git a/app/services/dependency_proxy/pull_manifest_service.rb b/app/services/dependency_proxy/pull_manifest_service.rb index 31494773cc0..e8f0ad6374a 100644 --- a/app/services/dependency_proxy/pull_manifest_service.rb +++ b/app/services/dependency_proxy/pull_manifest_service.rb @@ -20,7 +20,13 @@ module DependencyProxy file.write(response.body) file.flush - yield(success(file: file, digest: response.headers['docker-content-digest'], content_type: response.headers['content-type'])) + yield( + success( + file: file, + digest: response.headers[DependencyProxy::Manifest::DIGEST_HEADER], + content_type: response.headers['content-type'] + ) + ) ensure file.close file.unlink diff --git a/app/services/deployments/archive_in_project_service.rb b/app/services/deployments/archive_in_project_service.rb new file mode 100644 index 00000000000..a593721f390 --- /dev/null +++ b/app/services/deployments/archive_in_project_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Deployments + # This service archives old deploymets and deletes deployment refs for + # keeping the project repository performant. + class ArchiveInProjectService < ::BaseService + BATCH_SIZE = 100 + + def execute + unless ::Feature.enabled?(:deployments_archive, project, default_enabled: :yaml) + return error('Feature flag is not enabled') + end + + deployments = Deployment.archivables_in(project, limit: BATCH_SIZE) + + return success(result: :empty) if deployments.empty? + + ids = deployments.map(&:id) + ref_paths = deployments.map(&:ref_path) + + project.repository.delete_refs(*ref_paths) + project.deployments.id_in(ids).update_all(archived: true) + + success(result: :archived, count: ids.count) + end + end +end diff --git a/app/services/deployments/link_merge_requests_service.rb b/app/services/deployments/link_merge_requests_service.rb index 39fbef5dee2..40385418e48 100644 --- a/app/services/deployments/link_merge_requests_service.rb +++ b/app/services/deployments/link_merge_requests_service.rb @@ -16,7 +16,7 @@ module Deployments # Review apps have the environment type set (e.g. to `review`, though the # exact value may differ). We don't want to link merge requests to review # app deployments, as this is not useful. - return if deployment.environment.environment_type + return unless deployment.environment.should_link_to_merge_requests? # This service is triggered by a Sidekiq worker, which only runs when a # deployment is successful. We add an extra check here in case we ever diff --git a/app/services/design_management/copy_design_collection/copy_service.rb b/app/services/design_management/copy_design_collection/copy_service.rb index c43696442d2..5e557e9ea53 100644 --- a/app/services/design_management/copy_design_collection/copy_service.rb +++ b/app/services/design_management/copy_design_collection/copy_service.rb @@ -181,12 +181,12 @@ module DesignManagement ) end - # TODO Replace `Gitlab::Database.main.bulk_insert` with `BulkInsertSafe` + # TODO Replace `ApplicationRecord.legacy_bulk_insert` with `BulkInsertSafe` # once https://gitlab.com/gitlab-org/gitlab/-/issues/247718 is fixed. # When this is fixed, we can remove the call to # `with_project_iid_supply` above, since the objects will be instantiated # and callbacks (including `ensure_project_iid!`) will fire. - ::Gitlab::Database.main.bulk_insert( # rubocop:disable Gitlab/BulkInsert + ::ApplicationRecord.legacy_bulk_insert( # rubocop:disable Gitlab/BulkInsert DesignManagement::Design.table_name, new_rows, return_ids: true @@ -207,9 +207,9 @@ module DesignManagement ) end - # TODO Replace `Gitlab::Database.main.bulk_insert` with `BulkInsertSafe` + # TODO Replace `ApplicationRecord.legacy_bulk_insert` with `BulkInsertSafe` # once https://gitlab.com/gitlab-org/gitlab/-/issues/247718 is fixed. - ::Gitlab::Database.main.bulk_insert( # rubocop:disable Gitlab/BulkInsert + ::ApplicationRecord.legacy_bulk_insert( # rubocop:disable Gitlab/BulkInsert DesignManagement::Version.table_name, new_rows, return_ids: true @@ -239,7 +239,7 @@ module DesignManagement end # We cannot use `BulkInsertSafe` because of the uploader mounted in `Action`. - ::Gitlab::Database.main.bulk_insert( # rubocop:disable Gitlab/BulkInsert + ::ApplicationRecord.legacy_bulk_insert( # rubocop:disable Gitlab/BulkInsert DesignManagement::Action.table_name, new_rows ) @@ -278,7 +278,7 @@ module DesignManagement # We cannot use `BulkInsertSafe` due to the LfsObjectsProject#update_project_statistics # callback that fires after_commit. - ::Gitlab::Database.main.bulk_insert( # rubocop:disable Gitlab/BulkInsert + ::ApplicationRecord.legacy_bulk_insert( # rubocop:disable Gitlab/BulkInsert LfsObjectsProject.table_name, new_rows, on_conflict: :do_nothing # Upsert diff --git a/app/services/emails/destroy_service.rb b/app/services/emails/destroy_service.rb index d10833e66cb..d211c3470b2 100644 --- a/app/services/emails/destroy_service.rb +++ b/app/services/emails/destroy_service.rb @@ -3,6 +3,8 @@ module Emails class DestroyService < ::Emails::BaseService def execute(email) + raise StandardError, 'Cannot delete primary email' if email.user_primary_email? + email.destroy && update_secondary_emails!(email.email) end diff --git a/app/services/error_tracking/collect_error_service.rb b/app/services/error_tracking/collect_error_service.rb index 477453a693e..304e3898ee5 100644 --- a/app/services/error_tracking/collect_error_service.rb +++ b/app/services/error_tracking/collect_error_service.rb @@ -15,7 +15,7 @@ module ErrorTracking ) # The payload field contains all the data on error including stacktrace in jsonb. - # Together with occured_at these are 2 main attributes that we need to save here. + # Together with occurred_at these are 2 main attributes that we need to save here. error.events.create!( environment: event['environment'], description: exception['value'], @@ -28,7 +28,18 @@ module ErrorTracking private def event - params[:event] + @event ||= format_event(params[:event]) + end + + def format_event(event) + # Some SDK send exception payload as Array. For exmple Go lang SDK. + # We need to convert it to hash format we expect. + if event['exception'].is_a?(Array) + exception = event['exception'] + event['exception'] = { 'values' => exception } + end + + event end def exception diff --git a/app/services/google_cloud/service_accounts_service.rb b/app/services/google_cloud/service_accounts_service.rb new file mode 100644 index 00000000000..29ed69693b0 --- /dev/null +++ b/app/services/google_cloud/service_accounts_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module GoogleCloud + ## + # GCP keys used to store Google Cloud Service Accounts + GCP_KEYS = %w[GCP_PROJECT_ID GCP_SERVICE_ACCOUNT GCP_SERVICE_ACCOUNT_KEY].freeze + + ## + # This service deals with GCP Service Accounts in GitLab + + class ServiceAccountsService < ::BaseService + ## + # Find GCP Service Accounts in a GitLab project + # + # This method looks up GitLab project's CI vars + # and returns Google Cloud Service Accounts combinations + # aligning GitLab project and environment to GCP projects + + def find_for_project + group_vars_by_environment.map do |environment_scope, value| + { + environment: environment_scope, + gcp_project: value['GCP_PROJECT_ID'], + service_account_exists: value['GCP_SERVICE_ACCOUNT'].present?, + service_account_key_exists: value['GCP_SERVICE_ACCOUNT_KEY'].present? + } + end + end + + private + + def group_vars_by_environment + filtered_vars = @project.variables.filter { |variable| GCP_KEYS.include? variable.key } + filtered_vars.each_with_object({}) do |variable, grouped| + grouped[variable.environment_scope] ||= {} + grouped[variable.environment_scope][variable.key] = variable.value + end + end + end +end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index f900927793a..da3cebc2e6d 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -6,6 +6,7 @@ module Groups @current_user = user @params = params.dup @chat_team = @params.delete(:create_chat_team) + @create_event = @params.delete(:create_event) end def execute @@ -42,15 +43,26 @@ module Groups end end + after_create_hook + @group end private + attr_reader :create_event + def after_build_hook(group, params) # overridden in EE end + def after_create_hook + if group.persisted? && group.root? + delay = Namespaces::InviteTeamEmailService::DELIVERY_DELAY_IN_MINUTES + Namespaces::InviteTeamEmailWorker.perform_in(delay, group.id, current_user.id) + end + end + def remove_unallowed_params params.delete(:default_branch_protection) unless can?(current_user, :create_group_with_default_branch_protection) params.delete(:allow_mfa_for_subgroups) diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb index f9db552f743..c8c2124078d 100644 --- a/app/services/groups/import_export/import_service.rb +++ b/app/services/groups/import_export/import_service.rb @@ -14,7 +14,7 @@ module Groups def async_execute group_import_state = GroupImportState.safe_find_or_create_by!(group: group, user: current_user) - jid = GroupImportWorker.perform_async(current_user.id, group.id) + jid = GroupImportWorker.with_status.perform_async(current_user.id, group.id) if jid.present? group_import_state.update!(jid: jid) diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index 334083a859f..cd89eb799dc 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -175,21 +175,18 @@ module Groups end def refresh_project_authorizations - ProjectAuthorization.where(project_id: @group.all_projects.select(:id)).delete_all # rubocop: disable CodeReuse/ActiveRecord + projects_to_update = Set.new - # refresh authorized projects for current_user immediately - current_user.refresh_authorized_projects - - # schedule refreshing projects for all the members of the group - @group.refresh_members_authorized_projects + # All projects in this hierarchy need to have their project authorizations recalculated + @group.all_projects.each_batch { |prjs| projects_to_update.merge(prjs.ids) } # rubocop: disable CodeReuse/ActiveRecord # When a group is transferred, it also affects who gets access to the projects shared to # the subgroups within its hierarchy, so we also schedule jobs that refresh authorizations for all such shared projects. - project_group_shares_within_the_hierarchy = ProjectGroupLink.in_group(group.self_and_descendants.select(:id)) - - project_group_shares_within_the_hierarchy.find_each do |project_group_link| - AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project_group_link.project_id) + ProjectGroupLink.in_group(@group.self_and_descendants.select(:id)).each_batch do |project_group_links| + projects_to_update.merge(project_group_links.pluck(:project_id)) # rubocop: disable CodeReuse/ActiveRecord end + + AuthorizedProjectUpdate::ProjectAccessChangedService.new(projects_to_update.to_a).execute unless projects_to_update.empty? end def raise_transfer_error(message) @@ -199,7 +196,7 @@ module Groups def localized_error_messages { database_not_supported: s_('TransferGroup|Database is not supported.'), - namespace_with_same_path: s_('TransferGroup|The parent group already has a subgroup with the same path.'), + namespace_with_same_path: s_('TransferGroup|The parent group already has a subgroup or a project with the same path.'), group_is_already_root: s_('TransferGroup|Group is already a root group.'), same_parent_as_current: s_('TransferGroup|Group is already associated to the parent group.'), invalid_policies: s_("TransferGroup|You don't have enough permissions."), diff --git a/app/services/import/github/notes/create_service.rb b/app/services/import/github/notes/create_service.rb new file mode 100644 index 00000000000..79145f42313 --- /dev/null +++ b/app/services/import/github/notes/create_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Import + module Github + module Notes + class CreateService < ::Notes::CreateService + # Github does not have support to quick actions in notes (like /assign) + # Therefore, when importing notes we skip the quick actions processing + def quick_actions_supported?(_note) + false + end + end + end + end +end diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb index d8b639bb422..279d3051848 100644 --- a/app/services/issuable/clone/attributes_rewriter.rb +++ b/app/services/issuable/clone/attributes_rewriter.rb @@ -99,7 +99,7 @@ module Issuable yield(event) end.compact - Gitlab::Database.main.bulk_insert(table_name, events) # rubocop:disable Gitlab/BulkInsert + ApplicationRecord.legacy_bulk_insert(table_name, events) # rubocop:disable Gitlab/BulkInsert end end diff --git a/app/services/issuable_links/list_service.rb b/app/services/issuable_links/list_service.rb index fe9678dcc32..cc41a65379a 100644 --- a/app/services/issuable_links/list_service.rb +++ b/app/services/issuable_links/list_service.rb @@ -12,11 +12,16 @@ module IssuableLinks end def execute - serializer.new(current_user: current_user, issuable: issuable).represent(child_issuables) + serializer.new(current_user: current_user, issuable: issuable) + .represent(child_issuables, serializer_options) end private + def serializer_options + {} + end + def serializer raise NotImplementedError end diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 6dce9fd6e73..efb5de5b17c 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -3,6 +3,7 @@ module Issues class BaseService < ::IssuableBaseService include IncidentManagement::UsageData + include IssueTypeHelpers def hook_data(issue, action, old_associations: {}) hook_data = issue.to_hook_data(current_user, old_associations: old_associations) @@ -44,7 +45,7 @@ module Issues def filter_params(issue) super - params.delete(:issue_type) unless issue_type_allowed?(issue) + params.delete(:issue_type) unless create_issue_type_allowed?(issue, params[:issue_type]) filter_incident_label(issue) if params[:issue_type] moved_issue = params.delete(:moved_issue) @@ -89,12 +90,6 @@ module Issues Milestones::IssuesCountService.new(milestone).delete_cache end - # @param object [Issue, Project] - def issue_type_allowed?(object) - WorkItem::Type.base_types.key?(params[:issue_type]) && - can?(current_user, :"create_#{params[:issue_type]}", object) - end - # @param issue [Issue] def filter_incident_label(issue) return unless add_incident_label?(issue) || remove_incident_label?(issue) diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 7fdc8daf15c..8fd844c4886 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -80,7 +80,7 @@ module Issues ] allowed_params << :milestone_id if can?(current_user, :admin_issue, project) - allowed_params << :issue_type if issue_type_allowed?(project) + allowed_params << :issue_type if create_issue_type_allowed?(project, params[:issue_type]) params.slice(*allowed_params) end diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index ac846c769a3..65f143d0b21 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -62,6 +62,7 @@ module Issues def perform_incident_management_actions(issue) resolve_alert(issue) + resolve_incident(issue) end def close_external_issue(issue, closed_via) @@ -91,6 +92,14 @@ module Issues end end + def resolve_incident(issue) + return unless issue.incident? + + status = issue.incident_management_issuable_escalation_status || issue.build_incident_management_issuable_escalation_status + + SystemNoteService.resolve_incident_status(issue, current_user) if status.resolve + end + def store_first_mentioned_in_commit_at(issue, merge_request, max_commit_lookup: 100) metrics = issue.metrics return if metrics.nil? || metrics.first_mentioned_in_commit_at diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index fcedd1c1c8d..fa8d380404b 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -6,7 +6,7 @@ module Issues prepend RateLimitedService rate_limit key: :issues_create, - opts: { scope: [:project, :current_user], users_allowlist: -> { [User.support_bot.username] } } + opts: { scope: [:project, :current_user, :external_author] } # NOTE: For Issues::CreateService, we require the spam_params and do not default it to nil, because # spam_checking is likely to be necessary. However, if there is not a request available in scope @@ -25,6 +25,10 @@ module Issues create(@issue, skip_system_notes: skip_system_notes) end + def external_author + params[:external_author] # present when creating an issue using service desk (email: from) + end + def before_create(issue) Spam::SpamActionService.new( spammable: issue, diff --git a/app/services/issues/set_crm_contacts_service.rb b/app/services/issues/set_crm_contacts_service.rb new file mode 100644 index 00000000000..13fe30b5ac8 --- /dev/null +++ b/app/services/issues/set_crm_contacts_service.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Issues + class SetCrmContactsService < ::BaseProjectService + attr_accessor :issue, :errors + + MAX_ADDITIONAL_CONTACTS = 6 + + def execute(issue) + @issue = issue + @errors = [] + + return error_no_permissions unless allowed? + return error_invalid_params unless valid_params? + + determine_changes if params[:crm_contact_ids] + + return error_too_many if too_many? + + add_contacts if params[:add_crm_contact_ids] + remove_contacts if params[:remove_crm_contact_ids] + + if issue.valid? + ServiceResponse.success(payload: issue) + else + # The default error isn't very helpful: "Issue customer relations contacts is invalid" + issue.errors.delete(:issue_customer_relations_contacts) + issue.errors.add(:issue_customer_relations_contacts, errors.to_sentence) + ServiceResponse.error(payload: issue, message: issue.errors.full_messages) + end + end + + private + + def determine_changes + existing_contact_ids = issue.issue_customer_relations_contacts.map(&:contact_id) + params[:add_crm_contact_ids] = params[:crm_contact_ids] - existing_contact_ids + params[:remove_crm_contact_ids] = existing_contact_ids - params[:crm_contact_ids] + end + + def add_contacts + params[:add_crm_contact_ids].uniq.each do |contact_id| + issue_contact = issue.issue_customer_relations_contacts.create(contact_id: contact_id) + + unless issue_contact.persisted? + # The validation ensures that the id exists and the user has permission + errors << "#{contact_id}: The resource that you are attempting to access does not exist or you don't have permission to perform this action" + end + end + end + + def remove_contacts + issue.issue_customer_relations_contacts + .where(contact_id: params[:remove_crm_contact_ids]) # rubocop: disable CodeReuse/ActiveRecord + .delete_all + end + + def allowed? + current_user&.can?(:set_issue_crm_contacts, issue) + end + + def valid_params? + set_present? ^ add_or_remove_present? + end + + def set_present? + params[:crm_contact_ids].present? + end + + def add_or_remove_present? + params[:add_crm_contact_ids].present? || params[:remove_crm_contact_ids].present? + end + + def too_many? + params[:add_crm_contact_ids] && params[:add_crm_contact_ids].length > MAX_ADDITIONAL_CONTACTS + end + + def error_no_permissions + ServiceResponse.error(message: ['You have insufficient permissions to set customer relations contacts for this issue']) + end + + def error_invalid_params + ServiceResponse.error(message: ['You cannot combine crm_contact_ids with add_crm_contact_ids or remove_crm_contact_ids']) + end + + def error_too_many + ServiceResponse.error(payload: issue, message: ["You can only add up to #{MAX_ADDITIONAL_CONTACTS} contacts at one time"]) + end + end +end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index d120b007af2..824a609dfb9 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -80,7 +80,7 @@ module Issues todo_service.reassigned_assignable(issue, current_user, old_assignees) track_incident_action(current_user, issue, :incident_assigned) - if Gitlab::ActionCable::Config.in_app? || Feature.enabled?(:broadcast_issue_updates, issue.project) + if Feature.enabled?(:broadcast_issue_updates, issue.project, default_enabled: :yaml) GraphqlTriggers.issuable_assignees_updated(issue) end end diff --git a/app/services/jira/requests/base.rb b/app/services/jira/requests/base.rb index 56484075d08..a16f8bbd367 100644 --- a/app/services/jira/requests/base.rb +++ b/app/services/jira/requests/base.rb @@ -67,9 +67,19 @@ module Jira ServiceResponse.error(message: error_message(e)) end + def auth_docs_link_start + auth_docs_link_url = Rails.application.routes.url_helpers.help_page_path('integration/jira', anchor: 'authentication-in-jira') + '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auth_docs_link_url } + end + + def config_docs_link_start + config_docs_link_url = Rails.application.routes.url_helpers.help_page_path('integration/jira/configure') + '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: config_docs_link_url } + end + def error_message(error) reportable_error_message(error) || - s_('JiraRequest|An error occurred while requesting data from Jira. Check your Jira integration configuration and try again.') + s_('JiraRequest|An error occurred while requesting data from Jira. Check your %{docs_link_start}Jira integration configuration%{docs_link_end} and try again.').html_safe % { docs_link_start: config_docs_link_start, docs_link_end: '</a>'.html_safe } end # Returns a user-facing error message if possible, otherwise `nil`. @@ -93,11 +103,11 @@ module Jira def reportable_jira_ruby_error_message(error) case error.message when 'Unauthorized' - s_('JiraRequest|The credentials for accessing Jira are not valid. Check your Jira integration credentials and try again.') + s_('JiraRequest|The credentials for accessing Jira are not valid. Check your %{docs_link_start}Jira integration credentials%{docs_link_end} and try again.').html_safe % { docs_link_start: auth_docs_link_start, docs_link_end: '</a>'.html_safe } when 'Forbidden' - s_('JiraRequest|The credentials for accessing Jira are not allowed to access the data. Check your Jira integration credentials and try again.') + s_('JiraRequest|The credentials for accessing Jira are not allowed to access the data. Check your %{docs_link_start}Jira integration credentials%{docs_link_end} and try again.').html_safe % { docs_link_start: auth_docs_link_start, docs_link_end: '</a>'.html_safe } when 'Bad Request' - s_('JiraRequest|An error occurred while requesting data from Jira. Check your Jira integration configuration and try again.') + s_('JiraRequest|An error occurred while requesting data from Jira. Check your %{docs_link_start}Jira integration configuration%{docs_link_end} and try again.').html_safe % { docs_link_start: config_docs_link_start, docs_link_end: '</a>'.html_safe } when /errorMessages/ jira_ruby_json_error_message(error.message) end @@ -111,7 +121,7 @@ module Jira messages = Rails::Html::FullSanitizer.new.sanitize(messages).presence return unless messages - s_('JiraRequest|An error occurred while requesting data from Jira: %{messages}. Check your Jira integration configuration and try again.') % { messages: messages } + s_('JiraRequest|An error occurred while requesting data from Jira: %{messages}. Check your %{docs_link_start}Jira integration configuration%{docs_link_end} and try again.').html_safe % { messages: messages, docs_link_start: config_docs_link_start, docs_link_end: '</a>'.html_safe } rescue JSON::ParserError end end diff --git a/app/services/labels/transfer_service.rb b/app/services/labels/transfer_service.rb index a05090d6bfb..19d419609a5 100644 --- a/app/services/labels/transfer_service.rb +++ b/app/services/labels/transfer_service.rb @@ -50,21 +50,32 @@ module Labels # rubocop: disable CodeReuse/ActiveRecord def group_labels_applied_to_issues - @group_labels_applied_to_issues ||= Label.joins(:issues) - .where( - issues: { project_id: project.id }, - labels: { group_id: old_group.self_and_ancestors } - ) + @labels_applied_to_issues ||= if use_optimized_group_labels_query? + Label.joins(:issues) + .joins("INNER JOIN namespaces on namespaces.id = labels.group_id AND namespaces.type = 'Group'" ) + .where(issues: { project_id: project.id }).reorder(nil) + else + Label.joins(:issues).where( + issues: { project_id: project.id }, + labels: { group_id: old_group.self_and_ancestors } + ) + end end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord def group_labels_applied_to_merge_requests - @group_labels_applied_to_merge_requests ||= Label.joins(:merge_requests) - .where( - merge_requests: { target_project_id: project.id }, - labels: { group_id: old_group.self_and_ancestors } - ) + @labels_applied_to_mrs ||= if use_optimized_group_labels_query? + Label.joins(:merge_requests) + .joins("INNER JOIN namespaces on namespaces.id = labels.group_id AND namespaces.type = 'Group'" ) + .where(merge_requests: { target_project_id: project.id }).reorder(nil) + else + Label.joins(:merge_requests) + .where( + merge_requests: { target_project_id: project.id }, + labels: { group_id: old_group.self_and_ancestors } + ) + end end # rubocop: enable CodeReuse/ActiveRecord @@ -88,5 +99,9 @@ module Labels .update_all(label_id: new_label_id) end # rubocop: enable CodeReuse/ActiveRecord + + def use_optimized_group_labels_query? + Feature.enabled?(:use_optimized_group_labels_query, project.root_namespace, default_enabled: :yaml) + end end end diff --git a/app/services/loose_foreign_keys/batch_cleaner_service.rb b/app/services/loose_foreign_keys/batch_cleaner_service.rb new file mode 100644 index 00000000000..06c05e8ff54 --- /dev/null +++ b/app/services/loose_foreign_keys/batch_cleaner_service.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module LooseForeignKeys + class BatchCleanerService + def initialize(parent_klass:, deleted_parent_records:, modification_tracker: LooseForeignKeys::ModificationTracker.new, models_by_table_name:) + @parent_klass = parent_klass + @deleted_parent_records = deleted_parent_records + @modification_tracker = modification_tracker + @models_by_table_name = models_by_table_name + @deleted_records_counter = Gitlab::Metrics.counter( + :loose_foreign_key_processed_deleted_records, + 'The number of processed loose foreign key deleted records' + ) + end + + def execute + parent_klass.loose_foreign_key_definitions.each do |foreign_key_definition| + run_cleaner_service(foreign_key_definition, with_skip_locked: true) + break if modification_tracker.over_limit? + + run_cleaner_service(foreign_key_definition, with_skip_locked: false) + break if modification_tracker.over_limit? + end + + return if modification_tracker.over_limit? + + # At this point, all associations are cleaned up, we can update the status of the parent records + update_count = LooseForeignKeys::DeletedRecord.mark_records_processed(deleted_parent_records) + + deleted_records_counter.increment({ table: parent_klass.table_name, db_config_name: LooseForeignKeys::DeletedRecord.connection.pool.db_config.name }, update_count) + end + + private + + attr_reader :parent_klass, :deleted_parent_records, :modification_tracker, :models_by_table_name, :deleted_records_counter + + def record_result(cleaner, result) + if cleaner.async_delete? + modification_tracker.add_deletions(result[:table], result[:affected_rows]) + elsif cleaner.async_nullify? + modification_tracker.add_updates(result[:table], result[:affected_rows]) + end + end + + def run_cleaner_service(foreign_key_definition, with_skip_locked:) + cleaner = CleanerService.new( + model: models_by_table_name.fetch(foreign_key_definition.to_table), + foreign_key_definition: foreign_key_definition, + deleted_parent_records: deleted_parent_records, + with_skip_locked: with_skip_locked + ) + + loop do + result = cleaner.execute + record_result(cleaner, result) + + break if modification_tracker.over_limit? || result[:affected_rows] == 0 + end + end + end +end diff --git a/app/services/loose_foreign_keys/cleaner_service.rb b/app/services/loose_foreign_keys/cleaner_service.rb new file mode 100644 index 00000000000..8fe053e2edf --- /dev/null +++ b/app/services/loose_foreign_keys/cleaner_service.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module LooseForeignKeys + # rubocop: disable CodeReuse/ActiveRecord + class CleanerService + DELETE_LIMIT = 1000 + UPDATE_LIMIT = 500 + + delegate :connection, to: :model + + def initialize(model:, foreign_key_definition:, deleted_parent_records:, with_skip_locked: false) + @model = model + @foreign_key_definition = foreign_key_definition + @deleted_parent_records = deleted_parent_records + @with_skip_locked = with_skip_locked + end + + def execute + result = connection.execute(build_query) + + { affected_rows: result.cmd_tuples, table: foreign_key_definition.to_table } + end + + def async_delete? + foreign_key_definition.on_delete == :async_delete + end + + def async_nullify? + foreign_key_definition.on_delete == :async_nullify + end + + private + + attr_reader :model, :foreign_key_definition, :deleted_parent_records, :with_skip_locked + + def build_query + query = if async_delete? + delete_query + elsif async_nullify? + update_query + else + raise "Invalid on_delete argument: #{foreign_key_definition.on_delete}" + end + + unless query.include?(%{"#{foreign_key_definition.column}" IN (}) + raise("FATAL: foreign key condition is missing from the generated query: #{query}") + end + + query + end + + def arel_table + @arel_table ||= model.arel_table + end + + def primary_keys + @primary_keys ||= connection.primary_keys(model.table_name).map { |key| arel_table[key] } + end + + def quoted_table_name + @quoted_table_name ||= Arel.sql(connection.quote_table_name(model.table_name)) + end + + def delete_query + query = Arel::DeleteManager.new + query.from(quoted_table_name) + + add_in_query_with_limit(query, DELETE_LIMIT) + end + + def update_query + query = Arel::UpdateManager.new + query.table(quoted_table_name) + query.set([[arel_table[foreign_key_definition.column], nil]]) + + add_in_query_with_limit(query, UPDATE_LIMIT) + end + + # IN query with one or composite primary key + # WHERE (primary_key1, primary_key2) IN (subselect) + def add_in_query_with_limit(query, limit) + columns = Arel::Nodes::Grouping.new(primary_keys) + query.where(columns.in(in_query_with_limit(limit))).to_sql + end + + # Builds the following sub-query + # SELECT primary_keys FROM table WHERE foreign_key IN (1, 2, 3) LIMIT N + def in_query_with_limit(limit) + in_query = Arel::SelectManager.new + in_query.from(quoted_table_name) + in_query.where(arel_table[foreign_key_definition.column].in(deleted_parent_records.map(&:primary_key_value))) + in_query.projections = primary_keys + in_query.take(limit) + in_query.lock(Arel.sql('FOR UPDATE SKIP LOCKED')) if with_skip_locked + in_query + end + end + # rubocop: enable CodeReuse/ActiveRecord +end diff --git a/app/services/loose_foreign_keys/process_deleted_records_service.rb b/app/services/loose_foreign_keys/process_deleted_records_service.rb new file mode 100644 index 00000000000..735fc8a2415 --- /dev/null +++ b/app/services/loose_foreign_keys/process_deleted_records_service.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module LooseForeignKeys + class ProcessDeletedRecordsService + BATCH_SIZE = 1000 + + def initialize(connection:) + @connection = connection + end + + def execute + modification_tracker = ModificationTracker.new + + tracked_tables.cycle do |table| + records = load_batch_for_table(table) + + if records.empty? + tracked_tables.delete(table) + next + end + + break if modification_tracker.over_limit? + + model = find_parent_model!(table) + + LooseForeignKeys::BatchCleanerService + .new(parent_klass: model, + deleted_parent_records: records, + modification_tracker: modification_tracker, + models_by_table_name: models_by_table_name) + .execute + + break if modification_tracker.over_limit? + end + + modification_tracker.stats + end + + private + + attr_reader :connection + + def load_batch_for_table(table) + fully_qualified_table_name = "#{current_schema}.#{table}" + LooseForeignKeys::DeletedRecord.load_batch_for_table(fully_qualified_table_name, BATCH_SIZE) + end + + def find_parent_model!(table) + models_by_table_name.fetch(table) + end + + def current_schema + @current_schema = connection.current_schema + end + + def tracked_tables + @tracked_tables ||= models_by_table_name + .select { |table_name, model| model.respond_to?(:loose_foreign_key_definitions) } + .keys + end + + def models_by_table_name + @models_by_table_name ||= begin + all_models + .select(&:base_class?) + .index_by(&:table_name) + end + end + + def all_models + ApplicationRecord.descendants + end + end +end diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 0cc62e661a3..cb905e01613 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -63,10 +63,14 @@ module Members invites, params[:access_level], expires_at: params[:expires_at], - current_user: current_user + current_user: current_user, + tasks_to_be_done: params[:tasks_to_be_done], + tasks_project_id: params[:tasks_project_id] ) members.each { |member| process_result(member) } + + create_tasks_to_be_done end def process_result(member) @@ -112,6 +116,19 @@ module Members end end + def create_tasks_to_be_done + return unless experiment(:invite_members_for_task).enabled? + return if params[:tasks_to_be_done].blank? || params[:tasks_project_id].blank? + + valid_members = members.select { |member| member.valid? && member.member_task.valid? } + return unless valid_members.present? + + # We can take the first `member_task` here, since all tasks will have the same attributes needed + # for the `TasksToBeDone::CreateWorker`, ie. `project` and `tasks_to_be_done`. + member_task = valid_members[0].member_task + TasksToBeDone::CreateWorker.perform_async(member_task.id, current_user.id, valid_members.map(&:user_id)) + end + def areas_of_focus params[:areas_of_focus] || [] end diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb index 7b0bebff760..f2c8a6f20a1 100644 --- a/app/services/members/creator_service.rb +++ b/app/services/members/creator_service.rb @@ -4,6 +4,8 @@ module Members # This class serves as more of an app-wide way we add/create members # All roads to add members should take this path. class CreatorService + include Gitlab::Experiment::Dsl + class << self def parsed_access_level(access_level) access_levels.fetch(access_level) { access_level.to_i } @@ -24,6 +26,7 @@ module Members def execute find_or_build_member update_member + create_member_task member end @@ -61,6 +64,21 @@ module Members } end + def create_member_task + return unless experiment(:invite_members_for_task).enabled? + return unless member.persisted? + return if member_task_attributes.value?(nil) + + member.create_member_task(member_task_attributes) + end + + def member_task_attributes + { + tasks_to_be_done: args[:tasks_to_be_done], + project_id: args[:tasks_project_id] + } + end + def approve_request ::Members::ApproveAccessRequestService.new(current_user, access_level: access_level) diff --git a/app/services/members/invite_service.rb b/app/services/members/invite_service.rb index 257a986b8dd..85acb720f0f 100644 --- a/app/services/members/invite_service.rb +++ b/app/services/members/invite_service.rb @@ -39,6 +39,11 @@ module Members errors[invite_email(member)] = member.errors.full_messages.to_sentence end + override :create_tasks_to_be_done + def create_tasks_to_be_done + # Only create task issues for existing users. Tasks for new users are created when they signup. + end + def invite_email(member) member.invite_email || member.user.email end diff --git a/app/services/merge_requests/outdated_discussion_diff_lines_service.rb b/app/services/merge_requests/outdated_discussion_diff_lines_service.rb new file mode 100644 index 00000000000..a2de5a32963 --- /dev/null +++ b/app/services/merge_requests/outdated_discussion_diff_lines_service.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module MergeRequests + class OutdatedDiscussionDiffLinesService + include Gitlab::Utils::StrongMemoize + + attr_reader :project, :note + + OVERFLOW_LINES_COUNT = 2 + + def initialize(project:, note:) + @project = project + @note = note + end + + def execute + end_position = position.line_range["end"] + diff_line_index = diff_lines.find_index do |l| + if end_position["new_line"] + l.new_line == end_position["new_line"] + elsif end_position["old_line"] + l.old_line == end_position["old_line"] + end + end + initial_line_index = [diff_line_index - OVERFLOW_LINES_COUNT, 0].max + last_line_index = [diff_line_index + OVERFLOW_LINES_COUNT, diff_lines.length].min + + prev_lines = [] + + diff_lines[initial_line_index..last_line_index].each do |line| + if line.meta? + prev_lines.clear + else + prev_lines << line + end + end + + prev_lines + end + + private + + def position + note.change_position + end + + def repository + project.repository + end + + def diff_file + position.diff_file(repository) + end + + def diff_lines + strong_memoize(:diff_lines) do + diff_file.highlighted_diff_lines + end + end + end +end diff --git a/app/services/merge_requests/retarget_chain_service.rb b/app/services/merge_requests/retarget_chain_service.rb index dab6e198979..33aae4184ae 100644 --- a/app/services/merge_requests/retarget_chain_service.rb +++ b/app/services/merge_requests/retarget_chain_service.rb @@ -5,8 +5,6 @@ module MergeRequests MAX_RETARGET_MERGE_REQUESTS = 4 def execute(merge_request) - return unless Feature.enabled?(:retarget_merge_requests, merge_request.target_project, default_enabled: :yaml) - # we can only retarget MRs that are targeting the same project return unless merge_request.for_same_project? && merge_request.merged? diff --git a/app/services/merge_requests/toggle_attention_requested_service.rb b/app/services/merge_requests/toggle_attention_requested_service.rb new file mode 100644 index 00000000000..66c5d6fce5d --- /dev/null +++ b/app/services/merge_requests/toggle_attention_requested_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module MergeRequests + class ToggleAttentionRequestedService < MergeRequests::BaseService + attr_accessor :merge_request, :user + + def initialize(project:, current_user:, merge_request:, user:) + super(project: project, current_user: current_user) + + @merge_request = merge_request + @user = user + end + + def execute + return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request) + + if reviewer || assignee + update_state(reviewer) + update_state(assignee) + + if reviewer&.attention_requested? || assignee&.attention_requested? + notity_user + end + + success + else + error("User is not a reviewer or assignee of the merge request") + end + end + + private + + def notity_user + todo_service.create_attention_requested_todo(merge_request, current_user, user) + end + + def assignee + merge_request.find_assignee(user) + end + + def reviewer + merge_request.find_reviewer(user) + end + + def update_state(reviewer_or_assignee) + reviewer_or_assignee&.update(state: reviewer_or_assignee&.attention_requested? ? :reviewed : :attention_requested) + end + end +end diff --git a/app/services/namespaces/in_product_marketing_email_records.rb b/app/services/namespaces/in_product_marketing_email_records.rb new file mode 100644 index 00000000000..1237a05ea13 --- /dev/null +++ b/app/services/namespaces/in_product_marketing_email_records.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Namespaces + class InProductMarketingEmailRecords + attr_reader :records + + def initialize + @records = [] + end + + def save! + Users::InProductMarketingEmail.bulk_insert!(@records) + @records = [] + end + + def add(user, track, series) + @records << Users::InProductMarketingEmail.new( + user: user, + track: track, + series: series, + created_at: Time.zone.now, + updated_at: Time.zone.now + ) + end + end +end diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb index 0401653cf3c..90900698e1a 100644 --- a/app/services/namespaces/in_product_marketing_emails_service.rb +++ b/app/services/namespaces/in_product_marketing_emails_service.rb @@ -56,7 +56,7 @@ module Namespaces def initialize(track, interval) @track = track @interval = interval - @in_product_marketing_email_records = [] + @sent_email_records = InProductMarketingEmailRecords.new end def execute @@ -71,17 +71,21 @@ module Namespaces private - attr_reader :track, :interval, :in_product_marketing_email_records + attr_reader :track, :interval, :sent_email_records + + def send_email(user, group) + NotificationService.new.in_product_marketing(user.id, group.id, track, series) + end def send_email_for_group(group) users_for_group(group).each do |user| if can_perform_action?(user, group) send_email(user, group) - track_sent_email(user, track, series) + sent_email_records.add(user, track, series) end end - save_tracked_emails! + sent_email_records.save! end def groups_for_track @@ -126,10 +130,6 @@ module Namespaces end end - def send_email(user, group) - NotificationService.new.in_product_marketing(user.id, group.id, track, series) - end - def completed_actions TRACKS[track][:completed_actions] end @@ -146,21 +146,6 @@ module Namespaces def series TRACKS[track][:interval_days].index(interval) end - - def save_tracked_emails! - Users::InProductMarketingEmail.bulk_insert!(in_product_marketing_email_records) - @in_product_marketing_email_records = [] - end - - def track_sent_email(user, track, series) - in_product_marketing_email_records << Users::InProductMarketingEmail.new( - user: user, - track: track, - series: series, - created_at: Time.zone.now, - updated_at: Time.zone.now - ) - end end end diff --git a/app/services/namespaces/invite_team_email_service.rb b/app/services/namespaces/invite_team_email_service.rb new file mode 100644 index 00000000000..45975d1953a --- /dev/null +++ b/app/services/namespaces/invite_team_email_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Namespaces + class InviteTeamEmailService + include Gitlab::Experiment::Dsl + + TRACK = :invite_team + DELIVERY_DELAY_IN_MINUTES = 20.minutes + + def self.send_email(user, group) + new(user, group).execute + end + + def initialize(user, group) + @group = group + @user = user + @sent_email_records = InProductMarketingEmailRecords.new + end + + def execute + return unless user.email_opted_in? + return unless group.root? + return unless group.setup_for_company + + # Exclude group if users other than the creator have already been + # added/invited + return unless group.member_count == 1 + + return if email_for_track_sent_to_user? + + experiment(:invite_team_email, group: group) do |e| + e.candidate do + send_email(user, group) + sent_email_records.add(user, track, series) + sent_email_records.save! + end + + e.record! + end + end + + private + + attr_reader :user, :group, :sent_email_records + + def send_email(user, group) + NotificationService.new.in_product_marketing(user.id, group.id, track, series) + end + + def track + TRACK + end + + def series + 0 + end + + def email_for_track_sent_to_user? + Users::InProductMarketingEmail.for_user_with_track_and_series(user, track, series).present? + end + end +end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 194c3d7bf7b..9a0db3bb9aa 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -43,7 +43,7 @@ module Notes private def execute_quick_actions(note) - return yield(false) unless quick_actions_service.supported?(note) + return yield(false) unless quick_actions_supported?(note) content, update_params, message = quick_actions_service.execute(note, quick_action_options) only_commands = content.empty? @@ -54,6 +54,10 @@ module Notes do_commands(note, update_params, message, only_commands) end + def quick_actions_supported?(note) + quick_actions_service.supported?(note) + end + def quick_actions_service @quick_actions_service ||= QuickActionsService.new(project, current_user) end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index afc9015e758..6ad3a74b85d 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -598,8 +598,8 @@ class NotificationService user.notification_email_for(pipeline.project.group) end - if recipients.any? - mailer.public_send(email_template, pipeline, recipients).deliver_later + recipients.each do |recipient| + mailer.public_send(email_template, pipeline, recipient).deliver_later end end diff --git a/app/services/packages/create_dependency_service.rb b/app/services/packages/create_dependency_service.rb index 2c80ec66dbc..10a86e44cb0 100644 --- a/app/services/packages/create_dependency_service.rb +++ b/app/services/packages/create_dependency_service.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true module Packages + # rubocop: disable Gitlab/BulkInsert class CreateDependencyService < BaseService attr_reader :package, :dependencies @@ -51,7 +52,7 @@ module Packages } end - ids = database.bulk_insert(Packages::Dependency.table_name, rows, return_ids: true, on_conflict: :do_nothing) + ids = ApplicationRecord.legacy_bulk_insert(Packages::Dependency.table_name, rows, return_ids: true, on_conflict: :do_nothing) return ids if ids.size == names_and_version_patterns.size Packages::Dependency.uncached do @@ -72,11 +73,8 @@ module Packages } end - database.bulk_insert(Packages::DependencyLink.table_name, rows) - end - - def database - ::Gitlab::Database.main + ApplicationRecord.legacy_bulk_insert(Packages::DependencyLink.table_name, rows) end end + # rubocop: enable Gitlab/BulkInsert end diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb index 1d5d9c38432..ae9c92a3d3a 100644 --- a/app/services/packages/npm/create_package_service.rb +++ b/app/services/packages/npm/create_package_service.rb @@ -4,6 +4,8 @@ module Packages class CreatePackageService < ::Packages::CreatePackageService include Gitlab::Utils::StrongMemoize + PACKAGE_JSON_NOT_ALLOWED_FIELDS = %w[readme readmeFilename].freeze + def execute return error('Version is empty.', 400) if version.blank? return error('Package already exists.', 403) if current_package_exists? @@ -21,6 +23,10 @@ module Packages ::Packages::CreateDependencyService.new(package, package_dependencies).execute ::Packages::Npm::CreateTagService.new(package, dist_tag).execute + if Feature.enabled?(:packages_npm_abbreviated_metadata, project, default_enabled: :yaml) + package.create_npm_metadatum!(package_json: package_json) + end + package end @@ -46,6 +52,10 @@ module Packages params[:versions][version] end + def package_json + version_data.except(*PACKAGE_JSON_NOT_ALLOWED_FIELDS) + end + def dist_tag params['dist-tags'].each_key.first end diff --git a/app/services/packages/nuget/create_dependency_service.rb b/app/services/packages/nuget/create_dependency_service.rb index 3fc42056d43..85f295ac7b7 100644 --- a/app/services/packages/nuget/create_dependency_service.rb +++ b/app/services/packages/nuget/create_dependency_service.rb @@ -41,7 +41,7 @@ module Packages } end - ::Gitlab::Database.main.bulk_insert(::Packages::Nuget::DependencyLinkMetadatum.table_name, rows.compact) # rubocop:disable Gitlab/BulkInsert + ::ApplicationRecord.legacy_bulk_insert(::Packages::Nuget::DependencyLinkMetadatum.table_name, rows.compact) # rubocop:disable Gitlab/BulkInsert end def raw_dependency_for(dependency) diff --git a/app/services/packages/rubygems/create_dependencies_service.rb b/app/services/packages/rubygems/create_dependencies_service.rb index dea429148cf..0b2ae56bf45 100644 --- a/app/services/packages/rubygems/create_dependencies_service.rb +++ b/app/services/packages/rubygems/create_dependencies_service.rb @@ -3,8 +3,6 @@ module Packages module Rubygems class CreateDependenciesService - include BulkInsertSafe - def initialize(package, gemspec) @package = package @gemspec = gemspec diff --git a/app/services/packages/update_tags_service.rb b/app/services/packages/update_tags_service.rb index 2bdf75a6617..f29c54dacb9 100644 --- a/app/services/packages/update_tags_service.rb +++ b/app/services/packages/update_tags_service.rb @@ -15,7 +15,7 @@ module Packages tags_to_create = @tags - existing_tags @package.tags.with_name(tags_to_destroy).delete_all if tags_to_destroy.any? - ::Gitlab::Database.main.bulk_insert(Packages::Tag.table_name, rows(tags_to_create)) if tags_to_create.any? # rubocop:disable Gitlab/BulkInsert + ::ApplicationRecord.legacy_bulk_insert(Packages::Tag.table_name, rows(tags_to_create)) if tags_to_create.any? # rubocop:disable Gitlab/BulkInsert end private diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb index a5ee7173bdf..e5d40b60747 100644 --- a/app/services/projects/alerting/notify_service.rb +++ b/app/services/projects/alerting/notify_service.rb @@ -5,6 +5,7 @@ module Projects class NotifyService extend ::Gitlab::Utils::Override include ::AlertManagement::AlertProcessing + include ::AlertManagement::Responses def initialize(project, payload) @project = project @@ -23,7 +24,7 @@ module Projects complete_post_processing_tasks - ServiceResponse.success + success(alert) end private @@ -46,18 +47,6 @@ module Projects def valid_token?(token) token == integration.token end - - def bad_request - ServiceResponse.error(message: 'Bad Request', http_status: :bad_request) - end - - def unauthorized - ServiceResponse.error(message: 'Unauthorized', http_status: :unauthorized) - end - - def forbidden - ServiceResponse.error(message: 'Forbidden', http_status: :forbidden) - end end end end diff --git a/app/services/projects/all_issues_count_service.rb b/app/services/projects/all_issues_count_service.rb new file mode 100644 index 00000000000..15352b14d3e --- /dev/null +++ b/app/services/projects/all_issues_count_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Projects + # Service class for counting and caching the number of all issues of a + # project. + class AllIssuesCountService < Projects::CountService + def relation_for_count + @project.issues + end + + def cache_key_name + 'all_issues_count' + end + end +end diff --git a/app/services/projects/all_merge_requests_count_service.rb b/app/services/projects/all_merge_requests_count_service.rb new file mode 100644 index 00000000000..db0bab3f799 --- /dev/null +++ b/app/services/projects/all_merge_requests_count_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Projects + # Service class for counting and caching the number of all merge requests of + # a project. + class AllMergeRequestsCountService < Projects::CountService + def relation_for_count + @project.merge_requests + end + + def cache_key_name + 'all_merge_requests_count' + end + end +end diff --git a/app/services/projects/container_repository/cache_tags_created_at_service.rb b/app/services/projects/container_repository/cache_tags_created_at_service.rb deleted file mode 100644 index 3a5346d7a23..00000000000 --- a/app/services/projects/container_repository/cache_tags_created_at_service.rb +++ /dev/null @@ -1,70 +0,0 @@ -# frozen_string_literal: true - -module Projects - module ContainerRepository - class CacheTagsCreatedAtService - def initialize(container_repository) - @container_repository = container_repository - @cached_tag_names = Set.new - end - - def populate(tags) - return if tags.empty? - - # This will load all tags in one Redis roundtrip - # the maximum number of tags is configurable and is set to 200 by default. - # https://gitlab.com/gitlab-org/gitlab/blob/master/doc/user/packages/container_registry/index.md#set-cleanup-limits-to-conserve-resources - keys = tags.map(&method(:cache_key)) - cached_tags_count = 0 - - ::Gitlab::Redis::Cache.with do |redis| - tags.zip(redis.mget(keys)).each do |tag, created_at| - next unless created_at - - tag.created_at = DateTime.rfc3339(created_at) - @cached_tag_names << tag.name - cached_tags_count += 1 - end - end - - cached_tags_count - end - - def insert(tags, max_ttl_in_seconds) - return unless max_ttl_in_seconds - return if tags.empty? - - # tags with nil created_at are not cacheable - # tags already cached don't need to be cached again - cacheable_tags = tags.select do |tag| - tag.created_at.present? && !tag.name.in?(@cached_tag_names) - end - - return if cacheable_tags.empty? - - now = Time.zone.now - - ::Gitlab::Redis::Cache.with do |redis| - # we use a pipeline instead of a MSET because each tag has - # a specific ttl - redis.pipelined do - cacheable_tags.each do |tag| - created_at = tag.created_at - # ttl is the max_ttl_in_seconds reduced by the number - # of seconds that the tag has already existed - ttl = max_ttl_in_seconds - (now - created_at).seconds - ttl = ttl.to_i - redis.set(cache_key(tag), created_at.rfc3339, ex: ttl) if ttl > 0 - end - end - end - end - - private - - def cache_key(tag) - "container_repository:{#{@container_repository.id}}:tag:#{tag.name}:created_at" - end - end - end -end diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb index 3a60de0f1ee..1a788abac12 100644 --- a/app/services/projects/container_repository/cleanup_tags_service.rb +++ b/app/services/projects/container_repository/cleanup_tags_service.rb @@ -140,14 +140,13 @@ module Projects def cache strong_memoize(:cache) do - ::Projects::ContainerRepository::CacheTagsCreatedAtService.new(@container_repository) + ::Gitlab::ContainerRepository::Tags::Cache.new(@container_repository) end end def caching_enabled? container_expiration_policy && - older_than.present? && - Feature.enabled?(:container_registry_expiration_policies_caching, @project) + older_than.present? end def throttling_enabled? diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 1536f0a22b8..1d187b140ef 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -45,7 +45,7 @@ module Projects if namespace_id # Find matching namespace and check if it allowed # for current user if namespace_id passed. - unless current_user.can?(:create_projects, project_namespace) + unless current_user.can?(:create_projects, parent_namespace) @project.namespace_id = nil deny_namespace return @project @@ -136,7 +136,7 @@ module Projects access_level: group_access_level) end - AuthorizedProjectUpdate::ProjectCreateWorker.perform_async(@project.id) + AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(@project.id) # AuthorizedProjectsWorker uses an exclusive lease per user but # specialized workers might have synchronization issues. Until we # compare the inconsistency rates of both approaches, we still run @@ -227,14 +227,14 @@ module Projects def extra_attributes_for_measurement { current_user: current_user&.name, - project_full_path: "#{project_namespace&.full_path}/#{@params[:path]}" + project_full_path: "#{parent_namespace&.full_path}/#{@params[:path]}" } end private - def project_namespace - @project_namespace ||= Namespace.find_by_id(@params[:namespace_id]) || current_user.namespace + def parent_namespace + @parent_namespace ||= Namespace.find_by_id(@params[:namespace_id]) || current_user.namespace end def create_from_template? diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 27f813f4661..b7ed9202b01 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -152,9 +152,12 @@ module Projects deleted_count = project.commit_statuses.delete_all - if deleted_count > 0 - Gitlab::AppLogger.info "Projects::DestroyService - Project #{project.id} - #{deleted_count} leftover commit statuses" - end + Gitlab::AppLogger.info( + class: 'Projects::DestroyService', + project_id: project.id, + message: 'leftover commit statuses', + orphaned_commit_status_count: deleted_count + ) end # The project can have multiple webhooks with hundreds of thousands of web_hook_logs. diff --git a/app/services/projects/detect_repository_languages_service.rb b/app/services/projects/detect_repository_languages_service.rb index 0356a6b0ccd..9db0b71d106 100644 --- a/app/services/projects/detect_repository_languages_service.rb +++ b/app/services/projects/detect_repository_languages_service.rb @@ -21,7 +21,7 @@ module Projects .update_all(share: update[:share]) end - Gitlab::Database.main.bulk_insert( # rubocop:disable Gitlab/BulkInsert + ApplicationRecord.legacy_bulk_insert( # rubocop:disable Gitlab/BulkInsert RepositoryLanguage.table_name, detection.insertions(matching_programming_languages) ) diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index 64c0f1ff4ac..b1a2182fbdc 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -36,6 +36,7 @@ module Projects private attr_accessor :shared + attr_reader :logger def execute_after_export_action(after_export_strategy) return unless after_export_strategy @@ -74,7 +75,11 @@ module Projects end def project_tree_saver - tree_saver_class.new(project: project, current_user: current_user, shared: shared, params: params) + tree_saver_class.new(project: project, + current_user: current_user, + shared: shared, + params: params, + logger: logger) end def tree_saver_class @@ -116,7 +121,7 @@ module Projects end def notify_success - @logger.info( + logger.info( message: 'Project successfully exported', project_name: project.name, project_id: project.id @@ -124,7 +129,7 @@ module Projects end def notify_error - @logger.error( + logger.error( message: 'Project export error', export_errors: shared.errors.join(', '), project_name: project.name, diff --git a/app/services/projects/lfs_pointers/lfs_link_service.rb b/app/services/projects/lfs_pointers/lfs_link_service.rb index 7c00b9e6105..cf3cc5cd8e0 100644 --- a/app/services/projects/lfs_pointers/lfs_link_service.rb +++ b/app/services/projects/lfs_pointers/lfs_link_service.rb @@ -38,7 +38,7 @@ module Projects rows = existent_lfs_objects .not_linked_to_project(project) .map { |existing_lfs_object| { project_id: project.id, lfs_object_id: existing_lfs_object.id } } - Gitlab::Database.main.bulk_insert(:lfs_objects_projects, rows) # rubocop:disable Gitlab/BulkInsert + ApplicationRecord.legacy_bulk_insert(:lfs_objects_projects, rows) # rubocop:disable Gitlab/BulkInsert iterations += 1 linked_existing_objects += existent_lfs_objects.map(&:oid) diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index 1616a8a4062..152590fffff 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -36,17 +36,9 @@ module Projects private def project_members_through_invited_groups - groups_with_ancestors = if ::Feature.enabled?(:linear_participants_service_ancestor_scopes, current_user, default_enabled: :yaml) - visible_groups.self_and_ancestors - else - Gitlab::ObjectHierarchy - .new(visible_groups) - .base_and_ancestors - end - GroupMember .active_without_invites_and_requests - .with_source_id(groups_with_ancestors.pluck_primary_key) + .with_source_id(visible_groups.self_and_ancestors.pluck_primary_key) end def visible_groups diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb index c1bf2e68436..56f65718d24 100644 --- a/app/services/projects/prometheus/alerts/notify_service.rb +++ b/app/services/projects/prometheus/alerts/notify_service.rb @@ -6,6 +6,7 @@ module Projects class NotifyService include Gitlab::Utils::StrongMemoize include ::IncidentManagement::Settings + include ::AlertManagement::Responses # This set of keys identifies a payload as a valid Prometheus # payload and thus processable by this service. See also @@ -27,9 +28,9 @@ module Projects return unprocessable_entity unless self.class.processable?(payload) return unauthorized unless valid_alert_manager_token?(token, integration) - process_prometheus_alerts + alert_responses = process_prometheus_alerts - ServiceResponse.success + alert_response(alert_responses) end def self.processable?(payload) @@ -128,23 +129,17 @@ module Projects end def process_prometheus_alerts - alerts.each do |alert| + alerts.map do |alert| AlertManagement::ProcessPrometheusAlertService .new(project, alert.to_h) .execute end end - def bad_request - ServiceResponse.error(message: 'Bad Request', http_status: :bad_request) - end - - def unauthorized - ServiceResponse.error(message: 'Unauthorized', http_status: :unauthorized) - end + def alert_response(alert_responses) + alerts = alert_responses.map { |resp| resp.payload[:alert] }.compact - def unprocessable_entity - ServiceResponse.error(message: 'Unprocessable Entity', http_status: :unprocessable_entity) + success(alerts) end end end diff --git a/app/services/resource_events/base_synthetic_notes_builder_service.rb b/app/services/resource_events/base_synthetic_notes_builder_service.rb index 5939b9d2f9c..192d40129a3 100644 --- a/app/services/resource_events/base_synthetic_notes_builder_service.rb +++ b/app/services/resource_events/base_synthetic_notes_builder_service.rb @@ -24,10 +24,18 @@ module ResourceEvents private def apply_common_filters(events) + events = apply_pagination(events) events = apply_last_fetched_at(events) apply_fetch_until(events) end + def apply_pagination(events) + return events if params[:paginated_notes].nil? + return events.none if params[:paginated_notes][table_name].blank? + + events.id_in(params[:paginated_notes][table_name].map(&:id)) + end + def apply_last_fetched_at(events) return events unless params[:last_fetched_at].present? @@ -47,5 +55,9 @@ module ResourceEvents resource.project || resource.group end end + + def table_name + raise NotImplementedError + end end end diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb index bc2d3a946cc..03ac839c509 100644 --- a/app/services/resource_events/change_labels_service.rb +++ b/app/services/resource_events/change_labels_service.rb @@ -23,7 +23,7 @@ module ResourceEvents label_hash.merge(label_id: label.id, action: ResourceLabelEvent.actions['remove']) end - Gitlab::Database.main.bulk_insert(ResourceLabelEvent.table_name, labels) # rubocop:disable Gitlab/BulkInsert + ApplicationRecord.legacy_bulk_insert(ResourceLabelEvent.table_name, labels) # rubocop:disable Gitlab/BulkInsert resource.expire_note_etag_cache Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action(author: user) if resource.is_a?(Issue) diff --git a/app/services/resource_events/synthetic_label_notes_builder_service.rb b/app/services/resource_events/synthetic_label_notes_builder_service.rb index 5915ea938cf..0e5d945d13c 100644 --- a/app/services/resource_events/synthetic_label_notes_builder_service.rb +++ b/app/services/resource_events/synthetic_label_notes_builder_service.rb @@ -23,5 +23,9 @@ module ResourceEvents events.group_by { |event| event.discussion_id } end + + def table_name + 'resource_label_events' + end end end diff --git a/app/services/resource_events/synthetic_milestone_notes_builder_service.rb b/app/services/resource_events/synthetic_milestone_notes_builder_service.rb index 10acf94e22b..0e2b171e192 100644 --- a/app/services/resource_events/synthetic_milestone_notes_builder_service.rb +++ b/app/services/resource_events/synthetic_milestone_notes_builder_service.rb @@ -21,5 +21,9 @@ module ResourceEvents events = resource.resource_milestone_events.includes(user: :status) # rubocop: disable CodeReuse/ActiveRecord apply_common_filters(events) end + + def table_name + 'resource_milestone_events' + end end end diff --git a/app/services/resource_events/synthetic_state_notes_builder_service.rb b/app/services/resource_events/synthetic_state_notes_builder_service.rb index 71d40200365..e17882b00de 100644 --- a/app/services/resource_events/synthetic_state_notes_builder_service.rb +++ b/app/services/resource_events/synthetic_state_notes_builder_service.rb @@ -16,5 +16,9 @@ module ResourceEvents events = resource.resource_state_events.includes(user: :status) # rubocop: disable CodeReuse/ActiveRecord apply_common_filters(events) end + + def table_name + 'resource_state_events' + end end end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index cce7821a226..4ba1b3ade86 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -75,6 +75,10 @@ class SearchService search_results.highlight_map(scope) end + def search_aggregations + search_results.aggregations(scope) + end + private def page diff --git a/app/services/security/ci_configuration/sast_iac_create_service.rb b/app/services/security/ci_configuration/sast_iac_create_service.rb new file mode 100644 index 00000000000..80e9cf963da --- /dev/null +++ b/app/services/security/ci_configuration/sast_iac_create_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Security + module CiConfiguration + class SastIacCreateService < ::Security::CiConfiguration::BaseCreateService + private + + def action + Security::CiConfiguration::SastIacBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content).generate + end + + def next_branch + 'set-sast-iac-config' + end + + def message + _('Configure SAST IaC in `.gitlab-ci.yml`, creating this file if it does not already exist') + end + + def description + _('Configure SAST IaC in `.gitlab-ci.yml` using the GitLab managed template. You can [add variable overrides](https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings) to customize SAST IaC settings.') + end + end + end +end diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb index d83b21271c0..76d5063c337 100644 --- a/app/services/snippets/update_service.rb +++ b/app/services/snippets/update_service.rb @@ -148,3 +148,5 @@ module Snippets end end end + +Snippets::UpdateService.prepend_mod_with('Snippets::UpdateService') diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb index 8d995631db6..c8bdcf4310b 100644 --- a/app/services/spam/spam_verdict_service.rb +++ b/app/services/spam/spam_verdict_service.rb @@ -73,18 +73,12 @@ module Spam begin result, attribs, _error = spamcheck_client.issue_spam?(spam_issue: target, user: user, context: context) - return [nil, attribs] unless result - # @TODO log if error is not nil https://gitlab.com/gitlab-org/gitlab/-/issues/329545 - return [result, attribs] if result == NOOP || attribs["monitorMode"] == "true" + return [nil, attribs] unless result + + [result, attribs] - # Duplicate logic with Akismet logic in #akismet_verdict - if Gitlab::Recaptcha.enabled? && result != ALLOW - [CONDITIONAL_ALLOW, attribs] - else - [result, attribs] - end rescue StandardError => e Gitlab::ErrorTracking.log_exception(e) diff --git a/app/services/suggestions/create_service.rb b/app/services/suggestions/create_service.rb index eb98ed57d55..239cd86e0ec 100644 --- a/app/services/suggestions/create_service.rb +++ b/app/services/suggestions/create_service.rb @@ -25,7 +25,7 @@ module Suggestions end rows.in_groups_of(100, false) do |rows| - Gitlab::Database.main.bulk_insert('suggestions', rows) # rubocop:disable Gitlab/BulkInsert + ApplicationRecord.legacy_bulk_insert('suggestions', rows) # rubocop:disable Gitlab/BulkInsert end Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter.track_add_suggestion_action(note: @note) diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index e5080718b69..dc5cf0fe554 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -327,6 +327,10 @@ module SystemNoteService ::SystemNotes::IncidentService.new(noteable: incident, project: incident.project, author: author).change_incident_severity end + def resolve_incident_status(incident, author) + ::SystemNotes::IncidentService.new(noteable: incident, project: incident.project, author: author).resolve_incident_status + end + def log_resolving_alert(alert, monitoring_tool) ::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project).log_resolving_alert(monitoring_tool) end diff --git a/app/services/system_notes/incident_service.rb b/app/services/system_notes/incident_service.rb index 4628662f0e9..785291e0637 100644 --- a/app/services/system_notes/incident_service.rb +++ b/app/services/system_notes/incident_service.rb @@ -25,5 +25,11 @@ module SystemNotes ) end end + + def resolve_incident_status + body = 'changed the status to **Resolved** by closing the incident' + + create_note(NoteSummary.new(noteable, project, author, body, action: 'status')) + end end end diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index 62aead352aa..94629ae7609 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -176,7 +176,13 @@ module SystemNotes body = cross_reference_note_content(gfm_reference) if noteable.is_a?(ExternalIssue) - noteable.project.external_issue_tracker.create_cross_reference_note(noteable, mentioner, author) + Integrations::CreateExternalCrossReferenceWorker.perform_async( + noteable.project_id, + noteable.id, + mentioner.class.name, + mentioner.id, + author.id + ) else track_cross_reference_action create_note(NoteSummary.new(noteable, noteable.project, author, body, action: 'cross_reference')) diff --git a/app/services/tasks_to_be_done/base_service.rb b/app/services/tasks_to_be_done/base_service.rb new file mode 100644 index 00000000000..a5648ad10c4 --- /dev/null +++ b/app/services/tasks_to_be_done/base_service.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module TasksToBeDone + class BaseService < ::IssuableBaseService + LABEL_PREFIX = 'tasks to be done' + + def initialize(project:, current_user:, assignee_ids: []) + params = { + assignee_ids: assignee_ids, + title: title, + description: description, + add_labels: label_name + } + super(project: project, current_user: current_user, params: params) + end + + def execute + if (issue = existing_task_issue) + update_service = Issues::UpdateService.new(project: project, current_user: current_user, params: { add_assignee_ids: params[:assignee_ids] }) + update_service.execute(issue) + else + build_service = Issues::BuildService.new(project: project, current_user: current_user, params: params) + create(build_service.execute) + end + end + + private + + def existing_task_issue + IssuesFinder.new( + current_user, + project_id: project.id, + state: 'opened', + non_archived: true, + label_name: label_name + ).execute.last + end + + def title + raise NotImplementedError + end + + def description + raise NotImplementedError + end + + def label_suffix + raise NotImplementedError + end + + def label_name + "#{LABEL_PREFIX}:#{label_suffix}" + end + end +end diff --git a/app/services/tasks_to_be_done/create_ci_task_service.rb b/app/services/tasks_to_be_done/create_ci_task_service.rb new file mode 100644 index 00000000000..025ca2feb8e --- /dev/null +++ b/app/services/tasks_to_be_done/create_ci_task_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module TasksToBeDone + class CreateCiTaskService < BaseService + protected + + def title + 'Set up CI/CD' + end + + def description + <<~DESCRIPTION + GitLab CI/CD is a tool built into GitLab for software development through the [continuous methodologies](https://docs.gitlab.com/ee/ci/introduction/index.html#introduction-to-cicd-methodologies): + + * Continuous Integration (CI) + * Continuous Delivery (CD) + * Continuous Deployment (CD) + + Continuous Integration works by pushing small changes to your application’s codebase hosted in a Git repository, and, to every push, run a pipeline of scripts to build, test, and validate the code changes before merging them into the main branch. + + Continuous Delivery and Deployment consist of a step further CI, deploying your application to production at every push to the default branch of the repository. + + These methodologies allow you to catch bugs and errors early in the development cycle, ensuring that all the code deployed to production complies with the code standards you established for your app. + + * :book: [Read the documentation](https://docs.gitlab.com/ee/ci/introduction/index.html) + * :clapper: [Watch a Demo](https://www.youtube.com/watch?v=1iXFbchozdY) + + ## Next steps + + * [ ] To start we recommend reviewing the following documentation: + * [ ] [How GitLab CI/CD works.](https://docs.gitlab.com/ee/ci/introduction/index.html#how-gitlab-cicd-works) + * [ ] [Fundamental pipeline architectures.](https://docs.gitlab.com/ee/ci/pipelines/pipeline_architectures.html) + * [ ] [GitLab CI/CD basic workflow.](https://docs.gitlab.com/ee/ci/introduction/index.html#basic-cicd-workflow) + * [ ] [Step-by-step guide for writing .gitlab-ci.yml for the first time.](https://docs.gitlab.com/ee/user/project/pages/getting_started_part_four.html) + * [ ] When you're ready select **Projects** (in the top navigation bar) > **Your projects** > select the Project you've already created. + * [ ] Select **CI / CD** in the left navigation to start setting up CI / CD in your project. + DESCRIPTION + end + + def label_suffix + 'ci' + end + end +end diff --git a/app/services/tasks_to_be_done/create_code_task_service.rb b/app/services/tasks_to_be_done/create_code_task_service.rb new file mode 100644 index 00000000000..dc3b9366a66 --- /dev/null +++ b/app/services/tasks_to_be_done/create_code_task_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module TasksToBeDone + class CreateCodeTaskService < BaseService + protected + + def title + 'Create or import your code into your Project (Repository)' + end + + def description + <<~DESCRIPTION + You've already created your Group and Project within GitLab; we'll quickly review this hierarchy below. Once you're within your project you can easily create or import repositories. + + **With GitLab Groups, you can:** + + * Create one or multiple Projects for hosting your codebase (repositories). + * Assemble related projects together. + * Grant members access to several projects at once. + + Groups can also be nested in subgroups. + + Read more about groups in our [documentation](https://docs.gitlab.com/ee/user/group/). + + **Within GitLab Projects, you can** + + * Use it as an issue tracker. + * Collaborate on code. + * Continuously build, test, and deploy your app with built-in GitLab CI/CD. + + You can also import an existing repository by providing the Git URL. + + * :book: [Read the documentation](https://docs.gitlab.com/ee/user/project/index.html). + + ## Next steps + + Create or import your first repository into the project you created: + + * [ ] Click **Projects** in the top navigation bar, then click **Your projects**. + * [ ] Select the Project that you created, then select **Repository**. + * [ ] Once on the Repository page you can select the **+** icon to add or import files. + * [ ] You can review our full documentation on creating [repositories](https://docs.gitlab.com/ee/user/project/repository/) in GitLab. + + :tada: All done, you can close this issue! + DESCRIPTION + end + + def label_suffix + 'code' + end + end +end diff --git a/app/services/tasks_to_be_done/create_issues_task_service.rb b/app/services/tasks_to_be_done/create_issues_task_service.rb new file mode 100644 index 00000000000..a2de6852868 --- /dev/null +++ b/app/services/tasks_to_be_done/create_issues_task_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module TasksToBeDone + class CreateIssuesTaskService < BaseService + protected + + def title + 'Create/import issues (tickets) to collaborate on ideas and plan work' + end + + def description + <<~DESCRIPTION + Issues allow you and your team to discuss proposals before, and during, their implementation. They can be used for a variety of other purposes, customized to your needs and workflow. + + Issues are always associated with a specific project. If you have multiple projects in a group, you can view all the issues at the group level. [You can review our full Issue documentation here.](https://docs.gitlab.com/ee/user/project/issues/) + + If you have existing issues or equivalent tickets you can import them as long as they are formatted as a CSV file, [the import process is covered here](https://docs.gitlab.com/ee/user/project/issues/csv_import.html). + + **Common use cases include:** + + * Discussing the implementation of a new idea + * Tracking tasks and work status + * Accepting feature proposals, questions, support requests, or bug reports + * Elaborating on new code implementations + + ## Next steps + + * [ ] Select **Projects** in the top navigation > **Your Projects** > select the Project you've already created. + * [ ] Once you've selected that project, you can select **Issues** in the left navigation, then click **New issue**. + * [ ] Fill in the title and description in the **New issue** page. + * [ ] Click on **Create issue**. + + Pro tip: When you're in a group or project you can always utilize the **+** icon in the top navigation (located to the left of the search bar) to quickly create new issues. + + That's it! You can close this issue. + DESCRIPTION + end + + def label_suffix + 'issues' + end + end +end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 71bb813f384..091f441831a 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -217,6 +217,11 @@ class TodoService create_todos(reviewers, attributes) end + def create_attention_requested_todo(target, author, users) + attributes = attributes_for_todo(target.project, target, author, Todo::ATTENTION_REQUESTED) + create_todos(users, attributes) + end + private def create_todos(users, attributes) diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index 4ec875098fa..1634cc017ae 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -65,7 +65,10 @@ module Users user.destroy_dependent_associations_in_batches(exclude: [:snippets]) # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing - user_data = user.destroy + user_data = nil + ::Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/340260') do + user_data = user.destroy + end namespace.destroy user_data diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index 1850fa9747d..2d9766c3c56 100644 --- a/app/services/users/refresh_authorized_projects_service.rb +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -67,10 +67,8 @@ module Users def update_authorizations(remove = [], add = []) log_refresh_details(remove, add) - User.transaction do - user.remove_project_authorizations(remove) unless remove.empty? - ProjectAuthorization.insert_authorizations(add) unless add.empty? - end + user.remove_project_authorizations(remove) unless remove.empty? + ProjectAuthorization.insert_authorizations(add) unless add.empty? # Since we batch insert authorization rows, Rails' associations may get # out of sync. As such we force a reload of the User object. diff --git a/app/services/users/upsert_credit_card_validation_service.rb b/app/services/users/upsert_credit_card_validation_service.rb index 86b5b923418..61cf598f178 100644 --- a/app/services/users/upsert_credit_card_validation_service.rb +++ b/app/services/users/upsert_credit_card_validation_service.rb @@ -12,6 +12,7 @@ module Users credit_card_validated_at: params.fetch(:credit_card_validated_at), expiration_date: get_expiration_date(params), last_digits: Integer(params.fetch(:credit_card_mask_number), 10), + network: params.fetch(:credit_card_type), holder_name: params.fetch(:credit_card_holder_name) } diff --git a/app/uploaders/bulk_imports/export_uploader.rb b/app/uploaders/bulk_imports/export_uploader.rb index 356e5ce028e..cd6e599054b 100644 --- a/app/uploaders/bulk_imports/export_uploader.rb +++ b/app/uploaders/bulk_imports/export_uploader.rb @@ -2,6 +2,6 @@ module BulkImports class ExportUploader < ImportExportUploader - EXTENSION_WHITELIST = %w[ndjson.gz].freeze + EXTENSION_ALLOWLIST = %w[ndjson.gz].freeze end end diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index 20aab58243a..bd959b14648 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -32,7 +32,14 @@ class FileUploader < GitlabUploader def self.absolute_path(upload) File.join( - absolute_base_dir(upload.model), + root, + relative_path(upload) + ) + end + + def self.relative_path(upload) + File.join( + base_dir(upload.model), upload.path # already contain the dynamic_segment, see #upload_path ) end diff --git a/app/uploaders/import_export_uploader.rb b/app/uploaders/import_export_uploader.rb index 369afba2bae..7b161d72efb 100644 --- a/app/uploaders/import_export_uploader.rb +++ b/app/uploaders/import_export_uploader.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true class ImportExportUploader < AttachmentUploader - EXTENSION_WHITELIST = %w[tar.gz gz].freeze + EXTENSION_ALLOWLIST = %w[tar.gz gz].freeze def self.workhorse_local_upload_path File.join(options.storage_path, 'uploads', TMP_UPLOAD_PATH) end def extension_whitelist - EXTENSION_WHITELIST + EXTENSION_ALLOWLIST end def move_to_cache diff --git a/app/validators/gitlab/emoji_name_validator.rb b/app/validators/gitlab/emoji_name_validator.rb new file mode 100644 index 00000000000..a9092d0194f --- /dev/null +++ b/app/validators/gitlab/emoji_name_validator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Gitlab::EmojiNameValidator +# +# Validates that the provided value matches an indexed emoji alpha code +# +# @example Usage +# class AwardEmoji < ApplicationRecord +# validate :name, 'gitlab/emoji_name': true +# end +module Gitlab + class EmojiNameValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + unless TanukiEmoji.find_by_alpha_code(value.to_s) + record.errors.add(attribute, (options[:message] || 'is not a valid emoji name')) + end + end + end +end diff --git a/app/validators/json_schemas/error_tracking_event_payload.json b/app/validators/json_schemas/error_tracking_event_payload.json index 52efcf6800c..73ff71043ce 100644 --- a/app/validators/json_schemas/error_tracking_event_payload.json +++ b/app/validators/json_schemas/error_tracking_event_payload.json @@ -1,7 +1,7 @@ { "description": "Error tracking event payload", "type": "object", - "required": [], + "required": ["exception"], "properties": { "environment": { "type": "string" @@ -14,7 +14,7 @@ }, "exception": { "type": "object", - "required": [], + "required": ["values"], "properties": { "values": { "type": "array", @@ -28,12 +28,6 @@ "value": { "type": "string" }, - "module": { - "type": "string" - }, - "thread_id": { - "type": "number" - }, "stacktrace": { "type": "object", "required": [], @@ -44,9 +38,6 @@ "type": "object", "required": [], "properties": { - "project_root": { - "type": "string" - }, "abs_path": { "type": "string" }, diff --git a/app/validators/json_schemas/helm_metadata.json b/app/validators/json_schemas/helm_metadata.json index 7ac36e956f3..a5ff6f0b33a 100644 --- a/app/validators/json_schemas/helm_metadata.json +++ b/app/validators/json_schemas/helm_metadata.json @@ -103,7 +103,23 @@ "import-values": { "type": "array", "items": { - + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "child": { + "type": "string" + }, + "parent": { + "type": "string" + } + }, + "additionalProperties": false + } + ] } }, "alias": { diff --git a/app/validators/json_schemas/npm_package_json.json b/app/validators/json_schemas/npm_package_json.json new file mode 100644 index 00000000000..01bd874d214 --- /dev/null +++ b/app/validators/json_schemas/npm_package_json.json @@ -0,0 +1,26 @@ +{ + "description": "NPM package json metadata", + "type": "object", + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" }, + "dist": { + "type": "object", + "properties": { + "tarball": { "type": "string" }, + "shasum": { "type": "string" } + }, + "additionalProperties": true, + "required": [ + "tarball", + "shasum" + ] + } + }, + "additionalProperties": true, + "required": [ + "name", + "version", + "dist" + ] +} diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml index ba7d747fa04..58ea2be8b61 100644 --- a/app/views/admin/application_settings/_performance_bar.html.haml +++ b/app/views/admin/application_settings/_performance_bar.html.haml @@ -6,7 +6,7 @@ .form-check = f.check_box :performance_bar_enabled, class: 'form-check-input', data: { qa_selector: 'enable_performance_bar_checkbox'} = f.label :performance_bar_enabled, class: 'form-check-label' do - = _("Allow non-administrators to access to the performance bar") + = _("Allow non-administrators access to the performance bar") .form-group = f.label :performance_bar_allowed_group_path, _('Allow access to members of the following group'), class: 'label-bold' = f.text_field :performance_bar_allowed_group_path, class: 'form-control gl-form-input', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path diff --git a/app/views/admin/application_settings/_protected_paths.html.haml b/app/views/admin/application_settings/_protected_paths.html.haml index 04b42f42014..d273c81f51d 100644 --- a/app/views/admin/application_settings/_protected_paths.html.haml +++ b/app/views/admin/application_settings/_protected_paths.html.haml @@ -2,30 +2,26 @@ = form_errors(@application_setting) %fieldset - - if omnibus_protected_paths_throttle? - .bs-callout.bs-callout-danger - - relative_url_link = 'https://docs.gitlab.com/ee/user/admin_area/settings/protected_paths.html#migrate-settings-from-gitlab-123-and-earlier' - - relative_url_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: relative_url_link } - = _("Omnibus Protected Paths throttle is active, and takes priority over these settings. From 12.4, Omnibus throttle is deprecated and will be removed in a future release. Please read the %{relative_url_link_start}Migrating Protected Paths documentation%{relative_url_link_end}.").html_safe % { relative_url_link_start: relative_url_link_start, relative_url_link_end: '</a>'.html_safe } - .form-group .form-check = f.check_box :throttle_protected_paths_enabled, class: 'form-check-input' = f.label :throttle_protected_paths_enabled, class: 'form-check-label' do - = _('Enable protected paths rate limit') + = _('Enable rate limiting for POST requests to the specified paths') %span.form-text.text-muted - = _('Helps reduce request volume for protected paths') + = _('Helps reduce request volume for protected paths.') .form-group - = f.label :throttle_protected_paths_requests_per_period, 'Max requests per period per user', class: 'label-bold' + = f.label :throttle_protected_paths_requests_per_period, 'Maximum requests per period per user', class: 'label-bold' = f.number_field :throttle_protected_paths_requests_per_period, class: 'form-control gl-form-input' .form-group - = f.label :throttle_protected_paths_period_in_seconds, 'Rate limit period in seconds', class: 'label-bold' + = f.label :throttle_protected_paths_period_in_seconds, 'Rate limit period (in seconds)', class: 'label-bold' = f.number_field :throttle_protected_paths_period_in_seconds, class: 'form-control gl-form-input' .form-group = f.label :protected_paths, class: 'label-bold' do + = _('Paths to protect with rate limiting') + = f.text_area :protected_paths_raw, placeholder: '/users/sign_in,/users/password', class: 'form-control gl-form-input', rows: 10 + %span.form-text.text-muted - relative_url_link = 'https://docs.gitlab.com/omnibus/settings/configuration.html#configuring-a-relative-url-for-gitlab' - relative_url_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: relative_url_link } - = _('All paths are relative to the GitLab URL. Do not include %{relative_url_link_start}relative URL%{relative_url_link_end}.').html_safe % { relative_url_link_start: relative_url_link_start, relative_url_link_end: '</a>'.html_safe } - = f.text_area :protected_paths_raw, placeholder: '/users/sign_in,/users/password', class: 'form-control gl-form-input', rows: 10 + = _('All paths are relative to the GitLab URL. Do not include %{relative_url_link_start}relative URLs%{relative_url_link_end}.').html_safe % { relative_url_link_start: relative_url_link_start, relative_url_link_end: '</a>'.html_safe } = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' diff --git a/app/views/admin/application_settings/_sentry.html.haml b/app/views/admin/application_settings/_sentry.html.haml new file mode 100644 index 00000000000..5fd373d59e9 --- /dev/null +++ b/app/views/admin/application_settings/_sentry.html.haml @@ -0,0 +1,22 @@ += form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-sentry-settings'), html: { class: 'fieldset-form', id: 'sentry-settings' } do |f| + = form_errors(@application_setting) + + %span.text-muted + = _('Changing any setting here requires an application restart') + + %fieldset + .form-group + .form-check + = f.check_box :sentry_enabled, class: 'form-check-input' + = f.label :sentry_enabled, _('Enable Sentry error tracking'), class: 'form-check-label' + .form-group + = f.label :sentry_dsn, _('DSN'), class: 'label-light' + = f.text_field :sentry_dsn, class: 'form-control gl-form-input', placeholder: 'https://public@sentry.example.com/1' + .form-group + = f.label :sentry_clientside_dsn, _('Clientside DSN'), class: 'label-light' + = f.text_field :sentry_clientside_dsn, class: 'form-control gl-form-input', placeholder: 'https://public@sentry.example.com/2' + .form-group + = f.label :sentry_environment, _('Environment'), class: 'label-light' + = f.text_field :sentry_environment, class: 'form-control gl-form-input', placeholder: Rails.env + + = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml index 5bdad50c161..4fba1aee12d 100644 --- a/app/views/admin/application_settings/_usage.html.haml +++ b/app/views/admin/application_settings/_usage.html.haml @@ -15,7 +15,7 @@ .form-group - can_be_configured = @application_setting.usage_ping_can_be_configured? .form-check - = f.check_box :usage_ping_enabled, disabled: !can_be_configured, class: 'form-check-input' + = f.check_box :usage_ping_enabled, disabled: !can_be_configured, class: 'form-check-input', data: { qa_selector: 'enable_usage_data_checkbox' } = f.label :usage_ping_enabled, class: 'form-check-label' do = _('Enable Service Ping') .form-text.text-muted diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index b6266c3ea34..e56c898b236 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -1,13 +1,11 @@ -= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-visibility-settings'), html: { class: 'fieldset-form', id: 'visibility-settings' } do |f| += gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-visibility-settings'), html: { class: 'fieldset-form', id: 'visibility-settings' } do |f| = form_errors(@application_setting) %fieldset - = render 'shared/default_branch_protection', f: f, selected_level: @application_setting.default_branch_protection + = render 'shared/default_branch_protection', f: f = render_if_exists 'admin/application_settings/group_owners_can_manage_default_branch_protection_setting', form: f - .form-group - = f.label s_('ProjectCreationLevel|Default project creation protection'), class: 'label-bold' - = f.select :default_project_creation, options_for_select(Gitlab::Access.project_creation_options, @application_setting.default_project_creation), {}, class: 'form-control' + = render 'shared/project_creation_levels', f: f, method: :default_project_creation, legend: s_('ProjectCreationLevel|Default project creation protection') = render_if_exists 'admin/application_settings/default_project_deletion_protection_setting', form: f = render_if_exists 'admin/application_settings/default_delayed_project_deletion_setting', form: f = render_if_exists 'admin/application_settings/default_project_deletion_adjourned_period_setting', form: f diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index a72c96bb577..53ba626760b 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -80,7 +80,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Set the maximum session time for a web terminal.') - = link_to _('How do I use a web terminal?'), help_page_path('ci/environments/index.md', anchor: 'web-terminals'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('How do I use a web terminal?'), help_page_path('ci/environments/index.md', anchor: 'web-terminals-deprecated'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'terminal' diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index 6087551d7c7..7cb5760f62a 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -42,7 +42,7 @@ .js-self-monitoring-settings{ data: self_monitoring_project_data } -%section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded_by_default?) } +%section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'usage_statistics_settings_content' } } .settings-header#usage-statistics %h4 = _('Usage statistics') @@ -54,3 +54,15 @@ = render 'usage' = render_if_exists 'admin/application_settings/pseudonymizer_settings', expanded: expanded_by_default? + +- if Feature.enabled?(:configure_sentry_in_application_settings, default_enabled: :yaml) + %section.settings.as-sentry.no-animate#js-sentry-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'sentry_settings_content' } } + .settings-header + %h4 + = _('Sentry') + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = _('Configure Sentry integration for error tracking') + .settings-content + = render 'sentry' diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index 58e3f3f1136..3a053205725 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -87,14 +87,12 @@ %section.settings.as-protected-paths.no-animate#js-protected-paths-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 - = _('Protected Paths') + = _('Protected paths') %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p - = _('Configure paths to be protected by Rack Attack.') - .help-block - = _('These paths are protected for POST requests.') - = link_to _('More information'), help_page_path('security/rack_attack', anchor: 'protected-paths-throttle'), target: '_blank' + = _('Rate limit access to specified paths.') + = link_to _('Learn more.'), help_page_path('user/admin_area/settings/protected_paths.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'protected_paths' diff --git a/app/views/admin/application_settings/reporting.html.haml b/app/views/admin/application_settings/reporting.html.haml index d2e118f0624..ae6243c3b50 100644 --- a/app/views/admin/application_settings/reporting.html.haml +++ b/app/views/admin/application_settings/reporting.html.haml @@ -13,6 +13,9 @@ .settings-content = render 'spam' +-# this partial is from JiHu, see details in https://gitlab.com/gitlab-jh/gitlab/-/merge_requests/135 += render_if_exists 'admin/application_settings/content_validation_section' + %section.settings.as-abuse.no-animate#js-abuse-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml index 8dcd5f81c23..d9c683cbcc3 100644 --- a/app/views/admin/applications/show.html.haml +++ b/app/views/admin/applications/show.html.haml @@ -6,4 +6,5 @@ = render 'shared/doorkeeper/applications/show', edit_path: edit_admin_application_path(@application), delete_path: admin_application_path(@application), + index_path: admin_applications_path, show_trusted_row: true diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 681e7ccb613..4197d5b961f 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -153,9 +153,9 @@ %span.float-right #{Rails::VERSION::STRING} %p - = Gitlab::Database.main.human_adapter_name + = ApplicationRecord.database.human_adapter_name %span.float-right - = Gitlab::Database.main.version + = ApplicationRecord.database.version %p = _('Redis') %span.float-right diff --git a/app/views/admin/dashboard/stats.html.haml b/app/views/admin/dashboard/stats.html.haml index b98d11b734b..e0701812ba3 100644 --- a/app/views/admin/dashboard/stats.html.haml +++ b/app/views/admin/dashboard/stats.html.haml @@ -1,74 +1,75 @@ - page_title s_('AdminArea|Users statistics') -%h3.my-4 +%h3.gl-my-6 = s_('AdminArea|Users statistics') %table.table.gl-text-gray-500 %tr - %td.p-3 + %td.gl-p-5! = s_('AdminArea|Users without a Group and Project') = render_if_exists 'admin/dashboard/included_free_in_license_tooltip' - %td.p-3.text-right - = @users_statistics&.without_groups_and_projects.to_i + %td.gl-text-right{ class: 'gl-p-5!' } + = @users_statistics&.without_groups_and_projects + = render_if_exists 'admin/dashboard/minimal_access_stats_row', users_statistics: @users_statistics %tr - %td.p-3 + %td.gl-p-5! = s_('AdminArea|Users with highest role') %strong = s_('AdminArea|Guest') = render_if_exists 'admin/dashboard/included_free_in_license_tooltip' - %td.p-3.text-right - = @users_statistics&.with_highest_role_guest.to_i + %td.gl-text-right{ class: 'gl-p-5!' } + = @users_statistics&.with_highest_role_guest %tr - %td.p-3 + %td.gl-p-5! = s_('AdminArea|Users with highest role') %strong = s_('AdminArea|Reporter') - %td.p-3.text-right - = @users_statistics&.with_highest_role_reporter.to_i + %td.gl-text-right{ class: 'gl-p-5!' } + = @users_statistics&.with_highest_role_reporter %tr - %td.p-3 + %td.gl-p-5! = s_('AdminArea|Users with highest role') %strong = s_('AdminArea|Developer') - %td.p-3.text-right - = @users_statistics&.with_highest_role_developer.to_i + %td.gl-text-right{ class: 'gl-p-5!' } + = @users_statistics&.with_highest_role_developer %tr - %td.p-3 + %td.gl-p-5! = s_('AdminArea|Users with highest role') %strong = s_('AdminArea|Maintainer') - %td.p-3.text-right - = @users_statistics&.with_highest_role_maintainer.to_i + %td.gl-text-right{ class: 'gl-p-5!' } + = @users_statistics&.with_highest_role_maintainer %tr - %td.p-3 + %td.gl-p-5! = s_('AdminArea|Users with highest role') %strong = s_('AdminArea|Owner') - %td.p-3.text-right - = @users_statistics&.with_highest_role_owner.to_i + %td.gl-text-right{ class: 'gl-p-5!' } + = @users_statistics&.with_highest_role_owner %tr - %td.p-3 + %td.gl-p-5! = s_('AdminArea|Bots') - %td.p-3.text-right - = @users_statistics&.bots.to_i + %td.gl-text-right{ class: 'gl-p-5!' } + = @users_statistics&.bots = render_if_exists 'admin/dashboard/billable_users_row' %tr.bg-gray-light.gl-text-gray-900 - %td.p-3 + %td.gl-p-5! %strong = s_('AdminArea|Active users') - %td.p-3.text-right + %td.gl-text-right{ class: 'gl-p-5!' } %strong - = @users_statistics&.active.to_i + = @users_statistics&.active %tr.bg-gray-light.gl-text-gray-900 - %td.p-3 + %td.gl-p-5! %strong = s_('AdminArea|Blocked users') - %td.p-3.text-right + %td.gl-text-right{ class: 'gl-p-5!' } %strong - = @users_statistics&.blocked.to_i + = @users_statistics&.blocked %tr.bg-gray-light.gl-text-gray-900 - %td.p-3 + %td.gl-p-5! %strong = s_('AdminArea|Total users') - %td.p-3.text-right + %td.gl-text-right{ class: 'gl-p-5!' } %strong - = @users_statistics&.total.to_i + = @users_statistics&.total diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml index eec8f816f04..ba4abdc02e4 100644 --- a/app/views/admin/deploy_keys/index.html.haml +++ b/app/views/admin/deploy_keys/index.html.haml @@ -1,33 +1,44 @@ - page_title _('Deploy Keys') -- if @deploy_keys.any? - %h3.page-title.deploy-keys-title - = _('Public deploy keys (%{deploy_keys_count})') % { deploy_keys_count: @deploy_keys.load.size } - = link_to _('New deploy key'), new_admin_deploy_key_path, class: 'float-right btn gl-button btn-confirm btn-md gl-button' - .table-holder.deploy-keys-list - %table.table + +- if Feature.enabled?(:admin_deploy_keys_vue, default_enabled: :yaml) + #js-admin-deploy-keys-table{ data: admin_deploy_keys_data } +- else + - if @deploy_keys.any? + %h3.page-title.deploy-keys-title + = _('Public deploy keys (%{deploy_keys_count})') % { deploy_keys_count: @deploy_keys.load.size } + = link_to _('New deploy key'), new_admin_deploy_key_path, class: 'float-right btn gl-button btn-confirm btn-md gl-button' + %table.table.b-table.gl-table.b-table-stacked-lg{ data: { testid: 'deploy-keys-list' } } %thead %tr - %th.col-sm-2= _('Title') - %th.col-sm-4= _('Fingerprint') - %th.col-sm-2= _('Projects with write access') - %th.col-sm-2= _('Added at') - %th.col-sm-2 + %th= _('Title') + %th= _('Fingerprint') + %th= _('Projects with write access') + %th= _('Created') + %th.gl-lg-w-1px.gl-white-space-nowrap + %span.gl-sr-only + = _('Actions') %tbody - @deploy_keys.each do |deploy_key| %tr - %td - %strong= deploy_key.title - %td - %code.key-fingerprint= deploy_key.fingerprint - %td - - deploy_key.projects_with_write_access.each do |project| - = link_to project.full_name, admin_project_path(project), class: 'label deploy-project-label' - %td - %span.cgray - = _('added %{created_at_timeago}').html_safe % { created_at_timeago: time_ago_with_tooltip(deploy_key.created_at) } - %td - .float-right - = link_to _('Edit'), edit_admin_deploy_key_path(deploy_key), class: 'btn gl-button btn-sm' - = link_to _('Remove'), admin_deploy_key_path(deploy_key), data: { confirm: _('Are you sure?') }, method: :delete, class: 'gl-button btn btn-sm btn-danger delete-key' -- else - = render 'shared/empty_states/deploy_keys' + %td{ data: { label: _('Title') } } + %div + = deploy_key.title + %td{ data: { label: _('Fingerprint') } } + %div + %code= deploy_key.fingerprint + %td{ data: { label: _('Projects with write access') } } + %div + - deploy_key.projects_with_write_access.each do |project| + = link_to project.full_name, admin_project_path(project), class: 'gl-display-block' + %td{ data: { label: _('Created') } } + %div + = time_ago_with_tooltip(deploy_key.created_at) + %td.gl-lg-w-1px.gl-white-space-nowrap{ data: { label: _('Actions') } } + %div + = link_to edit_admin_deploy_key_path(deploy_key), class: 'btn btn-default btn-md gl-button btn-icon gl-mr-3', aria: { label: _('Edit deploy key') } do + = sprite_icon('pencil', css_class: 'gl-button-icon') + = link_to admin_deploy_key_path(deploy_key), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn btn-danger btn-md gl-button btn-icon', aria: { label: _('Remove deploy key') } do + = sprite_icon('remove', css_class: 'gl-button-icon') + + - else + = render 'shared/empty_states/deploy_keys' diff --git a/app/views/admin/dev_ops_report/_report.html.haml b/app/views/admin/dev_ops_report/_score.html.haml index 208afefc73b..208afefc73b 100644 --- a/app/views/admin/dev_ops_report/_report.html.haml +++ b/app/views/admin/dev_ops_report/_score.html.haml diff --git a/app/views/admin/dev_ops_report/show.html.haml b/app/views/admin/dev_ops_report/show.html.haml index c16ef7af76d..a2425b93ad3 100644 --- a/app/views/admin/dev_ops_report/show.html.haml +++ b/app/views/admin/dev_ops_report/show.html.haml @@ -1,4 +1,4 @@ -- page_title _('DevOps Report') +- page_title _('DevOps Reports') - add_page_specific_style 'page_bundles/dev_ops_report' .container @@ -6,5 +6,5 @@ - if show_adoption? = render_if_exists 'admin/dev_ops_report/devops_tabs' - else - = render 'report' + = render 'score' diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index f298fce7bcf..0539d7f13a2 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -1,4 +1,4 @@ - breadcrumb_title _('Runners') - page_title _('Runners') -#js-admin-runners{ data: { registration_token: Gitlab::CurrentSettings.runners_registration_token, runner_install_help_page: 'https://docs.gitlab.com/runner/install/', active_runners_count: @active_runners_count } } +#js-admin-runners{ data: admin_runners_data_attributes } diff --git a/app/views/admin/topics/_topic.html.haml b/app/views/admin/topics/_topic.html.haml index abf3cffa422..959e7ab31fc 100644 --- a/app/views/admin/topics/_topic.html.haml +++ b/app/views/admin/topics/_topic.html.haml @@ -6,7 +6,7 @@ .gl-min-w-0.gl-flex-grow-1 .title - = topic.name + = link_to topic.name, topic_explore_projects_path(topic_name: topic.name) .stats.gl-text-gray-500.gl-flex-shrink-0.gl-display-none.gl-sm-display-flex %span.gl-ml-5.has-tooltip{ title: n_('%d project', '%d projects', topic.total_projects_count) % topic.total_projects_count } diff --git a/app/views/admin/users/_tabs.html.haml b/app/views/admin/users/_tabs.html.haml index 90f06eeaf3f..6c14e1189fe 100644 --- a/app/views/admin/users/_tabs.html.haml +++ b/app/views/admin/users/_tabs.html.haml @@ -1,7 +1,3 @@ -%ul.nav-links.nav-tabs.nav.js-users-tabs{ role: 'tablist' } - %li.nav-item{ role: 'presentation' } - %a.nav-link{ href: admin_users_path, class: active_when(current_page?(admin_users_path)), role: 'tab' } - = s_('AdminUsers|Users') - %li.nav-item{ role: 'presentation' } - %a.nav-link{ href: admin_cohorts_path, class: active_when(current_page?(admin_cohorts_path)), role: 'tab' } - = s_('AdminUsers|Cohorts') += gl_tabs_nav({ class: 'js-users-tabs' }) do + = gl_tab_link_to s_('AdminUsers|Users'), admin_users_path + = gl_tab_link_to s_('AdminUsers|Cohorts'), admin_cohorts_path diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml index 1a43d91b800..e62e4cfa192 100644 --- a/app/views/admin/users/_users.html.haml +++ b/app/views/admin/users/_users.html.haml @@ -3,48 +3,38 @@ = sprite_icon('chevron-lg-left', size: 12) .fade-right = sprite_icon('chevron-lg-right', size: 12) - %ul.nav-links.nav.nav-tabs.scrolling-tabs - = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do - = link_to admin_users_path do - = s_('AdminUsers|Active') - %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.active_without_ghosts) - = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do - = link_to admin_users_path(filter: "admins") do - = s_('AdminUsers|Admins') - %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.admins) - = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do - = link_to admin_users_path(filter: 'two_factor_enabled') do - = s_('AdminUsers|2FA Enabled') - %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.with_two_factor) - = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do - = link_to admin_users_path(filter: 'two_factor_disabled') do - = s_('AdminUsers|2FA Disabled') - %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.without_two_factor) - = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do - = link_to admin_users_path(filter: 'external') do - = s_('AdminUsers|External') - %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.external) - = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do - = link_to admin_users_path(filter: "blocked") do - = s_('AdminUsers|Blocked') - %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.blocked) + = gl_tabs_nav({ class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1' }) do + = gl_tab_link_to admin_users_path, { item_active: active_when(params[:filter].nil?), class: 'gl-border-0!' } do + = s_('AdminUsers|Active') + = gl_tab_counter_badge(limited_counter_with_delimiter(User.active_without_ghosts)) + = gl_tab_link_to admin_users_path(filter: "admins"), { item_active: active_when(params[:filter] == 'admins'), class: 'gl-border-0!' } do + = s_('AdminUsers|Admins') + = gl_tab_counter_badge(limited_counter_with_delimiter(User.admins)) + = gl_tab_link_to admin_users_path(filter: 'two_factor_enabled'), { item_active: active_when(params[:filter] == 'two_factor_enabled'), class: 'filter-two-factor-enabled gl-border-0!' } do + = s_('AdminUsers|2FA Enabled') + = gl_tab_counter_badge(limited_counter_with_delimiter(User.with_two_factor)) + = gl_tab_link_to admin_users_path(filter: 'two_factor_disabled'), { item_active: active_when(params[:filter] == 'two_factor_disabled'), class: 'filter-two-factor-disabled gl-border-0!' } do + = s_('AdminUsers|2FA Disabled') + = gl_tab_counter_badge(limited_counter_with_delimiter(User.without_two_factor)) + = gl_tab_link_to admin_users_path(filter: 'external'), { item_active: active_when(params[:filter] == 'external'), class: 'gl-border-0!' } do + = s_('AdminUsers|External') + = gl_tab_counter_badge(limited_counter_with_delimiter(User.external)) + = gl_tab_link_to admin_users_path(filter: "blocked"), { item_active: active_when(params[:filter] == 'blocked'), class: 'gl-border-0!' } do + = s_('AdminUsers|Blocked') + = gl_tab_counter_badge(limited_counter_with_delimiter(User.blocked)) - if ban_feature_available? - = nav_link(html_options: { class: active_when(params[:filter] == 'banned') }) do - = link_to admin_users_path(filter: "banned") do - = s_('AdminUsers|Banned') - %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.banned) - = nav_link(html_options: { class: "#{active_when(params[:filter] == 'blocked_pending_approval')} filter-blocked-pending-approval" }) do - = link_to admin_users_path(filter: "blocked_pending_approval"), data: { qa_selector: 'pending_approval_tab' } do - = s_('AdminUsers|Pending approval') - %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.blocked_pending_approval) - = nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do - = link_to admin_users_path(filter: "deactivated") do - = s_('AdminUsers|Deactivated') - %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.deactivated) - = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do - = link_to admin_users_path(filter: "wop") do - = s_('AdminUsers|Without projects') - %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.without_projects) + = gl_tab_link_to admin_users_path(filter: "banned"), { item_active: active_when(params[:filter] == 'banned'), class: 'gl-border-0!' } do + = s_('AdminUsers|Banned') + = gl_tab_counter_badge(limited_counter_with_delimiter(User.banned)) + = gl_tab_link_to admin_users_path(filter: "blocked_pending_approval"), { item_active: active_when(params[:filter] == 'blocked_pending_approval'), class: 'filter-blocked-pending-approval gl-border-0!', data: { qa_selector: 'pending_approval_tab' } } do + = s_('AdminUsers|Pending approval') + = gl_tab_counter_badge(limited_counter_with_delimiter(User.blocked_pending_approval)) + = gl_tab_link_to admin_users_path(filter: "deactivated"), { item_active: active_when(params[:filter] == 'deactivated'), class: 'gl-border-0!' } do + = s_('AdminUsers|Deactivated') + = gl_tab_counter_badge(limited_counter_with_delimiter(User.deactivated)) + = gl_tab_link_to admin_users_path(filter: "wop"), { item_active: active_when(params[:filter] == 'wop'), class: 'gl-border-0!' } do + = s_('AdminUsers|Without projects') + = gl_tab_counter_badge(limited_counter_with_delimiter(User.without_projects)) .nav-controls = render_if_exists 'admin/users/admin_email_users' = render_if_exists 'admin/users/admin_export_user_permissions' diff --git a/app/views/clusters/clusters/_cluster_list.html.haml b/app/views/clusters/clusters/_cluster_list.html.haml index 38ed7e334c9..e5e1b68225e 100644 --- a/app/views/clusters/clusters/_cluster_list.html.haml +++ b/app/views/clusters/clusters/_cluster_list.html.haml @@ -1,12 +1,10 @@ -- if clusters.empty? - = render 'empty_state' -- else +- if !clusters.empty? .top-area.adjust .gl-display-block.gl-text-right.gl-my-4.gl-w-full - if clusterable.can_add_cluster? - = link_to s_('ClusterIntegration|Connect cluster with certificate'), clusterable.new_path, class: 'btn gl-button btn-confirm js-add-cluster gl-py-2', qa_selector: :integrate_kubernetes_cluster_button + = link_to s_('ClusterIntegration|Connect cluster with certificate'), clusterable.new_path, class: 'btn gl-button btn-confirm js-add-cluster gl-py-2', data: { qa_selector: 'integrate_kubernetes_cluster_button' } - else %span.btn.gl-button.btn-confirm.js-add-cluster.disabled.gl-py-2 = s_("ClusterIntegration|Connect cluster with certificate") - #js-clusters-list-app{ data: js_clusters_list_data(clusterable.index_path(format: :json)) } +#js-clusters-list-app{ data: js_clusters_list_data(clusterable) } diff --git a/app/views/clusters/clusters/_empty_state.html.haml b/app/views/clusters/clusters/_empty_state.html.haml deleted file mode 100644 index feef3e0027f..00000000000 --- a/app/views/clusters/clusters/_empty_state.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -.row.empty-state - .col-12 - .svg-content= image_tag 'illustrations/clusters_empty.svg' - .col-12 - .text-content - %h4.gl-text-center= s_('ClusterIntegration|Integrate Kubernetes with a cluster certificate') - %p.gl-text-center - = s_('ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way.') - = clusterable.empty_state_help_text - = clusterable.learn_more_link - - - if clusterable.can_add_cluster? - .gl-text-center - = link_to s_('ClusterIntegration|Integrate with a cluster certificate'), clusterable.new_path, class: 'gl-button btn btn-confirm', data: { qa_selector: 'add_kubernetes_cluster_link' } diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml index f6d50410e9a..7142dd83dce 100644 --- a/app/views/clusters/clusters/aws/_new.html.haml +++ b/app/views/clusters/clusters/aws/_new.html.haml @@ -11,7 +11,7 @@ 'external-id' => @aws_role.role_external_id, 'role-arn' => @aws_role.role_arn, 'instance-types' => @instance_types, - 'kubernetes-integration-help-path' => help_page_path('user/project/clusters/index'), + 'kubernetes-integration-help-path' => help_page_path('user/infrastructure/clusters/index.md'), 'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_eks_clusters.md', anchor: 'how-to-create-a-new-cluster-on-eks-through-cluster-certificates-deprecated'), 'create-role-arn-help-path' => help_page_path('user/project/clusters/add_eks_clusters.md', anchor: 'how-to-create-a-new-cluster-on-eks-through-cluster-certificates-deprecated'), 'external-link-icon' => sprite_icon('external-link') } } diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml index 5266fad9278..173456926a5 100644 --- a/app/views/clusters/clusters/gcp/_form.html.haml +++ b/app/views/clusters/clusters/gcp/_form.html.haml @@ -2,7 +2,7 @@ - zones_link_url = 'https://cloud.google.com/compute/docs/regions-zones/regions-zones' - machine_type_link_url = 'https://cloud.google.com/compute/docs/machine-types' - pricing_link_url = 'https://cloud.google.com/compute/pricing#machinetype' -- kubernetes_integration_url = help_page_path('user/project/clusters/index') +- kubernetes_integration_url = help_page_path('user/infrastructure/clusters/index.md') - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe - help_link_end = ' %{external_link_icon}</a>'.html_safe % { external_link_icon: external_link_icon } @@ -74,7 +74,7 @@ label_class: 'label-bold' } .form-text.text-muted = s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.') - = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md', anchor: 'gitlab-managed-clusters'), target: '_blank' + = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank' .form-group = field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' } diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml index 45287a01cc9..457e34b306a 100644 --- a/app/views/clusters/clusters/index.html.haml +++ b/app/views/clusters/clusters/index.html.haml @@ -3,24 +3,10 @@ = render_gcp_signup_offer -.clusters-container.gl-my-2 +.clusters-container - if display_cluster_agents?(clusterable) - .js-toggle-container - %ul.nav-links.nav-tabs.nav{ role: 'tablist' } - %li.nav-item{ role: 'presentation' } - %a.nav-link.active{ href: "#certificate-clusters-pane", id: "certificate-clusters-tab", data: { toggle: 'tab' }, role: 'tab' } - %span= s_('ClusterIntegration|Clusters connected with a certificate') - - %li.nav-item{ role: 'presentation' } - %a.nav-link{ href: "#agent-clusters-pane", id: "agent-clusters-tab", data: { toggle: 'tab' }, role: 'tab' } - %span= s_('ClusterIntegration|GitLab Agent managed clusters') - - .tab-content - .tab-pane.active{ id: 'certificate-clusters-pane', role: 'tabpanel' } - = render 'cluster_list', clusters: @clusters - - .tab-pane{ id: 'agent-clusters-pane', role: 'tabpanel' } - #js-cluster-agents-list{ data: js_cluster_agents_list_data(clusterable) } + .gl-my-6 + .js-clusters-main-view{ data: js_clusters_data(clusterable) } - else = render 'cluster_list', clusters: @clusters diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml index 74e336723ba..7af7a812338 100644 --- a/app/views/clusters/clusters/new.html.haml +++ b/app/views/clusters/clusters/new.html.haml @@ -1,31 +1,31 @@ - breadcrumb_title _('Kubernetes') - page_title _('Kubernetes Cluster') -- active_tab = local_assigns.fetch(:active_tab, 'create') - provider = params[:provider] +- active_tab = params[:tab] || local_assigns.fetch(:active_tab, 'create') +- is_active_tab_create = active_tab === 'create' +- is_active_tab_add = active_tab === 'add' = render_gcp_signup_offer .row.gl-mt-3 .col-md-3 = render 'sidebar' - .col-md-9.js-toggle-container - %ul.nav-links.nav-tabs.gitlab-tabs.nav{ role: 'tablist' } - %li.nav-item{ role: 'presentation' } - %a.nav-link{ href: '#create-cluster-pane', id: 'create-cluster-tab', class: active_when(active_tab == 'create'), data: { toggle: 'tab' }, role: 'tab' } - %span - = create_new_cluster_label(provider: params[:provider]) - %li.nav-item{ role: 'presentation' } - %a.nav-link{ href: '#add-cluster-pane', id: 'add-cluster-tab', class: active_when(active_tab == 'add'), data: { toggle: 'tab', qa_selector: 'add_existing_cluster_tab' }, role: 'tab' } - %span= s_('ClusterIntegration|Connect existing cluster') + .col-md-9 + = gl_tabs_nav({ class: 'nav-justified' }) do + = gl_tab_link_to clusterable.new_path(tab: 'create'), { item_active: is_active_tab_create } do + %span= create_new_cluster_label(provider: params[:provider]) + = gl_tab_link_to s_('ClusterIntegration|Connect existing cluster'), clusterable.new_path(tab: 'add'), { item_active: is_active_tab_add, qa_selector: 'add_existing_cluster_tab' } - .tab-content.gitlab-tab-content - .tab-pane.p-0{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' } - = render 'clusters/clusters/cloud_providers/cloud_provider_selector' + .tab-content + - if is_active_tab_create + .tab-pane.active{ role: 'tabpanel' } + = render 'clusters/clusters/cloud_providers/cloud_provider_selector' - - if ['aws', 'gcp'].include?(provider) - .p-3.border-top - = render "clusters/clusters/#{provider}/new" + - if ['aws', 'gcp'].include?(provider) + .p-3.border-top + = render "clusters/clusters/#{provider}/new" - .tab-pane{ id: 'add-cluster-pane', class: active_when(active_tab == 'add'), role: 'tabpanel' } - #js-cluster-new{ data: js_cluster_new } - = render 'clusters/clusters/user/form' + - if is_active_tab_add + .tab-pane.active.gl-p-5{ role: 'tabpanel' } + #js-cluster-new{ data: js_cluster_new } + = render 'clusters/clusters/user/form' diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 2a09d8d8cc0..e4c8f225ed2 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -13,7 +13,7 @@ cluster_status: @cluster.status_name, cluster_status_reason: @cluster.status_reason, provider_type: @cluster.provider_type, - help_path: help_page_path('user/project/clusters/index.md'), + help_path: help_page_path('user/infrastructure/clusters/index.md'), environments_help_path: help_page_path('ci/environments/index.md', anchor: 'create-a-static-environment'), clusters_help_path: help_page_path('user/project/clusters/deploy_to_cluster.md'), deploy_boards_help_path: help_page_path('user/project/deploy_boards.md', anchor: 'enabling-deploy-boards'), diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml index fdaf2107686..b94b14bf6bd 100644 --- a/app/views/dashboard/_projects_head.html.haml +++ b/app/views/dashboard/_projects_head.html.haml @@ -14,19 +14,7 @@ .top-area.scrolling-tabs-container.inner-page-scroll-tabs .fade-left= sprite_icon('chevron-lg-left', size: 12) .fade-right= sprite_icon('chevron-lg-right', size: 12) - %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs{ class: ('border-0' if feature_project_list_filter_bar) } - = nav_link(page: [dashboard_projects_path, root_path]) do - = link_to dashboard_projects_path, class: 'shortcuts-activity', data: {placement: 'right'} do - = _("Your projects") - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(@total_user_projects_count) - = nav_link(page: starred_dashboard_projects_path) do - = link_to starred_dashboard_projects_path, data: {placement: 'right'} do - = _("Starred projects") - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(@total_starred_projects_count) - = nav_link(page: [explore_root_path, trending_explore_projects_path, starred_explore_projects_path, explore_projects_path]) do - = link_to explore_root_path, data: {placement: 'right'} do - = _("Explore projects") - = render_if_exists "dashboard/removed_projects_tab", removed_projects_count: @removed_projects_count + = render 'dashboard/projects_nav' - unless feature_project_list_filter_bar .nav-controls = render 'shared/projects/search_form' diff --git a/app/views/dashboard/_projects_nav.html.haml b/app/views/dashboard/_projects_nav.html.haml new file mode 100644 index 00000000000..64aa1e01d06 --- /dev/null +++ b/app/views/dashboard/_projects_nav.html.haml @@ -0,0 +1,13 @@ +- is_your_projects_path = current_page?(dashboard_projects_path) || current_page?(root_path) +- is_explore_projects_path = current_page?(explore_root_path) || current_page?(trending_explore_projects_path) || current_page?(starred_explore_projects_path) || current_page?(explore_projects_path) + += gl_tabs_nav({ class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1 gl-flex-nowrap gl-border-0' }) do + = gl_tab_link_to dashboard_projects_path, { item_active: is_your_projects_path, class: 'shortcuts-activity', data: { placement: 'right' } } do + = _("Your projects") + = gl_tab_counter_badge(limited_counter_with_delimiter(@total_user_projects_count)) + = gl_tab_link_to starred_dashboard_projects_path, { data: { placement: 'right' } } do + = _("Starred projects") + = gl_tab_counter_badge(limited_counter_with_delimiter(@total_starred_projects_count)) + = gl_tab_link_to _("Explore projects"), explore_root_path, { item_active: is_explore_projects_path, data: { placement: 'right' } } + = gl_tab_link_to _("Explore topics"), topics_explore_projects_path, { data: { placement: 'right' } } + = render_if_exists "dashboard/removed_projects_tab", removed_projects_count: @removed_projects_count diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml index e96b5695ddc..8c468812e33 100644 --- a/app/views/dashboard/_snippets_head.html.haml +++ b/app/views/dashboard/_snippets_head.html.haml @@ -7,10 +7,6 @@ = link_to _("New snippet"), new_snippet_path, class: "gl-button btn btn-confirm", title: _("New snippet") .top-area - %ul.nav-links.nav.nav-tabs - = nav_link(page: dashboard_snippets_path, html_options: {class: 'home'}) do - = link_to dashboard_snippets_path, title: 'Your snippets', data: {placement: 'right'} do - Your snippets - = nav_link(page: explore_snippets_path) do - = link_to explore_snippets_path, title: 'Explore snippets', data: {placement: 'right'} do - Explore snippets + = gl_tabs_nav({ class: 'gl-border-0' }) do + = gl_tab_link_to _('Your snippets'), dashboard_snippets_path, { title: _('Your snippets') } + = gl_tab_link_to _('Explore snippets'), explore_snippets_path, { title: _('Explore snippets') } diff --git a/app/views/dashboard/projects/_nav.html.haml b/app/views/dashboard/projects/_nav.html.haml index f9b61bf1f3e..3e39872902d 100644 --- a/app/views/dashboard/projects/_nav.html.haml +++ b/app/views/dashboard/projects/_nav.html.haml @@ -14,8 +14,6 @@ = link_to s_('DashboardProjects|All'), dashboard_projects_path, class: params[:personal].present? ? inactive_class : active_class = link_to s_('DashboardProjects|Personal'), filter_projects_path(personal: true), class: params[:personal].present? ? active_class : inactive_class - else - %ul.nav-links.mobile-separator.nav.nav-tabs - = nav_link(html_options: { class: ("active" unless params[:personal].present?) }) do - = link_to s_('DashboardProjects|All'), dashboard_projects_path - = nav_link(html_options: { class: ("active" if params[:personal].present?) }) do - = link_to s_('DashboardProjects|Personal'), filter_projects_path(personal: true) + = gl_tabs_nav do + = gl_tab_link_to s_('DashboardProjects|All'), dashboard_projects_path, { item_active: params[:personal].blank? } + = gl_tab_link_to s_('DashboardProjects|Personal'), filter_projects_path(personal: true), { item_active: params[:personal].present? } diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 58f817bf63b..aa54a1e589e 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -10,19 +10,13 @@ - if current_user.todos.any? .top-area - %ul.nav-links.mobile-separator.nav.nav-tabs - %li.todos-pending{ class: active_when(params[:state].blank? || params[:state] == 'pending') }> - = link_to todos_filter_path(state: 'pending') do - %span - To Do - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm - = number_with_delimiter(todos_pending_count) - %li.todos-done{ class: active_when(params[:state] == 'done') }> - = link_to todos_filter_path(state: 'done') do - %span - Done - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm - = number_with_delimiter(todos_done_count) + = gl_tabs_nav({ class: 'gl-flex-grow-1 gl-border-0' }) do + = gl_tab_link_to todos_filter_path(state: 'pending'), item_active: params[:state].blank? || params[:state] == 'pending', class: "js-todos-pending" do + = _("To Do") + = gl_tab_counter_badge number_with_delimiter(todos_pending_count) + = gl_tab_link_to todos_filter_path(state: 'done'), item_active: params[:state] == 'done', class: "js-todos-done" do + = _("Done") + = gl_tab_counter_badge number_with_delimiter(todos_done_count) .nav-controls - if @allowed_todos.any?(&:pending?) diff --git a/app/views/devise/confirmations/new.html.haml b/app/views/devise/confirmations/new.html.haml index 51354618aa4..eee223ff63c 100644 --- a/app/views/devise/confirmations/new.html.haml +++ b/app/views/devise/confirmations/new.html.haml @@ -7,7 +7,12 @@ .form-group = f.label :email = f.email_field :email, class: "form-control gl-form-input", required: true, title: _('Please provide a valid email address.'), value: nil - .clearfix + + %div + - if recaptcha_enabled? + = recaptcha_tags nonce: content_security_policy_nonce + + .gl-mt-5 = f.submit _("Resend"), class: 'gl-button btn btn-confirm' .clearfix.prepend-top-20 diff --git a/app/views/devise/passwords/new.html.haml b/app/views/devise/passwords/new.html.haml index 7f6ce712af2..7bbde4a39c7 100644 --- a/app/views/devise/passwords/new.html.haml +++ b/app/views/devise/passwords/new.html.haml @@ -8,7 +8,12 @@ = f.email_field :email, class: "form-control gl-form-input", required: true, value: params[:user_email], autofocus: true, title: _('Please provide a valid email address.') .form-text.text-muted = _('Requires your primary GitLab email address.') - .clearfix + + %div + - if recaptcha_enabled? + = recaptcha_tags nonce: content_security_policy_nonce + + .gl-mt-5 = f.submit _("Reset password"), class: "gl-button btn-confirm btn" .clearfix.prepend-top-20 diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml index 75521d42f7e..3a568421ce9 100644 --- a/app/views/doorkeeper/applications/show.html.haml +++ b/app/views/doorkeeper/applications/show.html.haml @@ -6,4 +6,7 @@ %h3.page-title = _("Application: %{name}") % { name: @application.name } -= render 'shared/doorkeeper/applications/show', edit_path: edit_oauth_application_path(@application), delete_path: oauth_application_path(@application) += render 'shared/doorkeeper/applications/show', + edit_path: edit_oauth_application_path(@application), + delete_path: oauth_application_path(@application), + index_path: oauth_applications_path diff --git a/app/views/errors/omniauth_error.html.haml b/app/views/errors/omniauth_error.html.haml index 0e9041d07ea..e114e4609f8 100644 --- a/app/views/errors/omniauth_error.html.haml +++ b/app/views/errors/omniauth_error.html.haml @@ -1,7 +1,7 @@ - content_for(:title, 'Auth Error') .container - = render "shared/errors/graphic_422.svg" + = render partial: "shared/errors/graphic_422", formats: :svg %h3 Sign-in using #{@provider} auth failed %p.light.subtitle Sign-in failed because #{@error}. diff --git a/app/views/explore/groups/_nav.html.haml b/app/views/explore/groups/_nav.html.haml index c337149a2f3..3c9c4e9f76b 100644 --- a/app/views/explore/groups/_nav.html.haml +++ b/app/views/explore/groups/_nav.html.haml @@ -1,8 +1,6 @@ .top-area - %ul.nav-links.nav.nav-tabs - = nav_link(page: explore_groups_path) do - = link_to explore_groups_path do - = _("Explore Groups") + = gl_tabs_nav({ class: 'gl-display-flex gl-flex-grow-1 gl-border-none'}) do + = gl_tab_link_to _("Explore Groups"), explore_groups_path .nav-controls = render 'shared/groups/search_form' = render 'shared/groups/dropdown' diff --git a/app/views/explore/projects/_nav.html.haml b/app/views/explore/projects/_nav.html.haml index 65b7d055843..9d7a6f1ccfb 100644 --- a/app/views/explore/projects/_nav.html.haml +++ b/app/views/explore/projects/_nav.html.haml @@ -1,14 +1,8 @@ .top-area - %ul.nav-links.nav.nav-tabs - = nav_link(page: [explore_projects_path, explore_root_path]) do - = link_to explore_projects_path do - = _('All') - = nav_link(page: starred_explore_projects_path) do - = link_to starred_explore_projects_path do - = _('Most stars') - = nav_link(page: trending_explore_projects_path) do - = link_to trending_explore_projects_path do - = _('Trending') + = gl_tabs_nav({ class: 'gl-display-flex gl-flex-grow-1 gl-border-none'}) do + = gl_tab_link_to _('All'), explore_projects_path, { item_active: current_page?(explore_projects_path) || current_page?(explore_root_path) } + = gl_tab_link_to _('Most stars'), starred_explore_projects_path + = gl_tab_link_to _('Trending'), trending_explore_projects_path .nav-controls - unless current_user diff --git a/app/views/explore/projects/topic.html.haml b/app/views/explore/projects/topic.html.haml new file mode 100644 index 00000000000..aeb040ea61f --- /dev/null +++ b/app/views/explore/projects/topic.html.haml @@ -0,0 +1,31 @@ +- @hide_top_links = false +- @no_container = true +- page_title @topic.name, _("Topics") +- max_topic_name_length = 50 + += render_dashboard_ultimate_trial(current_user) + +.gl-text-center.gl-bg-gray-10.gl-pb-2.gl-pt-6 + .gl-pb-5.gl-align-items-center.gl-justify-content-center.gl-display-flex + .avatar-container.rect-avatar.s60.gl-flex-shrink-0 + = topic_icon(@topic, alt: _('Topic avatar'), class: 'avatar topic-avatar s60') + - if @topic.name.length > max_topic_name_length + %h1.gl-mt-3.str-truncated.has-tooltip{ title: @topic.name } + = truncate(@topic.name, length: max_topic_name_length) + - else + %h1.gl-mt-3 + = @topic.name + - if @topic.description.present? + .topic-description.gl-ml-4.gl-mr-4 + = markdown(@topic.description) + +%div{ class: container_class } + .gl-py-5.gl-border-gray-100.gl-border-b-solid.gl-border-b-1 + %h3.gl-m-0= _('Projects with this topic') + .top-area.gl-pt-2.gl-pb-2 + .nav-controls + = render 'shared/projects/search_form' + = render 'shared/projects/dropdown' + = render 'filter' + + = render 'projects', projects: @projects diff --git a/app/views/explore/projects/topics.html.haml b/app/views/explore/projects/topics.html.haml new file mode 100644 index 00000000000..228304d25b6 --- /dev/null +++ b/app/views/explore/projects/topics.html.haml @@ -0,0 +1,12 @@ +- @hide_top_links = true +- page_title _("Topics") +- header_title _("Topics"), topics_explore_projects_path + += render_dashboard_ultimate_trial(current_user) + +- if current_user + = render 'explore/topics/head' +- else + = render 'explore/head' + += render partial: 'shared/topics/list' diff --git a/app/views/explore/topics/_head.html.haml b/app/views/explore/topics/_head.html.haml new file mode 100644 index 00000000000..f5ee95b16c3 --- /dev/null +++ b/app/views/explore/topics/_head.html.haml @@ -0,0 +1,9 @@ +.page-title-holder.d-flex.align-items-center + %h1.page-title= _('Projects') + +.top-area.scrolling-tabs-container.inner-page-scroll-tabs + .fade-left= sprite_icon('chevron-lg-left', size: 12) + .fade-right= sprite_icon('chevron-lg-right', size: 12) + = render 'dashboard/projects_nav' + .nav-controls + = render 'shared/topics/search_form' diff --git a/app/views/groups/_invite_members_side_nav_link.html.haml b/app/views/groups/_invite_members_side_nav_link.html.haml new file mode 100644 index 00000000000..bccfa9897da --- /dev/null +++ b/app/views/groups/_invite_members_side_nav_link.html.haml @@ -0,0 +1,8 @@ +.js-invite-members-trigger{ data: { trigger_source: 'group-side-nav', + classes: 'gl-text-decoration-none! gl-shadow-none! gl-text-body!', + icon: 'users', + display_text: title, + trigger_element: 'side-nav'} } + += render partial: 'shared/nav/sidebar_submenu', locals: { sidebar_menu: sidebar_menu } += render 'groups/invite_members_modal', group: group diff --git a/app/views/groups/boards/show.html.haml b/app/views/groups/boards/show.html.haml index dbbf78eed00..92838fa4b11 100644 --- a/app/views/groups/boards/show.html.haml +++ b/app/views/groups/boards/show.html.haml @@ -1,3 +1 @@ -= render 'shared/alerts/positioning_disabled' - = render "shared/boards/show", board: @board, group: true diff --git a/app/views/groups/crm/contacts.html.haml b/app/views/groups/crm/contacts.html.haml new file mode 100644 index 00000000000..c452a969d17 --- /dev/null +++ b/app/views/groups/crm/contacts.html.haml @@ -0,0 +1,4 @@ +- breadcrumb_title _('Customer Relations Contacts') +- page_title _('Customer Relations Contacts') + +#js-crm-contacts-app{ data: { group_full_path: @group.full_path } } diff --git a/app/views/groups/crm/organizations.html.haml b/app/views/groups/crm/organizations.html.haml new file mode 100644 index 00000000000..e83dab9fda6 --- /dev/null +++ b/app/views/groups/crm/organizations.html.haml @@ -0,0 +1,4 @@ +- breadcrumb_title _('Customer Relations Organizations') +- page_title _('Customer Relations Organizations') + +#js-crm-organizations-app{ data: { group_full_path: @group.full_path } } diff --git a/app/views/groups/dependency_proxies/show.html.haml b/app/views/groups/dependency_proxies/show.html.haml index 8936c4dcbb4..47caec717af 100644 --- a/app/views/groups/dependency_proxies/show.html.haml +++ b/app/views/groups/dependency_proxies/show.html.haml @@ -1,5 +1,7 @@ - page_title _("Dependency Proxy") +- @content_class = "limit-container-width" unless fluid_layout - dependency_proxy_available = Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: true) || @group.public? #js-dependency-proxy{ data: { group_path: @group.full_path, - dependency_proxy_available: dependency_proxy_available.to_s } } + dependency_proxy_available: dependency_proxy_available.to_s, + no_manifests_illustration: image_path('illustrations/docker-empty-state.svg') } } diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml index 0f11ca5fb8f..e55af71022e 100644 --- a/app/views/groups/new.html.haml +++ b/app/views/groups/new.html.haml @@ -10,7 +10,7 @@ .row{ 'v-cloak': true } #create-group-pane.tab-pane - = form_for @group, html: { class: 'group-form gl-show-field-errors' } do |f| + = form_for @group, html: { class: 'group-form gl-show-field-errors gl-mt-3' } do |f| = render 'new_group_fields', f: f, group_name_id: 'create-group-name' #import-group-pane.tab-pane diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml index cdff533e3c7..a82f7803b44 100644 --- a/app/views/groups/settings/_advanced.html.haml +++ b/app/views/groups/settings/_advanced.html.haml @@ -1,3 +1,4 @@ +- remove_form_id = 'js-remove-group-form' = render 'groups/settings/export', group: @group .sub-section @@ -26,6 +27,6 @@ = f.submit s_('GroupSettings|Change group URL'), class: 'btn gl-button btn-warning' = render 'groups/settings/transfer', group: @group -= render 'groups/settings/remove', group: @group += render 'groups/settings/remove', group: @group, remove_form_id: remove_form_id = render_if_exists 'groups/settings/restore', group: @group -= render_if_exists 'groups/settings/immediately_remove', group: @group += render_if_exists 'groups/settings/immediately_remove', group: @group, remove_form_id: remove_form_id diff --git a/app/views/groups/settings/_membership.html.haml b/app/views/groups/settings/_membership.html.haml index b05a294e864..c1566dddc00 100644 --- a/app/views/groups/settings/_membership.html.haml +++ b/app/views/groups/settings/_membership.html.haml @@ -4,3 +4,4 @@ = render 'shared/allow_request_access', form: f = render_if_exists 'groups/member_lock_setting', f: f, group: @group += render_if_exists 'groups/user_caps_setting', f: f, group: @group diff --git a/app/views/groups/settings/_permanent_deletion.html.haml b/app/views/groups/settings/_permanent_deletion.html.haml index 125a20060ed..152cdfc1411 100644 --- a/app/views/groups/settings/_permanent_deletion.html.haml +++ b/app/views/groups/settings/_permanent_deletion.html.haml @@ -1,9 +1,11 @@ +- remove_form_id = local_assigns.fetch(:remove_form_id, nil) + .sub-section %h4.danger-title= _('Remove group') - = form_tag(group, method: :delete) do + = form_tag(group, method: :delete, id: remove_form_id) do %p = _('Removing this group also removes all child projects, including archived projects, and their resources.') %br %strong= _('Removed group can not be restored!') - = render 'groups/settings/remove_button', group: group + = render 'groups/settings/remove_button', group: group, remove_form_id: remove_form_id diff --git a/app/views/groups/settings/_remove.html.haml b/app/views/groups/settings/_remove.html.haml index a617467019a..8571b93364b 100644 --- a/app/views/groups/settings/_remove.html.haml +++ b/app/views/groups/settings/_remove.html.haml @@ -1,5 +1,6 @@ +- remove_form_id = local_assigns.fetch(:remove_form_id, nil) + - if group.adjourned_deletion? - = render_if_exists 'groups/settings/adjourned_deletion', group: group + = render_if_exists 'groups/settings/adjourned_deletion', group: group, remove_form_id: remove_form_id - else - = render 'groups/settings/permanent_deletion', group: group - + = render 'groups/settings/permanent_deletion', group: group, remove_form_id: remove_form_id diff --git a/app/views/groups/settings/_remove_button.html.haml b/app/views/groups/settings/_remove_button.html.haml index a04dba68b92..1d5b7160049 100644 --- a/app/views/groups/settings/_remove_button.html.haml +++ b/app/views/groups/settings/_remove_button.html.haml @@ -1,7 +1,9 @@ +- remove_form_id = local_assigns.fetch(:remove_form_id, nil) + - if group.paid? .gl-alert.gl-alert-info.gl-mb-5{ data: { testid: 'group-has-linked-subscription-alert' } } = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') .gl-alert-body = html_escape(_("This group can't be removed because it is linked to a subscription. To remove this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe } -= button_to _('Remove group'), '#', class: ['btn gl-button btn-danger js-confirm-danger', ('disabled' if group.paid?)], data: { 'confirm-danger-message' => remove_group_message(group), 'testid' => 'remove-group-button' } +.js-confirm-danger{ data: group_settings_confirm_modal_data(group, remove_form_id) } diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml index 1472ae42152..b2379d77314 100644 --- a/app/views/groups/settings/_transfer.html.haml +++ b/app/views/groups/settings/_transfer.html.haml @@ -14,7 +14,7 @@ %li= s_("GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.") - if group.paid? - .gl-alert.gl-alert-info.gl-mb-5{ data: { testid: 'group-to-transfer-has-linked-subscription-alert' } } + .gl-alert.gl-alert-info.gl-mb-5 = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') .gl-alert-body = html_escape(_("This group can't be transfered because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe } diff --git a/app/views/groups/settings/applications/show.html.haml b/app/views/groups/settings/applications/show.html.haml index fce3602349b..6e7f6ce4df0 100644 --- a/app/views/groups/settings/applications/show.html.haml +++ b/app/views/groups/settings/applications/show.html.haml @@ -6,4 +6,7 @@ %h3.page-title = _("Group application: %{name}") % { name: @application.name } -= render 'shared/doorkeeper/applications/show', edit_path: edit_group_settings_application_path(@group, @application), delete_path: group_settings_application_path(@group, @application) += render 'shared/doorkeeper/applications/show', + edit_path: edit_group_settings_application_path(@group, @application), + delete_path: group_settings_application_path(@group, @application), + index_path: group_settings_applications_path diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 2e74d983397..ed3f2b0c6db 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -32,8 +32,6 @@ = render_if_exists 'groups/group_activity_analytics', group: @group -= render 'groups/invite_members_modal', group: @group - .groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } .top-area.group-nav-container.justify-content-between .scrolling-tabs-container.inner-page-scroll-tabs diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml index 95888963947..f81afd0a82e 100644 --- a/app/views/help/index.html.haml +++ b/app/views/help/index.html.haml @@ -46,5 +46,5 @@ %button.btn-blank.btn-link.js-trigger-shortcut{ type: 'button' } = _('Use shortcuts') - unless Gitlab::CurrentSettings.help_page_hide_commercial_content? - %li= link_to _('Get a support subscription'), 'https://about.gitlab.com/pricing/' - %li= link_to _('Compare GitLab editions'), 'https://about.gitlab.com/features/#compare' + %li= link_to _('Get a support subscription'), "https://#{ApplicationHelper.promo_host}/pricing/" + %li= link_to _('Compare GitLab editions'), "https://#{ApplicationHelper.promo_host}/features/#compare" diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index 3f7f929f766..ef6479f8be2 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -23,7 +23,7 @@ = form_tag personal_access_token_import_github_path, method: :post do .form-group %label.label-bold= _('Personal Access Token') - = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: _('e.g. %{token}') % { token: '8d3f016698e...' }, data: { qa_selector: 'personal_access_token_field' } + = text_field_tag :personal_access_token, '', class: 'form-control gl-form-input', placeholder: _('e.g. %{token}') % { token: '8d3f016698e...' }, data: { qa_selector: 'personal_access_token_field' } %span.form-text.text-muted = import_github_personal_access_token_message diff --git a/app/views/issues/_issues_calendar.ics.ruby b/app/views/issues/_issues_calendar.ics.ruby index c21c4dac9f0..4b0be661aa5 100644 --- a/app/views/issues/_issues_calendar.ics.ruby +++ b/app/views/issues/_issues_calendar.ics.ruby @@ -1,3 +1,4 @@ +# rubocop:disable Naming/FileName # frozen_string_literal: true cal = Icalendar::Calendar.new @@ -17,3 +18,5 @@ end # rubocop: enable CodeReuse/ActiveRecord cal.to_ical + +# rubocop:enable Naming/FileName diff --git a/app/views/jira_connect/branches/new.html.haml b/app/views/jira_connect/branches/new.html.haml index f0e34c30018..74d547e6bb8 100644 --- a/app/views/jira_connect/branches/new.html.haml +++ b/app/views/jira_connect/branches/new.html.haml @@ -1,5 +1,6 @@ - @hide_breadcrumbs = true - @hide_top_links = true +- @content_class = 'limit-container-width' - page_title _('New branch') .js-jira-connect-create-branch{ data: @new_branch_data } diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml index cbe9a860210..be2be7288f8 100644 --- a/app/views/jira_connect/subscriptions/index.html.haml +++ b/app/views/jira_connect/subscriptions/index.html.haml @@ -9,20 +9,9 @@ = link_to _('Sign in to GitLab'), jira_connect_users_path, target: '_blank', rel: 'noopener noreferrer', class: 'js-jira-connect-sign-in' %main.jira-connect-app.gl-px-5.gl-pt-7.gl-mx-auto - - if current_user.blank? && @subscriptions.empty? - .jira-connect-app-body.gl-px-5.gl-text-center - %h2= s_('JiraService|GitLab for Jira Configuration') - %p= s_('JiraService|Sign in to GitLab.com to get started.') + .js-jira-connect-app{ data: jira_connect_app_data(@subscriptions) } - .gl-mt-7 - = external_link _('Sign in to GitLab'), jira_connect_users_path, class: "btn gl-button btn-confirm js-jira-connect-sign-in" - - .gl-mt-7 - %p= s_('Integrations|Note: this integration only works with accounts on GitLab.com (SaaS).') - - else - .js-jira-connect-app{ data: jira_connect_app_data(@subscriptions) } - - %p.jira-connect-app-body.gl-px-5.gl-mt-7.gl-font-base.gl-text-center + %p.jira-connect-app-body.gl-px-5.gl-font-base.gl-text-center.gl-mx-auto %strong= s_('Integrations|Browser limitations') - browser_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">' - firefox_link_start = browser_link_start.html_safe % { url: 'https://www.mozilla.org/en-US/firefox/' } diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index a89c621a55c..5ca4a2f9888 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -92,3 +92,5 @@ = render 'layouts/google_analytics' if extra_config.has_key?('google_analytics_id') = render 'layouts/matomo' if extra_config.has_key?('matomo_url') && extra_config.has_key?('matomo_site_id') = render 'layouts/snowplow' + -# This is needed by [GitLab JH](https://gitlab.com/gitlab-jh/gitlab/-/issues/184) + = render_if_exists "layouts/frontend_monitor" diff --git a/app/views/layouts/_loading_hints.html.haml b/app/views/layouts/_loading_hints.html.haml index e2189009045..9b2815ea9bc 100644 --- a/app/views/layouts/_loading_hints.html.haml +++ b/app/views/layouts/_loading_hints.html.haml @@ -1,4 +1,4 @@ -= cache_if(Feature.enabled?(:cached_loading_hints, current_user), [ActionController::Base.asset_host, user_application_theme, user_color_scheme], expires_in: 1.minute) do += cache([ActionController::Base.asset_host, user_application_theme, user_color_scheme], expires_in: 1.minute) do - css_crossorigin = ActionController::Base.asset_host ? 'anonymous' : nil - if ActionController::Base.asset_host @@ -11,5 +11,5 @@ = preload_link_tag(path_to_stylesheet('application_utilities'), crossorigin: css_crossorigin) = preload_link_tag(path_to_stylesheet('application'), crossorigin: css_crossorigin) = preload_link_tag(path_to_stylesheet("highlight/themes/#{user_color_scheme}"), crossorigin: css_crossorigin) - - if Gitlab::Tracking.enabled? && Gitlab::CurrentSettings.snowplow_collector_hostname - %link{ rel: 'preconnect', href: Gitlab::CurrentSettings.snowplow_collector_hostname, crossorigin: '' } + - if Gitlab::Tracking.enabled? && Gitlab::Tracking.collector_hostname + %link{ rel: 'preconnect', href: Gitlab::Tracking.collector_hostname, crossorigin: '' } diff --git a/app/views/layouts/_published_experiments.html.haml b/app/views/layouts/_published_experiments.html.haml new file mode 100644 index 00000000000..717e1e633d2 --- /dev/null +++ b/app/views/layouts/_published_experiments.html.haml @@ -0,0 +1,4 @@ += javascript_tag(nonce: content_security_policy_nonce) do + :plain + gl = window.gl || {}; + gl.experiments = #{raw ApplicationExperiment.published_experiments.reject { |name, data| data[:excluded] }.to_json}; diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml index fc3b12acc46..7e242fb4a8e 100644 --- a/app/views/layouts/_snowplow.html.haml +++ b/app/views/layouts/_snowplow.html.haml @@ -10,5 +10,6 @@ window.snowplowOptions = #{Gitlab::Tracking.options(@group).to_json} gl = window.gl || {}; - gl.snowplowStandardContext = #{Gitlab::Tracking::StandardContext.new.to_context.to_json.to_json} + gl.snowplowStandardContext = #{Gitlab::Tracking::StandardContext.new(namespace: @group || @project&.namespace, + project: @project, user: current_user).to_context.to_json.to_json} gl.snowplowPseudonymizedPageUrl = #{masked_page_url.to_json}; diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 899bf65de48..26e3d9b3b92 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -16,4 +16,5 @@ = render 'layouts/img_loader' + = render 'layouts/published_experiments' = yield :scripts_body diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 8d28823bfa4..83e8ff79aec 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -120,7 +120,7 @@ - sign_in_text = allow_signup? ? _('Sign in / Register') : _('Sign in') = link_to sign_in_text, new_session_path(:user, redirect_to_referer: 'yes'), class: 'gl-button btn btn-default btn-sign-in' - %button.navbar-toggler.d-block.d-sm-none{ type: 'button', class: 'gl-border-none!', data: { testid: 'top-nav-responsive-toggle' } } + %button.navbar-toggler.d-block.d-sm-none{ type: 'button', class: 'gl-border-none!', data: { testid: 'top-nav-responsive-toggle', qa_selector: 'mobile_navbar_button' } } %span.sr-only= _('Toggle navigation') %span.more-icon.gl-px-3.gl-font-sm.gl-font-weight-bold %span.gl-pr-2= _('Menu') diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index 01e59b8e2ef..a03cd4cd68f 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -10,7 +10,7 @@ %li %button.js-shortcuts-modal-trigger{ type: "button" } = _("Keyboard shortcuts") - %span.text-secondary.float-right{ "aria-hidden": "true" }= '?'.html_safe + %kbd.flat.float-right{ "aria-hidden": "true" }= '?'.html_safe %li.divider %li = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback" diff --git a/app/views/layouts/header/_logo_with_title.html.haml b/app/views/layouts/header/_logo_with_title.html.haml index 9b5a47306d2..0b9d4e2eea4 100644 --- a/app/views/layouts/header/_logo_with_title.html.haml +++ b/app/views/layouts/header/_logo_with_title.html.haml @@ -1,4 +1,4 @@ %header.navbar.fixed-top.navbar-gitlab.justify-content-center - = render 'shared/logo.svg' + = render partial: 'shared/logo', formats: :svg %span.logo-text.d-none.d-lg-block.gl-ml-3.pt-1 - = render 'shared/logo_type.svg' + = render partial: 'shared/logo_type', formats: :svg diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml index 02a37dac158..3c52c430868 100644 --- a/app/views/layouts/nav/_breadcrumbs.html.haml +++ b/app/views/layouts/nav/_breadcrumbs.html.haml @@ -6,7 +6,7 @@ %nav.breadcrumbs{ class: [container, @content_class], 'aria-label': _('Breadcrumbs') } .breadcrumbs-container{ class: ("border-bottom-0" if @no_breadcrumb_border) } - if defined?(@left_sidebar) - = button_tag class: 'toggle-mobile-nav', type: 'button' do + = button_tag class: 'toggle-mobile-nav', data: { qa_selector: 'toggle_mobile_nav_button' }, type: 'button' do %span.sr-only= _("Open sidebar") = sprite_icon('hamburger', size: 18) .breadcrumbs-links{ data: { testid: 'breadcrumb-links', qa_selector: 'breadcrumb_links_content' } } @@ -16,7 +16,7 @@ - if @breadcrumbs_extra_links - @breadcrumbs_extra_links.each do |extra| = breadcrumb_list_item link_to(extra[:text], extra[:link]) - = render "layouts/nav/breadcrumbs/collapsed_dropdown", location: :after + = render "layouts/nav/breadcrumbs/collapsed_inline_list", location: :after - unless @skip_current_level_breadcrumb %li %h2.breadcrumbs-sub-title{ data: { qa_selector: 'breadcrumb_sub_title_content' } } diff --git a/app/views/layouts/nav/_top_nav_responsive.html.haml b/app/views/layouts/nav/_top_nav_responsive.html.haml index 86fd8b6d80c..22a260b5c0c 100644 --- a/app/views/layouts/nav/_top_nav_responsive.html.haml +++ b/app/views/layouts/nav/_top_nav_responsive.html.haml @@ -2,4 +2,5 @@ - view_model = top_nav_responsive_view_model(project: @project, group: @group) .top-nav-responsive{ class: top_class } - #js-top-nav-responsive{ data: { view_model: view_model.to_json } } + .cloak-startup + #js-top-nav-responsive{ data: { view_model: view_model.to_json } } diff --git a/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml b/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml deleted file mode 100644 index 8ea75087fed..00000000000 --- a/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -- dropdown_location = local_assigns.fetch(:location, nil) -- button_tooltip = local_assigns.fetch(:title, _("Show parent pages")) -- if defined?(@breadcrumb_dropdown_links) && @breadcrumb_dropdown_links.key?(dropdown_location) - %li.dropdown - %button.text-expander.has-tooltip.js-breadcrumbs-collapsed-expander{ type: "button", data: { toggle: "dropdown", container: "body" }, "aria-label": button_tooltip, title: button_tooltip } - = sprite_icon("ellipsis_h", size: 12) - = sprite_icon("angle-right", size: 8, css_class: "breadcrumbs-list-angle") - .dropdown-menu - %ul - - @breadcrumb_dropdown_links[dropdown_location].each_with_index do |link, index| - %li{ style: "text-indent: #{[index * 16, 60].min}px;" }= link diff --git a/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml b/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml new file mode 100644 index 00000000000..ccb660c050e --- /dev/null +++ b/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml @@ -0,0 +1,11 @@ +- dropdown_location = local_assigns.fetch(:location, nil) +- button_tooltip = local_assigns.fetch(:title, _("Show all breadcrumbs")) +- if defined?(@breadcrumb_collapsed_links) && @breadcrumb_collapsed_links.key?(dropdown_location) + %li.expander + %button.text-expander.has-tooltip.js-breadcrumbs-collapsed-expander{ type: "button", data: { container: "body" }, "aria-label": button_tooltip, title: button_tooltip } + = sprite_icon("ellipsis_h", size: 12) + = sprite_icon("angle-right", size: 8, css_class: "breadcrumbs-list-angle") + - @breadcrumb_collapsed_links[dropdown_location].each_with_index do |link, index| + %li{ :class => "gl-display-none! breadcrumbs-detail-item" } + = link + = sprite_icon("angle-right", size: 8, css_class: "breadcrumbs-list-angle") diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 842fb23d24a..f820f911d61 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -66,9 +66,9 @@ = _('Analytics') %li.divider.fly-out-top-item = nav_link(controller: :dev_ops_report) do - = link_to admin_dev_ops_report_path, title: _('DevOps Report') do + = link_to admin_dev_ops_report_path, title: _('DevOps Reports') do %span - = _('DevOps Report') + = _('DevOps Reports') = nav_link(controller: :usage_trends) do = link_to admin_usage_trends_path, title: _('Usage Trends') do %span diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml index 4d5c354388f..caa46b7bc56 100644 --- a/app/views/layouts/terms.html.haml +++ b/app/views/layouts/terms.html.haml @@ -1,21 +1,22 @@ !!! 5 +- add_page_specific_style 'page_bundles/terms' - @hide_breadcrumbs = true %html{ lang: I18n.locale, class: page_class } = render "layouts/head" %body{ data: { page: body_data_page } } .layout-page.terms{ class: page_class } - .content-wrapper + .content-wrapper.gl-pb-5 .mobile-overlay .alert-wrapper = render "layouts/broadcast" = render 'layouts/header/read_only_banner' - = render "layouts/flash", extra_flash_class: 'limit-container-width' + = render "layouts/flash" %div{ class: "#{container_class} limit-container-width" } .content{ id: "content-body" } - .card - .card-header + .gl-card + .gl-card-header = brand_header_logo - logo_text = brand_header_logo_type - if logo_text.present? diff --git a/app/views/notify/in_product_marketing_email.html.haml b/app/views/notify/in_product_marketing_email.html.haml index 6382718480f..a85fa7c519f 100644 --- a/app/views/notify/in_product_marketing_email.html.haml +++ b/app/views/notify/in_product_marketing_email.html.haml @@ -166,10 +166,11 @@ = about_link('mailers/in_product_marketing/gitlab-logo-gray-rgb.png', 200) %tr %td{ "aria-hidden" => "true", height: "30", style: "font-size: 0; line-height: 0;" } - %tr{ style: "background-color: #ffffff;" } - %td{ style: "color: #424242; padding: 10px 30px; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;font-size: 16px; line-height: 22px; border: 1px solid #dddddd" } - %p - = @message.progress.html_safe + - if @message.series? + %tr{ style: "background-color: #ffffff;" } + %td{ style: "color: #424242; padding: 10px 30px; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;font-size: 16px; line-height: 22px; border: 1px solid #dddddd" } + %p + = @message.progress.html_safe %tr %td{ bgcolor: "#ffffff", height: "auto", style: "max-width: 600px; width: 100%; text-align: center; height: 200px; padding: 25px 15px; mso-line-height-rule: exactly; min-height: 40px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;", valign: "middle", width: "100%" } = inline_image_link(@message.logo_path, { width: '150', style: 'width: 150px;' }) @@ -210,6 +211,12 @@ %td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" } %p{ style: "margin: 0 0 50px 0;" } = @message.feedback_thanks + - if @message.invite_members? + %tr + %td{ align: "center", style: "padding: 0 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } + = @message.invite_text + %br + = @message.invite_link %tr{ style: "background-color: #ffffff;" } %td{ align: "center", style: "padding:75px 20px 25px;" } = about_link('gitlab_logo.png', 80) diff --git a/app/views/notify/in_product_marketing_email.text.erb b/app/views/notify/in_product_marketing_email.text.erb index 6f0a2efa410..79a366eb1cc 100644 --- a/app/views/notify/in_product_marketing_email.text.erb +++ b/app/views/notify/in_product_marketing_email.text.erb @@ -21,6 +21,10 @@ <%= @message.feedback_thanks %> <% end %> +<% if @message.invite_members? %> +<%= @message.invite_text %> +<%= @message.invite_link %> +<% end %> diff --git a/app/views/notify/member_access_denied_email.html.haml b/app/views/notify/member_access_denied_email.html.haml index 11661a423dd..eeef66d353d 100644 --- a/app/views/notify/member_access_denied_email.html.haml +++ b/app/views/notify/member_access_denied_email.html.haml @@ -2,6 +2,11 @@ %td.text-content %p Your request to join the - #{link_to member_source.human_name, member_source.web_url, class: :highlight} #{member_source.model_name.singular} - has been #{content_tag :span, 'denied', class: :highlight}. + + - if @source_hidden + #{content_tag :span, 'Hidden', class: :highlight} + - else + #{link_to member_source.human_name, member_source.web_url, class: :highlight} + + #{member_source.model_name.singular} has been #{content_tag :span, 'denied', class: :highlight}. diff --git a/app/views/notify/member_access_granted_email.html.haml b/app/views/notify/member_access_granted_email.html.haml index e28a10a243f..cce36f7b8a6 100644 --- a/app/views/notify/member_access_granted_email.html.haml +++ b/app/views/notify/member_access_granted_email.html.haml @@ -8,7 +8,11 @@ %td.text-content %p = _('You have been granted %{access_level} access to the %{source_link} %{source_type}.').html_safe % { access_level: access_level, source_link: source_link, source_type: source_type } + - if member.tasks_to_be_done.present? + = s_("InviteEmail|You were assigned the following tasks:") + %ul.list-style-position-inside + - member.tasks_to_be_done.each do |task| + %li= localized_tasks_to_be_done_choices[task] %p - leave_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: leave_link } = _('If this was a mistake you can %{leave_link_start}leave the %{source_type}%{link_end}.').html_safe % { source_type: source_type, leave_link_start: leave_link_start, link_end: link_end } - diff --git a/app/views/notify/member_invited_email.html.haml b/app/views/notify/member_invited_email.html.haml index 5489a2ac6a1..1d1f696e1b2 100644 --- a/app/views/notify/member_invited_email.html.haml +++ b/app/views/notify/member_invited_email.html.haml @@ -24,6 +24,11 @@ %p - if member.created_by = html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders.merge({ inviter: (link_to inviter_name, user_url(member.created_by)).html_safe }) + - if member.tasks_to_be_done.present? + = s_("InviteEmail|and has assigned you the following tasks:") + %ul.list-style-position-inside + - member.tasks_to_be_done.each do |task| + %li= localized_tasks_to_be_done_choices[task] - else = html_escape(s_("InviteEmail|You are invited to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders %p.invite-actions diff --git a/app/views/profiles/_event_table.html.haml b/app/views/profiles/_event_table.html.haml index f74902a3c3b..67c649a9fce 100644 --- a/app/views/profiles/_event_table.html.haml +++ b/app/views/profiles/_event_table.html.haml @@ -3,10 +3,11 @@ %ul.content-list - events.each do |event| - %li - %span.description - = audit_icon(event.details[:with], css_class: 'gl-mr-2') - = _('Signed in with %{authentication} authentication') % { authentication: event.details[:with]} - %span.float-right= time_ago_with_tooltip(event.created_at) + - if event.success? + %li + %span.description + = audit_icon('key', css_class: 'gl-mr-2') + = _('Signed in with %{authentication} authentication') % { authentication: event.provider } + %span.float-right= time_ago_with_tooltip(event.created_at) = paginate events, theme: "gitlab" diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml index aec855c790e..4bbb4a21b39 100644 --- a/app/views/profiles/audit_log.html.haml +++ b/app/views/profiles/audit_log.html.haml @@ -6,6 +6,6 @@ %h4.gl-mt-0 = page_title %p - = _('This is a security log of important events involving your account.') + = _('This is a security log of authentication events involving your account.') .col-lg-8 = render 'event_table', events: @events diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index 35bdfbb1c29..0cfe65994da 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -44,7 +44,7 @@ %span.badge.badge-muted.badge-pill.gl-badge.badge-info= s_('Profiles|Public email') - if @primary_email === current_user.notification_email_or_default %span.badge.badge-muted.badge-pill.gl-badge.badge-info= s_('Profiles|Default notification email') - - @emails.each do |email| + - @emails.reject(&:user_primary_email?).each do |email| %li{ data: { qa_selector: 'email_row_content' } } = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? } %span.float-right diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 3e41f107e04..b1470520eea 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -71,7 +71,7 @@ placeholder: s_("Profiles|What's your status?") .checkbox-icon-inline-wrapper = status_form.check_box :availability, { data: { testid: "user-availability-checkbox" }, label: s_("Profiles|Busy"), wrapper_class: 'gl-mr-0 gl-font-weight-bold' }, availability["busy"], availability["not_set"] - .gl-text-gray-600.gl-ml-5= s_('Profiles|"Busy" will be shown next to your name') + .gl-text-gray-600.gl-ml-5= s_('Profiles|An indicator appears next to your name and avatar') .col-lg-12 %hr .row.user-time-preferences.js-search-settings-section diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 00df8608957..0eae3c95bf6 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -43,7 +43,9 @@ .gl-alert.gl-alert-danger.gl-mb-5 .gl-alert-container .gl-alert-content - = @error + %p.gl-alert-body.gl-md-0 + = @error[:message] + = link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer' .form-group = label_tag :pin_code, _('Pin code'), class: "label-bold" = text_field_tag :pin_code, nil, class: "form-control gl-form-input", required: true, data: { qa_selector: 'pin_code_field' } diff --git a/app/views/projects/_invite_members_side_nav_link.html.haml b/app/views/projects/_invite_members_side_nav_link.html.haml new file mode 100644 index 00000000000..ea6174d19f0 --- /dev/null +++ b/app/views/projects/_invite_members_side_nav_link.html.haml @@ -0,0 +1,8 @@ +.js-invite-members-trigger{ data: { trigger_source: 'project-side-nav', + classes: 'gl-text-decoration-none! gl-shadow-none! gl-text-body!', + icon: 'users', + display_text: title, + trigger_element: 'side-nav'} } + += render partial: 'shared/nav/sidebar_submenu', locals: { sidebar_menu: sidebar_menu } += render 'projects/invite_members_modal', project: project diff --git a/app/views/projects/_merge_request_merge_commit_template.html.haml b/app/views/projects/_merge_request_merge_commit_template.html.haml new file mode 100644 index 00000000000..185b730e0bb --- /dev/null +++ b/app/views/projects/_merge_request_merge_commit_template.html.haml @@ -0,0 +1,17 @@ +- form = local_assigns.fetch(:form) + +.form-group + %b= s_('ProjectSettings|Merge commit message template') + %p.text-secondary + - configure_the_merge_commit_message_help_link_url = help_page_path('user/project/merge_requests/commit_templates.md', anchor: 'merge-commit-message-template') + - configure_the_merge_commit_message_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: configure_the_merge_commit_message_help_link_url } + = s_('ProjectSettings|The commit message used when merging, if the merge method creates a merge commit. %{link_start}Learn more about syntax and variables.%{link_end}').html_safe % { link_start: configure_the_merge_commit_message_help_link_start, link_end: '</a>'.html_safe } + .mb-2 + - default_merge_commit_template = "Merge branch '%{source_branch}' into '%{target_branch}'\n\n%{title}\n\n%{issues}\n\nSee merge request %{reference}" + = form.text_area :merge_commit_template, class: 'form-control gl-form-input', rows: 8, maxlength: 500, placeholder: default_merge_commit_template + %p.form-text.text-muted + = s_('ProjectSettings|Maximum 500 characters.') + = s_('ProjectSettings|Supported variables:') + - Gitlab::MergeRequests::MergeCommitMessage::PLACEHOLDERS.keys.each do |placeholder| + %code + = "%{#{placeholder}}".html_safe diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml index f595b4f709b..c5a25bec6eb 100644 --- a/app/views/projects/_merge_request_settings.html.haml +++ b/app/views/projects/_merge_request_settings.html.haml @@ -10,5 +10,7 @@ = render 'projects/merge_request_merge_suggestions_settings', project: @project, form: form += render 'projects/merge_request_merge_commit_template', project: @project, form: form + - if @project.forked? = render 'projects/merge_request_target_project_settings', project: @project, form: form diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index 256c3ebad0a..c21240b340c 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -40,12 +40,17 @@ .form-group.project-path.col-sm-6 = f.label :path, class: 'label-bold' do %span= _("Project slug") - = f.text_field :path, placeholder: "my-awesome-project", class: "form-control gl-form-input", required: true, aria: { required: true } + = f.text_field :path, placeholder: "my-awesome-project", class: "form-control gl-form-input", required: true, aria: { required: true }, data: { username: current_user.username } - if current_user.can_create_group? .form-text.text-muted - link_start_group_path = '<a href="%{path}">' % { path: new_group_path } - project_tip = s_('ProjectsNew|Want to house several dependent projects under the same namespace? %{link_start}Create a group.%{link_end}') % { link_start: link_start_group_path, link_end: '</a>' } = project_tip.html_safe +.gl-alert.gl-alert-success.gl-mb-4.gl-display-none.js-user-readme-repo + = sprite_icon('check-circle', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-body + - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/profile/index', anchor: 'user-profile-readme') } + = html_escape(_('%{project_path} is a project that you can use to add a README to your GitLab profile. Create a public project and initialize the repository with a README to get started. %{help_link_start}Learn more.%{help_link_end}')) % { project_path: "<strong>#{current_user.username} / #{current_user.username}</strong>".html_safe, help_link_start: help_link_start, help_link_end: '</a>'.html_safe } .form-group = f.label :description, class: 'label-bold' do @@ -72,7 +77,7 @@ - e.try do .form-group .form-check.gl-mb-3 - = check_box_tag 'project[initialize_with_sast]', '1', true, class: 'form-check-input', data: { track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' } + = check_box_tag 'project[initialize_with_sast]', '1', true, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' } = label_tag 'project[initialize_with_sast]', class: 'form-check-label' do = s_('ProjectsNew|Enable Static Application Security Testing (SAST)') .form-text.text-muted @@ -81,7 +86,7 @@ - e.try(:free_indicator) do .form-group .form-check.gl-mb-3 - = check_box_tag 'project[initialize_with_sast]', '1', true, class: 'form-check-input', data: { track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' } + = check_box_tag 'project[initialize_with_sast]', '1', true, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' } = label_tag 'project[initialize_with_sast]', class: 'form-check-label' do = s_('ProjectsNew|Enable Static Application Security Testing (SAST)') %span.badge.badge-info.badge-pill.gl-badge.sm= _('Free') diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml index cb0ec9f19c8..815e76ebcb9 100644 --- a/app/views/projects/_remove.html.haml +++ b/app/views/projects/_remove.html.haml @@ -1,4 +1,6 @@ - return unless can?(current_user, :remove_project, project) +- merge_requests_count = Projects::AllMergeRequestsCountService.new(project).count +- issues_count = Projects::AllIssuesCountService.new(project).count .sub-section %h4.danger-title= _('Delete project') @@ -7,4 +9,4 @@ = link_to _('Learn more.'), help_page_path('user/project/settings/index', anchor: 'removing-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer' %p %strong= _('Deleted projects cannot be restored!') - #js-project-delete-button{ data: { form_path: project_path(project), confirm_phrase: project.path } } + #js-project-delete-button{ data: { form_path: project_path(project), confirm_phrase: delete_confirm_phrase(project), is_fork: project.forked?.to_s, issues_count: number_with_delimiter(issues_count), merge_requests_count: number_with_delimiter(merge_requests_count), forks_count: number_with_delimiter(project.forks_count), stars_count: number_with_delimiter(project.star_count) } } diff --git a/app/views/projects/_remove_fork.html.haml b/app/views/projects/_remove_fork.html.haml index 8fa21966683..92eb29dc407 100644 --- a/app/views/projects/_remove_fork.html.haml +++ b/app/views/projects/_remove_fork.html.haml @@ -8,4 +8,4 @@ %p %strong= _('Once removed, the fork relationship cannot be restored. This project will no longer be able to receive or send merge requests to the source project or other forks.') = link_to _('Learn more.'), help_page_path('user/project/settings/index', anchor: 'removing-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer' - = button_to _('Remove fork relationship'), '#', class: "gl-button btn btn-danger js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_warning_message(@project) } + = button_to _('Remove fork relationship'), '#', class: "gl-button btn btn-danger js-legacy-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_warning_message(@project) } diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml index 7b345941cf7..63cf4dfe0ab 100644 --- a/app/views/projects/_service_desk_settings.html.haml +++ b/app/views/projects/_service_desk_settings.html.haml @@ -14,8 +14,9 @@ custom_email: (@project.service_desk_custom_address if @project.service_desk_enabled), custom_email_enabled: "#{Gitlab::ServiceDeskEmail.enabled?}", selected_template: "#{@project.service_desk_setting&.issue_template_key}", + selected_file_template_project_id: "#{@project.service_desk_setting&.file_template_project_id}", outgoing_name: "#{@project.service_desk_setting&.outgoing_name}", project_key: "#{@project.service_desk_setting&.project_key}", - templates: issuable_templates_names(Issue.new) } } + templates: available_service_desk_templates_for(@project) } } - elsif show_callout?('promote_service_desk_dismissed') = render 'shared/promotions/promote_servicedesk' diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml index ee717c2deca..e48008e1cc6 100644 --- a/app/views/projects/_transfer.html.haml +++ b/app/views/projects/_transfer.html.haml @@ -14,4 +14,4 @@ = label_tag :new_namespace_id, _('Select a new namespace'), class: 'gl-font-weight-bold' .form-group = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2' - = f.submit 'Transfer project', class: "gl-button btn btn-danger js-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) } + = f.submit 'Transfer project', class: "gl-button btn btn-danger js-legacy-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) } diff --git a/app/views/projects/_visibility_modal.html.haml b/app/views/projects/_visibility_modal.html.haml index f75216a71b6..db98b978f04 100644 --- a/app/views/projects/_visibility_modal.html.haml +++ b/app/views/projects/_visibility_modal.html.haml @@ -20,10 +20,10 @@ %li = _("Current forks will keep their visibility level.").html_safe %label{ for: "confirm_path_input" } - = _("To confirm, type %{phrase_code}").html_safe % { phrase_code: '<code class="js-confirm-danger-match">%{phrase_name}</code>'.html_safe % { phrase_name: @project.full_path } } + = _("To confirm, type %{phrase_code}").html_safe % { phrase_code: '<code class="js-legacy-confirm-danger-match">%{phrase_name}</code>'.html_safe % { phrase_name: @project.full_path } } .form-group - = text_field_tag 'confirm_path_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input' + = text_field_tag 'confirm_path_input', '', class: 'form-control js-legacy-confirm-danger-input qa-confirm-input' .form-actions %button.btn.gl-button.btn-default.gl-mr-4{ type: "button", "data-dismiss": "modal" } = _('Cancel') - = submit_tag _('Reduce project visibility'), class: "btn gl-button btn-danger js-confirm-danger-submit qa-confirm-button", disabled: true + = submit_tag _('Reduce project visibility'), class: "btn gl-button btn-danger js-legacy-confirm-danger-submit qa-confirm-button", disabled: true diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 30c052e054f..704576619a7 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -20,7 +20,7 @@ %span.legend-box.legend-box-9 %span.right-label Older - .table-responsive.file-content.blame.code.js-syntax-highlight + .table-responsive.file-content.blame.code{ class: user_color_scheme } %table - current_line = 1 - @blame.groups.each do |blame_group| diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index d7668dd1c91..dad4ea205b4 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -10,7 +10,7 @@ = edit_blob_button(@project, @ref, @path, blob: blob) = ide_edit_button(@project, @ref, @path, blob: blob) - if can_view_pipeline_editor?(@project) && @path == @project.ci_config_path_or_default - = link_to "Pipeline Editor", project_ci_pipeline_editor_path(@project), class: "btn gl-button btn-confirm-secondary gl-ml-3" + = link_to "Pipeline Editor", project_ci_pipeline_editor_path(@project, branch_name: @ref), class: "btn gl-button btn-confirm-secondary gl-ml-3" .btn-group{ role: "group", class: ("gl-ml-3" if current_user) }> = render_if_exists 'projects/blob/header_file_locks_link' - if current_user diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index ecbef9a11a7..8378ce2c7e5 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -16,14 +16,10 @@ %h3.page-title.blob-edit-page-title Edit file .file-editor - %ul.nav-links.no-bottom.js-edit-mode.nav.nav-tabs - %li.active - = link_to '#editor' do - Write + = gl_tabs_nav({ class: 'js-edit-mode nav-links gl-border-0'}) do + = gl_tab_link_to _('Write'), '#editor', { tab_class: 'active' } - %li - = link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id) do - = editing_preview_title(@blob.name) + = gl_tab_link_to editing_preview_title(@blob.name), '#preview', { data: { 'preview-url': project_preview_blob_path(@project, @id) } } = form_tag(project_update_blob_path(@project, @id), method: :put, class: 'js-quick-submit js-requires-input js-edit-blob-form', data: blob_editor_paths(@project)) do = render 'projects/blob/editor', ref: @ref, path: @path, blob_data: @blob.data diff --git a/app/views/projects/blob/viewers/_changelog.html.haml b/app/views/projects/blob/viewers/_changelog.html.haml index cac858c1444..5f3b9285ea9 100644 --- a/app/views/projects/blob/viewers/_changelog.html.haml +++ b/app/views/projects/blob/viewers/_changelog.html.haml @@ -1,3 +1,3 @@ = sprite_icon('history', css_class: 'gl-mr-1 gl-vertical-align-text-bottom') = succeed '.' do - = _("To find the state of this project's repository at the time of any of these versions, check out %{link_start}the tags%{link_end}.").html_safe % { link_start: "<a href='#{project_tags_path(viewer.project)}'>".html_safe, link_end: "</a>".html_safe } + = _("To find the state of this project's repository at the time of any of these versions, check out %{link_start}the tags%{link_end}").html_safe % { link_start: "<a href='#{project_tags_path(viewer.project)}'>".html_safe, link_end: "</a>".html_safe } diff --git a/app/views/projects/blob/viewers/_readme.html.haml b/app/views/projects/blob/viewers/_readme.html.haml index e06ff4edf71..c07428f0337 100644 --- a/app/views/projects/blob/viewers/_readme.html.haml +++ b/app/views/projects/blob/viewers/_readme.html.haml @@ -1,4 +1,4 @@ = sprite_icon('information-o', css_class: 'gl-vertical-align-middle! gl-mr-2') = succeed '.' do - link_to_wiki = link_to(_("the wiki"), wiki_path(viewer.project.wiki)) - = _("To learn more about this project, read %{link_to_wiki}.").html_safe % { link_to_wiki: link_to_wiki } + = _("To learn more about this project, read %{link_to_wiki}").html_safe % { link_to_wiki: link_to_wiki } diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 99a9535b8e8..be6efa310b9 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -23,7 +23,7 @@ - if commit = render 'projects/branches/commit', commit: commit, project: @project - else - = s_('Branches|Cant find HEAD commit for this branch') + = s_('Branches|Can’t find HEAD commit for this branch') - if branch.name != @repository.root_ref .js-branch-divergence-graph diff --git a/app/views/projects/commit/_ci_menu.html.haml b/app/views/projects/commit/_ci_menu.html.haml index 41f5fb3dcbd..60a79456c36 100644 --- a/app/views/projects/commit/_ci_menu.html.haml +++ b/app/views/projects/commit/_ci_menu.html.haml @@ -1,12 +1,10 @@ - any_pipelines = @commit.present(current_user: current_user).any_pipelines? -%ul.nav-links.no-top.no-bottom.commit-ci-menu.nav.nav-tabs - = nav_link(path: 'commit#show') do - = link_to project_commit_path(@project, @commit.id) do - = _('Changes') - %span.badge.badge-pill= @diffs.size += gl_tabs_nav({ class: 'commit-ci-menu gl-display-flex gl-flex-grow-1'}) do + = gl_tab_link_to project_commit_path(@project, @commit.id) do + = _('Changes') + = gl_tab_counter_badge(@diffs.size) - if any_pipelines - = nav_link(path: 'commit#pipelines') do - = link_to pipelines_project_commit_path(@project, @commit.id) do - = _('Pipelines') - %span.badge.badge-pill.js-pipelines-mr-count= @commit.pipelines.size + = gl_tab_link_to pipelines_project_commit_path(@project, @commit.id) do + = _('Pipelines') + = gl_tab_counter_badge(@commit.pipelines.size, { class: 'js-pipelines-mr-count' }) diff --git a/app/views/projects/commit/_multiple_signatures_signature_badge.html.haml b/app/views/projects/commit/_multiple_signatures_signature_badge.html.haml new file mode 100644 index 00000000000..74515438af2 --- /dev/null +++ b/app/views/projects/commit/_multiple_signatures_signature_badge.html.haml @@ -0,0 +1,6 @@ +- title = capture do + = html_escape(_('This commit was signed with %{strong_open}multiple%{strong_close} signatures.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } + +- locals = { signature: signature, title: title, label: _('Unverified'), css_class: 'invalid', icon: 'status_notfound_borderless' } + += render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/confluences/show.html.haml b/app/views/projects/confluences/show.html.haml index cf4e39f9659..413de90b67b 100644 --- a/app/views/projects/confluences/show.html.haml +++ b/app/views/projects/confluences/show.html.haml @@ -6,9 +6,8 @@ = s_('WikiEmpty|Confluence is enabled') %p - wiki_confluence_epic_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/3629' - - wiki_confluence_epic_link_start = format('<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe, url: wiki_confluence_epic_link_url) - = format(s_("WikiEmpty|You've enabled the Confluence Workspace integration. Your wiki will be viewable directly within Confluence. We are hard at work integrating Confluence more seamlessly into GitLab. If you'd like to stay up to date, follow our %{wiki_confluence_epic_link_start}Confluence epic%{wiki_confluence_epic_link_end}.").html_safe, wiki_confluence_epic_link_start: wiki_confluence_epic_link_start, wiki_confluence_epic_link_end: '</a>'.html_safe) + - wiki_confluence_epic_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wiki_confluence_epic_link_url } + = html_escape(s_("WikiEmpty|You've enabled the Confluence Workspace integration. Your wiki will be viewable directly within Confluence. We are hard at work integrating Confluence more seamlessly into GitLab. If you'd like to stay up to date, follow our %{wiki_confluence_epic_link_start}Confluence epic%{wiki_confluence_epic_link_end}.")) % { wiki_confluence_epic_link_start: wiki_confluence_epic_link_start, wiki_confluence_epic_link_end: '</a>'.html_safe } = link_to @project.confluence_integration.confluence_url, target: '_blank', rel: 'noopener noreferrer', class: 'gl-button btn btn-success external-url', title: s_('WikiEmpty|Go to Confluence') do - = sprite_icon('external-link') = s_('WikiEmpty|Go to Confluence') - + = sprite_icon('external-link') diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index f398ac6ede7..ba79f0ee3cb 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -1,4 +1,6 @@ - page_title _("Value Stream Analytics") +- data_attributes = @request_params.valid? ? @request_params.to_data_attributes : {} +- data_attributes.merge!(cycle_analytics_initial_data(@project, @group)) - add_page_specific_style 'page_bundles/cycle_analytics' -#js-cycle-analytics{ data: cycle_analytics_initial_data(@project, @group) } +#js-cycle-analytics{ data: data_attributes } diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml index 57dfcb8cf4a..f18574c3ad5 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -6,7 +6,7 @@ .table-section.section-10{ role: 'gridcell' } .table-mobile-header{ role: 'rowheader' }= _("ID") - %strong.table-mobile-content ##{deployment.iid} + %strong.table-mobile-content{ data: { testid: 'deployment-id' } } ##{deployment.iid} .table-section.section-10{ role: 'gridcell' } .table-mobile-header{ role: 'rowheader' }= _("Triggerer") diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 926a0610577..6421aef14cf 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -23,7 +23,7 @@ .js-project-permissions-form - if show_visibility_confirm_modal?(@project) = render "visibility_modal" - = f.submit _('Save changes'), class: "btn gl-button btn-confirm #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level } + = f.submit _('Save changes'), class: "btn gl-button btn-confirm #{('js-legacy-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level } %section.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)], data: { qa_selector: 'merge_request_settings_content' } } .settings-header diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 905794c0730..c3fbf774faa 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -77,5 +77,5 @@ git push -u origin --all git push -u origin --tags -- if @project.empty_repo_upload_experiment? +- if @project.upload_anchor_data.present? = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, default_branch_name), ref: default_branch_name, method: :post diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index 0136184f80d..72ccc8d830c 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -1,10 +1,19 @@ - page_title _("Environments") -- add_page_specific_style 'page_bundles/environments' -#environments-list-view{ data: { environments_data: environments_list_data, - "can-read-environment" => can?(current_user, :read_environment, @project).to_s, - "can-create-environment" => can?(current_user, :create_environment, @project).to_s, - "new-environment-path" => new_project_environment_path(@project), - "help-page-path" => help_page_path("ci/environments/index.md"), - "project-path" => @project.full_path, - "default-branch-name" => @project.default_branch_or_main } } +- if Feature.enabled?(:new_environments_table) + #environments-table{ data: { endpoint: project_environments_path(@project, format: :json), + "can-read-environment" => can?(current_user, :read_environment, @project).to_s, + "can-create-environment" => can?(current_user, :create_environment, @project).to_s, + "new-environment-path" => new_project_environment_path(@project), + "help-page-path" => help_page_path("ci/environments/index.md"), + "project-path" => @project.full_path, + "default-branch-name" => @project.default_branch_or_main } } +- else + - add_page_specific_style 'page_bundles/environments' + #environments-list-view{ data: { environments_data: environments_list_data, + "can-read-environment" => can?(current_user, :read_environment, @project).to_s, + "can-create-environment" => can?(current_user, :create_environment, @project).to_s, + "new-environment-path" => new_project_environment_path(@project), + "help-page-path" => help_page_path("ci/environments/index.md"), + "project-path" => @project.full_path, + "default-branch-name" => @project.default_branch_or_main } } diff --git a/app/views/projects/google_cloud/index.html.haml b/app/views/projects/google_cloud/index.html.haml index 4fc66e17810..69e481501d5 100644 --- a/app/views/projects/google_cloud/index.html.haml +++ b/app/views/projects/google_cloud/index.html.haml @@ -3,81 +3,4 @@ - @content_class = "limit-container-width" unless fluid_layout -#js-google-cloud - - %h1.gl-font-size-h1 Google Cloud - - %section#js-section-google-cloud-service-accounts - - %h2.gl-font-size-h2 Service Accounts - - %p= _('Service Accounts keys are required to authorize GitLab to deploy your Google Cloud project.') - - %table.table.b-table.gl-table - - %thead - %tr - %th Environment - %th GCP Project ID - %th Service Account Key - - %tbody - - %tr - %td * - %td serving-salutes-453 - %td ..... - - %tr - %td production - %td crimson-corey-234 - %td ..... - - %tr - %td review/* - %td roving-river-379 - %td ..... - - %a.gl-button.btn.btn-primary= _('Add new service account') - - %br - - %section#js-section-google-cloud-deployments - - .row.row-fluid - - .col-lg-4 - %h2.gl-font-size-h2 Deployments - %p= _('Google Cloud offers several deployment targets. Select the one most suitable for your project.') - %p - = _('Deployments to Google Kubernetes Engine can be ') - %a{ href: '#' }= _('managed') - = _('in Infrastructure :: Kubernetes clusters') - - .col-lg-8 - - %br - - .gl-card.gl-mb-6 - .gl-card-body - .gl-display-flex.gl-align-items-baseline - %strong.gl-font-lg App Engine - .gl-ml-auto.gl-text-gray-500 Disabled - %p= _('App Engine description and apps that are suitable for this deployment target') - %button.gl-button.btn.btn-default= _('Configure via Merge Request') - - .gl-card.gl-mb-6 - .gl-card-body - .gl-display-flex.gl-align-items-baseline - %strong.gl-font-lg Cloud Functions - .gl-ml-auto.gl-text-gray-500 Disabled - %p= _('Cloud Functions description and apps that are suitable for this deployment target') - %button.gl-button.btn.btn-default= _('Configure via Merge Request') - - .gl-card.gl-mb-6 - .gl-card-body - .gl-display-flex.gl-align-items-baseline - %strong.gl-font-lg Cloud Run - .gl-ml-auto.gl-text-gray-500 Disabled - %p= _('Cloud Run description and apps that are suitable for this deployment target') - %button.gl-button.btn.btn-default= _('Configure via Merge Request') +#js-google-cloud{ data: @js_data } diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml index 86dfa1929d6..f6861e4119e 100644 --- a/app/views/projects/hook_logs/show.html.haml +++ b/app/views/projects/hook_logs/show.html.haml @@ -2,11 +2,11 @@ - add_to_breadcrumbs _('Webhook Settings'), namespace_project_hooks_path - page_title _('Webhook Logs') -.row.gl-mt-3.gl-mb-3 - .col-lg-3 - %h4.gl-mt-0 - = _("Request details") - .col-lg-9 - = link_to _('Resend Request'), @hook_log.present.retry_path, method: :post, class: "btn gl-button btn-default float-right gl-ml-3" - - = render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log } +%h3.page-title + = _("Request details") + +%hr + += link_to _("Resend Request"), @hook_log.present.retry_path, method: :post, class: "btn gl-button btn-default float-right gl-ml-3" + += render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log } diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 2fbaa5812c0..f1c19756474 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -1,5 +1,4 @@ -- add_page_startup_api_call discussions_path(@issue) -- add_page_startup_api_call notes_url +- add_page_startup_api_call Feature.enabled?(:paginated_issue_discussions, @project, default_enabled: :yaml) ? discussions_path(@issue, per_page: 20) : discussions_path(@issue) - @gfm_form = true diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml index e4d072a9472..1ab51df6d24 100644 --- a/app/views/projects/issues/_issues.html.haml +++ b/app/views/projects/issues/_issues.html.haml @@ -1,5 +1,5 @@ - is_project_overview = local_assigns.fetch(:is_project_overview, false) -= render 'shared/alerts/positioning_disabled' += render 'shared/alerts/positioning_disabled' if @sort == 'relative_position' - if Feature.enabled?(:vue_issuables_list, @project) && !is_project_overview - data_endpoint = local_assigns.fetch(:data_endpoint, expose_path(api_v4_projects_issues_path(id: @project.id))) diff --git a/app/views/projects/issues/_service_desk_empty_state.html.haml b/app/views/projects/issues/_service_desk_empty_state.html.haml index a4251c8e5dc..afeb2a23ea2 100644 --- a/app/views/projects/issues/_service_desk_empty_state.html.haml +++ b/app/views/projects/issues/_service_desk_empty_state.html.haml @@ -16,7 +16,7 @@ = s_("ServiceDesk|Your users can send emails to this address:") %code= @project.service_desk_address - %span= s_("ServiceDesk|Issues created from Service Desk emails appear here. Each comment becomes part of the email conversation.") + %span= s_("ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation.") = link_to _('Learn more.'), help_page_path('user/project/service_desk') - if can_edit_project_settings && !service_desk_enabled diff --git a/app/views/projects/issues/_service_desk_info_content.html.haml b/app/views/projects/issues/_service_desk_info_content.html.haml index 448a341eba6..f0ec68ba54b 100644 --- a/app/views/projects/issues/_service_desk_info_content.html.haml +++ b/app/views/projects/issues/_service_desk_info_content.html.haml @@ -1,16 +1,17 @@ - service_desk_enabled = @project.service_desk_enabled? - can_edit_project_settings = can?(current_user, :admin_project, @project) +- can_admin_issues = can?(current_user, :admin_issue, @project) - title_text = s_("ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab") .non-empty-state.media .svg-content - = render 'shared/empty_states/icons/service_desk_callout.svg' + = render partial: 'shared/empty_states/icons/service_desk_callout', formats: :svg .gl-mt-3.gl-ml-3 %h5= title_text - - if can_edit_project_settings && service_desk_enabled + - if can_admin_issues && service_desk_enabled %p = s_("ServiceDesk|Your users can send emails to this address:") %code= @project.service_desk_address diff --git a/app/views/projects/learn_gitlab/index.html.haml b/app/views/projects/learn_gitlab/index.html.haml index 4935b72d3fa..9b17be99da0 100644 --- a/app/views/projects/learn_gitlab/index.html.haml +++ b/app/views/projects/learn_gitlab/index.html.haml @@ -1,5 +1,12 @@ - breadcrumb_title _("Learn GitLab") - page_title _("Learn GitLab") - add_page_specific_style 'page_bundles/learn_gitlab' +- data = learn_gitlab_data(@project) +- invite_members_open = session.delete(:confetti_post_signup) -#js-learn-gitlab-app{ data: { actions: onboarding_actions_data(@project).to_json, sections: onboarding_sections_data.to_json } } +- experiment(:confetti_post_signup, actor: current_user) do |e| + - e.control do + #js-learn-gitlab-app{ data: data } + - e.candidate do + = render 'projects/invite_members_modal', project: @project + #js-learn-gitlab-app{ data: data.merge(invite_members_open: invite_members_open) } diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml index 459742c3b81..0c8af873095 100644 --- a/app/views/projects/merge_requests/_widget.html.haml +++ b/app/views/projects/merge_requests/_widget.html.haml @@ -14,8 +14,8 @@ window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds')}'; window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests')}'; window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_compliance/index.md', anchor: 'policies')}'; - window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}'; - window.gl.mrWidgetData.approvals_help_path = '#{help_page_path("user/project/merge_requests/merge_request_approvals")}'; + window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/approvals/rules.md', anchor: 'eligible-approvers')}'; + window.gl.mrWidgetData.approvals_help_path = '#{help_page_path("user/project/merge_requests/approvals/index.md")}'; window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}'; window.gl.mrWidgetData.codequality_help_path = '#{help_page_path("user/project/merge_requests/code_quality", anchor: "code-quality-reports")}'; window.gl.mrWidgetData.false_positive_doc_url = '#{help_page_path('user/application_security/vulnerabilities/index')}'; diff --git a/app/views/projects/mirrors/_instructions.html.haml b/app/views/projects/mirrors/_instructions.html.haml index a91751da0aa..2bd2c7cac44 100644 --- a/app/views/projects/mirrors/_instructions.html.haml +++ b/app/views/projects/mirrors/_instructions.html.haml @@ -3,7 +3,7 @@ %li = html_escape(_('The repository must be accessible over %{code_open}http://%{code_close}, %{code_open}https://%{code_close}, %{code_open}ssh://%{code_close} or %{code_open}git://%{code_close}.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - %li= html_escape(_('When using the %{code_open}http://%{code_close} or %{code_open}https://%{code_close} protocols, provide the exact URL to the repository. HTTP redirects will not be followed.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + %li= html_escape(_('When using the %{code_open}http://%{code_close} or %{code_open}https://%{code_close} protocols, please provide the exact URL to the repository. HTTP redirects will not be followed.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %li= html_escape(_('Include the username in the URL if required: %{code_open}https://username@gitlab.company.com/group/project.git%{code_close}.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %li - minutes = Gitlab.config.gitlab_shell.git_timeout / 60 diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index c62853145b6..4e4738ebd25 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -12,7 +12,7 @@ .row{ 'v-cloak': true } #blank-project-pane.tab-pane.active - = form_for @project, html: { class: 'new_project' } do |f| + = form_for @project, html: { class: 'new_project gl-mt-3' } do |f| = render 'new_project_fields', f: f, project_name_id: "blank-project-name" #create-from-template-pane.tab-pane diff --git a/app/views/projects/pages/_no_domains.html.haml b/app/views/projects/pages/_no_domains.html.haml index 8d6e403b93a..a537bd80d30 100644 --- a/app/views/projects/pages/_no_domains.html.haml +++ b/app/views/projects/pages/_no_domains.html.haml @@ -3,4 +3,4 @@ .card-header = s_('GitLabPages|Domains') .nothing-here-block - = s_("GitLabPages|Support for domains and certificates is disabled. Ask your system's administrator to enable it.") + = s_("GitLabPages|Support for domains and certificates is disabled. Ask your system's administrator to enable it.") diff --git a/app/views/projects/pipeline_schedules/_tabs.html.haml b/app/views/projects/pipeline_schedules/_tabs.html.haml index f69041e1eb1..f825ef35902 100644 --- a/app/views/projects/pipeline_schedules/_tabs.html.haml +++ b/app/views/projects/pipeline_schedules/_tabs.html.haml @@ -1,18 +1,12 @@ -%ul.nav-links.mobile-separator.nav.nav-tabs - %li{ class: active_when(scope.nil?) }> - = link_to schedule_path_proc.call(nil) do - = s_("PipelineSchedules|All") - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm.js-totalbuilds-count - = number_with_delimiter(all_schedules.count(:id)) += gl_tabs_nav({ class: 'gl-display-flex gl-flex-grow-1 gl-border-0' }) do + = gl_tab_link_to schedule_path_proc.call(nil), { item_active: active_when(scope.nil?) } do + = s_("PipelineSchedules|All") + = gl_tab_counter_badge(number_with_delimiter(all_schedules.count(:id)), { class: 'js-totalbuilds-count' }) - %li{ class: active_when(scope == 'active') }> - = link_to schedule_path_proc.call('active') do - = s_("PipelineSchedules|Active") - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm - = number_with_delimiter(all_schedules.active.count(:id)) + = gl_tab_link_to schedule_path_proc.call('active'), { item_active: active_when(scope == 'active') } do + = s_("PipelineSchedules|Active") + = gl_tab_counter_badge(number_with_delimiter(all_schedules.active.count(:id))) - %li{ class: active_when(scope == 'inactive') }> - = link_to schedule_path_proc.call('inactive') do - = s_("PipelineSchedules|Inactive") - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm - = number_with_delimiter(all_schedules.inactive.count(:id)) + = gl_tab_link_to schedule_path_proc.call('inactive'), { item_active: active_when(scope == 'inactive') } do + = s_("PipelineSchedules|Inactive") + = gl_tab_counter_badge(number_with_delimiter(all_schedules.inactive.count(:id))) diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 4a10f6aee1c..0bfdee088b4 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -17,45 +17,46 @@ - if @pipeline.queued_duration = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" - .well-segment.qa-pipeline-badges - .icon-container - = sprite_icon('flag') - - if @pipeline.child? - %span.js-pipeline-child.badge.badge-pill.gl-badge.sm.badge-primary.has-tooltip{ title: s_("Pipelines|This is a child pipeline within the parent pipeline") } - = s_('Pipelines|Child pipeline') - = surround '(', ')' do - = link_to s_('Pipelines|parent'), pipeline_path(@pipeline.triggered_by_pipeline), class: 'text-white text-underline' - - if @pipeline.latest? - %span.js-pipeline-url-latest.badge.badge-pill.gl-badge.sm.badge-success.has-tooltip{ title: _("Latest pipeline for the most recent commit on this branch") } - latest - - if @pipeline.merge_train_pipeline? - %span.js-pipeline-url-train.badge.badge-pill.gl-badge.sm.badge-info.has-tooltip{ title: _("This is a merge train pipeline") } - train - - if @pipeline.has_yaml_errors? - %span.js-pipeline-url-yaml.badge.badge-pill.gl-badge.sm.badge-danger.has-tooltip{ title: @pipeline.yaml_errors } - yaml invalid - - if @pipeline.failure_reason? - %span.js-pipeline-url-failure.badge.badge-pill.gl-badge.sm.badge-danger.has-tooltip{ title: @pipeline.failure_reason } - error - - if @pipeline.auto_devops_source? - - popover_title_text = html_escape(_('This pipeline makes use of a predefined CI/CD configuration enabled by %{b_open}Auto DevOps.%{b_close}')) % { b_open: '<b>'.html_safe, b_close: '</b>'.html_safe } - - popover_content_url = help_page_path('topics/autodevops/index.md') - - popover_content_text = _('Learn more about Auto DevOps') - %a.js-pipeline-url-autodevops.badge.badge-pill.gl-badge.sm.badge-info.autodevops-badge{ href: "#", tabindex: "0", role: "button", data: { container: "body", - toggle: "popover", - placement: "top", - html: "true", - triggers: "focus", - title: "<div class='gl-font-weight-normal gl-line-height-normal'>#{popover_title_text}</div>", - content: "<a href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>", - } } - Auto DevOps - - if @pipeline.detached_merge_request_pipeline? - %span.js-pipeline-url-mergerequest.badge.badge-pill.gl-badge.sm.badge-info.has-tooltip{ title: _('Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for Pipelines for Merged Results.') } - detached - - if @pipeline.stuck? - %span.js-pipeline-url-stuck.badge.badge-pill.gl-badge.sm.badge-warning - stuck + - if has_pipeline_badges?(@pipeline) + .well-segment.qa-pipeline-badges + .icon-container + = sprite_icon('flag') + - if @pipeline.child? + %span.js-pipeline-child.badge.badge-pill.gl-badge.sm.badge-primary.has-tooltip{ title: s_("Pipelines|This is a child pipeline within the parent pipeline") } + = s_('Pipelines|Child pipeline') + = surround '(', ')' do + = link_to s_('Pipelines|parent'), pipeline_path(@pipeline.triggered_by_pipeline), class: 'text-white text-underline' + - if @pipeline.latest? + %span.js-pipeline-url-latest.badge.badge-pill.gl-badge.sm.badge-success.has-tooltip{ title: _("Latest pipeline for the most recent commit on this branch") } + latest + - if @pipeline.merge_train_pipeline? + %span.js-pipeline-url-train.badge.badge-pill.gl-badge.sm.badge-info.has-tooltip{ title: _("This is a merge train pipeline") } + train + - if @pipeline.has_yaml_errors? + %span.js-pipeline-url-yaml.badge.badge-pill.gl-badge.sm.badge-danger.has-tooltip{ title: @pipeline.yaml_errors } + yaml invalid + - if @pipeline.failure_reason? + %span.js-pipeline-url-failure.badge.badge-pill.gl-badge.sm.badge-danger.has-tooltip{ title: @pipeline.failure_reason } + error + - if @pipeline.auto_devops_source? + - popover_title_text = html_escape(_('This pipeline makes use of a predefined CI/CD configuration enabled by %{b_open}Auto DevOps.%{b_close}')) % { b_open: '<b>'.html_safe, b_close: '</b>'.html_safe } + - popover_content_url = help_page_path('topics/autodevops/index.md') + - popover_content_text = _('Learn more about Auto DevOps') + %a.js-pipeline-url-autodevops.badge.badge-pill.gl-badge.sm.badge-info.autodevops-badge{ href: "#", tabindex: "0", role: "button", data: { container: "body", + toggle: "popover", + placement: "top", + html: "true", + triggers: "focus", + title: "<div class='gl-font-weight-normal gl-line-height-normal'>#{popover_title_text}</div>", + content: "<a href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>", + } } + Auto DevOps + - if @pipeline.detached_merge_request_pipeline? + %span.js-pipeline-url-mergerequest.badge.badge-pill.gl-badge.sm.badge-info.has-tooltip{ title: _('Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for Pipelines for Merged Results.') } + detached + - if @pipeline.stuck? + %span.js-pipeline-url-stuck.badge.badge-pill.gl-badge.sm.badge-warning + stuck .well-segment.branch-info .icon-container.commit-icon diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml index 992407adf71..21e871246d7 100644 --- a/app/views/projects/pipelines/charts.html.haml +++ b/app/views/projects/pipelines/charts.html.haml @@ -1,4 +1,5 @@ - page_title _('CI/CD Analytics') #js-project-pipelines-charts-app{ data: { project_path: @project.full_path, - should_render_dora_charts: should_render_dora_charts.to_s } } + should_render_dora_charts: should_render_dora_charts.to_s, + should_render_quality_summary: should_render_quality_summary.to_s } } diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 8fd8d3cf540..c911fc8a203 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -22,8 +22,8 @@ %ul - @pipeline.yaml_errors.split(",").each do |error| %li= error - - lint_link_url = project_ci_lint_path(@project) - - lint_link_start = '<a href="%{url}">'.html_safe % { url: lint_link_url } + - lint_link_url = project_ci_pipeline_editor_path(@project, tab: "LINT_TAB") + - lint_link_start = '<a href="%{url}" class="gl-text-blue-500!">'.html_safe % { url: lint_link_url } = s_('You can also test your %{gitlab_ci_yml} in %{lint_link_start}CI Lint%{lint_link_end}').html_safe % { gitlab_ci_yml: '.gitlab-ci.yml', lint_link_start: lint_link_start, lint_link_end: '</a>'.html_safe } = render "projects/pipelines/with_tabs", pipeline: @pipeline, stages: @stages, pipeline_has_errors: pipeline_has_errors diff --git a/app/views/projects/product_analytics/_links.html.haml b/app/views/projects/product_analytics/_links.html.haml index 0797c5baf91..6e5667e2644 100644 --- a/app/views/projects/product_analytics/_links.html.haml +++ b/app/views/projects/product_analytics/_links.html.haml @@ -1,10 +1,5 @@ -.mb-3 - %ul.nav-links - = nav_link(path: 'product_analytics#index') do - = link_to _('Events'), project_product_analytics_path(@project) - = nav_link(path: 'product_analytics#graphs') do - = link_to 'Graphs', graphs_project_product_analytics_path(@project) - = nav_link(path: 'product_analytics#test') do - = link_to _('Test'), test_project_product_analytics_path(@project) - = nav_link(path: 'product_analytics#setup') do - = link_to _('Setup'), setup_project_product_analytics_path(@project) += gl_tabs_nav({ class: 'mb-3'}) do + = gl_tab_link_to _('Events'), project_product_analytics_path(@project) + = gl_tab_link_to _('Graphs'), graphs_project_product_analytics_path(@project) + = gl_tab_link_to _('Test'), test_project_product_analytics_path(@project) + = gl_tab_link_to _('Setup'), setup_project_product_analytics_path(@project) diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml index b185f45d129..0f4d5869cea 100644 --- a/app/views/projects/settings/_general.html.haml +++ b/app/views/projects/settings/_general.html.haml @@ -1,3 +1,4 @@ +- hidden_topics_field_id = 'project_topic_list_field' = form_for [@project], html: { multipart: true, class: "edit-project js-general-settings-form" }, authenticity_token: true do |f| %input{ name: 'update_section', type: 'hidden', value: 'js-general-settings' } @@ -15,9 +16,9 @@ .row .form-group.col-md-9 - = f.label :topics, _('Topics (optional)'), class: 'label-bold' - = f.text_field :topics, value: @project.topic_list.join(', '), maxlength: 2000, class: "form-control gl-form-input" - %p.form-text.text-muted= _('Separate topics with commas.') + = f.label :topics, _('Topics'), class: 'label-bold' + .js-topics-selector{ data: { hidden_input_id: hidden_topics_field_id } } + = f.hidden_field :topics, value: @project.topic_list.join(', '), id: hidden_topics_field_id .row .form-group.col-md-9 diff --git a/app/views/projects/work_items/index.html.haml b/app/views/projects/work_items/index.html.haml index 052db598571..0efd7a740d3 100644 --- a/app/views/projects/work_items/index.html.haml +++ b/app/views/projects/work_items/index.html.haml @@ -1,3 +1,3 @@ - page_title s_('WorkItem|Work Items') -#js-work-items +#js-work-items{ data: { full_path: @project.full_path } } diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml index 9356b6ad49c..65a1ffa3e46 100644 --- a/app/views/registrations/welcome/show.html.haml +++ b/app/views/registrations/welcome/show.html.haml @@ -24,9 +24,10 @@ .form-group.col-sm-12.js-other-role-group.hidden = f.label :other_role, _('What is your job title? (optional)'), class: 'form-check-label gl-mb-3' = f.text_field :other_role, class: 'form-control' + = render_if_exists "registrations/welcome/jobs_to_be_done", f: f = render_if_exists "registrations/welcome/setup_for_company", f: f + = render_if_exists "registrations/welcome/joining_project" = render 'devise/shared/email_opted_in', f: f - = render_if_exists "registrations/welcome/jobs_to_be_done", f: f .row .form-group.col-sm-12.gl-mb-0 - if partial_exists? "registrations/welcome/button" diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index ca6f2369bd8..54aa9aad8a5 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -5,7 +5,7 @@ .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller .fade-left= sprite_icon('chevron-lg-left', size: 12) .fade-right= sprite_icon('chevron-lg-right', size: 12) - %ul.nav-links.search-filter.scrolling-tabs.nav.nav-tabs + = gl_tabs_nav({ class: 'search-filter scrolling-tabs nav-links'}) do - if @project - if project_search_tabs?(:blobs) = search_filter_link 'blobs', _("Code"), data: { qa_selector: 'code_tab' } diff --git a/app/views/shared/_confirm_modal.html.haml b/app/views/shared/_confirm_modal.html.haml index 8b13bb948ee..4cb3f6d1739 100644 --- a/app/views/shared/_confirm_modal.html.haml +++ b/app/views/shared/_confirm_modal.html.haml @@ -12,10 +12,10 @@ %p %span.js-warning-text= _('This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention.') %br - - phrase_code = '<code class="js-confirm-danger-match">%{phrase_name}</code>'.html_safe % { phrase_name: phrase } + - phrase_code = '<code class="js-legacy-confirm-danger-match">%{phrase_name}</code>'.html_safe % { phrase_name: phrase } = _('Please type %{phrase_code} to proceed or close this modal to cancel.').html_safe % { phrase_code: phrase_code } .form-group - = text_field_tag 'confirm_name_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input' + = text_field_tag 'confirm_name_input', '', class: 'form-control js-legacy-confirm-danger-input qa-confirm-input' .form-actions - = submit_tag _('Confirm'), class: "gl-button btn btn-danger js-confirm-danger-submit qa-confirm-button" + = submit_tag _('Confirm'), class: "gl-button btn btn-danger js-legacy-confirm-danger-submit qa-confirm-button" diff --git a/app/views/shared/_default_branch_protection.html.haml b/app/views/shared/_default_branch_protection.html.haml index d7ae21debd8..7a6152f6d96 100644 --- a/app/views/shared/_default_branch_protection.html.haml +++ b/app/views/shared/_default_branch_protection.html.haml @@ -1,3 +1,4 @@ -.form-group - = f.label :default_branch_protection, class: 'label-bold' - = f.select :default_branch_protection, options_for_select(Gitlab::Access.protection_options, selected_level), {}, class: 'form-control' +%fieldset.form-group + %legend.h5.gl-border-none.gl-mt-0.gl-mb-3= _('Default branch protection') + - Gitlab::Access.protection_options.each do |option| + = f.gitlab_ui_radio_component :default_branch_protection, option[:value], option[:label], help_text: option[:help_text] diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml index 6eb736b0710..4e3b1e02f16 100644 --- a/app/views/shared/_issues.html.haml +++ b/app/views/shared/_issues.html.haml @@ -1,4 +1,4 @@ -= render 'shared/alerts/positioning_disabled' += render 'shared/alerts/positioning_disabled' if @sort == 'relative_position' - if @issues.to_a.any? %ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class, data: { group_full_path: @group&.full_path } } diff --git a/app/views/shared/_md_preview.html.haml b/app/views/shared/_md_preview.html.haml index 0976defea1b..a49a0667d84 100644 --- a/app/views/shared/_md_preview.html.haml +++ b/app/views/shared/_md_preview.html.haml @@ -9,7 +9,7 @@ .md-area.position-relative .md-header - %ul.nav.nav-tabs.nav-links.clearfix + = gl_tabs_nav({ class: 'clearfix nav-links'}) do %li.md-header-tab.active %button.js-md-write-button = _("Write") diff --git a/app/views/shared/_new_commit_form.html.haml b/app/views/shared/_new_commit_form.html.haml index 5641c67e462..14ea96f9669 100644 --- a/app/views/shared/_new_commit_form.html.haml +++ b/app/views/shared/_new_commit_form.html.haml @@ -5,9 +5,7 @@ - if project.empty_repo? - ref = local_assigns[:ref] || @ref - - branch_name_class = project.empty_repo_upload_experiment? ? 'js-branch-name' : nil - - = hidden_field_tag 'branch_name', ref, class: branch_name_class + = hidden_field_tag 'branch_name', ref, class: 'js-branch-name' - else - if can?(current_user, :push_code, @project) .form-group.row.branch diff --git a/app/views/shared/_no_password.html.haml b/app/views/shared/_no_password.html.haml index 9c1e5a49b44..d1e1a8a819d 100644 --- a/app/views/shared/_no_password.html.haml +++ b/app/views/shared/_no_password.html.haml @@ -1,12 +1,10 @@ - if show_no_password_message? - .no-password-message.gl-alert.gl-alert-warning - = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label': _('Dismiss') } - = sprite_icon('close', size: 16, css_class: 'gl-icon') + = render 'shared/global_alert', + variant: :warning, + alert_class: 'js-no-password-message', + close_button_class: 'js-hide-no-password-message' do .gl-alert-body - - translation_params = { protocol: gitlab_config.protocol.upcase, set_password_link: link_to_set_password } - - set_password_message = _("You won't be able to pull or push repositories via %{protocol} until you %{set_password_link} on your account") % translation_params - = set_password_message.html_safe + = no_password_message .gl-alert-actions - = link_to _('Remind later'), '#', class: 'hide-no-password-message btn gl-alert-action btn-info btn-md gl-button' - = link_to _("Don't show again"), profile_path(user: {hide_no_password: true}), method: :put, role: 'button', class: 'btn gl-alert-action btn-md btn-default gl-button btn-default-secondary' + = link_to _('Remind later'), '#', class: 'js-hide-no-password-message gl-alert-action btn btn-confirm btn-md gl-button' + = link_to _("Don't show again"), profile_path(user: { hide_no_password: true }), method: :put, role: 'button', class: 'gl-alert-action btn btn-default btn-md gl-button' diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml index 2c6ceb58654..20dc1b41970 100644 --- a/app/views/shared/_no_ssh.html.haml +++ b/app/views/shared/_no_ssh.html.haml @@ -1,10 +1,10 @@ - if show_no_ssh_key_message? - %div{ class: 'no-ssh-key-message gl-alert gl-alert-warning', role: 'alert' } - = sprite_icon('warning', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title') - %button{ class: 'gl-alert-dismiss hide-no-ssh-message', type: 'button', 'aria-label': _('Dismiss') } - = sprite_icon('close', css_class: 'gl-icon s16') + = render 'shared/global_alert', + variant: :warning, + alert_class: 'js-no-ssh-message', + close_button_class: 'js-hide-no-ssh-message' do .gl-alert-body - = s_("MissingSSHKeyWarningLink|You won't be able to pull or push repositories via SSH until you add an SSH key to your profile") + = s_("MissingSSHKeyWarningLink|You can't push or pull repositories using SSH until you add an SSH key to your profile.") .gl-alert-actions - = link_to s_('MissingSSHKeyWarningLink|Add SSH key'), profile_keys_path, class: "btn gl-alert-action btn-warning btn-md gl-button" - = link_to s_("MissingSSHKeyWarningLink|Don't show again"), profile_path(user: {hide_no_ssh_key: true}), method: :put, role: 'button', class: 'btn gl-alert-action btn-md btn-warning gl-button btn-warning-secondary' + = link_to s_('MissingSSHKeyWarningLink|Add SSH key'), profile_keys_path, class: "gl-alert-action btn btn-confirm btn-md gl-button" + = link_to s_("MissingSSHKeyWarningLink|Don't show again"), profile_path(user: { hide_no_ssh_key: true }), method: :put, role: 'button', class: 'gl-alert-action btn btn-default btn-md gl-button' diff --git a/app/views/shared/_project_creation_levels.html.haml b/app/views/shared/_project_creation_levels.html.haml new file mode 100644 index 00000000000..00f495a26dc --- /dev/null +++ b/app/views/shared/_project_creation_levels.html.haml @@ -0,0 +1,7 @@ +- method = local_assigns.fetch(:method, nil) +- legend = local_assigns.fetch(:legend, nil) + +%fieldset.form-group + %legend.h5.gl-border-none.gl-mt-0.gl-mb-3= legend + - Gitlab::Access.project_creation_options.each do |label, value| + = f.gitlab_ui_radio_component method, value, label diff --git a/app/views/shared/_visibility_radios.html.haml b/app/views/shared/_visibility_radios.html.haml index f48bfcd0e72..760fe18ddec 100644 --- a/app/views/shared/_visibility_radios.html.haml +++ b/app/views/shared/_visibility_radios.html.haml @@ -10,6 +10,7 @@ = visibility_level_label(level) .option-description = visibility_level_description(level, form_model) + .option-disabled-reason .text-muted - if all_visibility_levels_restricted? diff --git a/app/views/shared/alerts/_positioning_disabled.html.haml b/app/views/shared/alerts/_positioning_disabled.html.haml index 91c1d3463d8..59f2fcef24c 100644 --- a/app/views/shared/alerts/_positioning_disabled.html.haml +++ b/app/views/shared/alerts/_positioning_disabled.html.haml @@ -1,2 +1,2 @@ - if issue_repositioning_disabled? - = render 'shared/alert_info', body: _('Issues manual ordering is temporarily disabled for technical reasons.') + = render 'shared/alert_info', body: _('Issues are being rebalanced at the moment, so manual reordering is disabled.') diff --git a/app/views/shared/boards/_switcher.html.haml b/app/views/shared/boards/_switcher.html.haml index 18e0ca20cf7..c667b3a4626 100644 --- a/app/views/shared/boards/_switcher.html.haml +++ b/app/views/shared/boards/_switcher.html.haml @@ -3,15 +3,10 @@ - milestone_filter_opts = milestone_filter_opts.merge(only_group_milestones: true) if board.group_board? - weights = Gitlab.ee? ? ([Issue::WEIGHT_ANY] + Issue.weight_options) : [] -#js-multiple-boards-switcher.inline.boards-switcher{ data: { current_board: current_board_json.to_json, - milestone_path: milestones_filter_path(milestone_filter_opts), +#js-multiple-boards-switcher.inline.boards-switcher{ data: { milestone_path: milestones_filter_path(milestone_filter_opts), board_base_url: board_base_url, has_missing_boards: (!multiple_boards_available? && current_board_parent.boards.size > 1).to_s, can_admin_board: can?(current_user, :admin_issue_board, parent).to_s, multiple_issue_boards_available: parent.multiple_issue_boards_available?.to_s, - labels_path: labels_filter_path_with_defaults(only_group_labels: true, include_descendant_groups: true), - labels_web_url: parent.is_a?(Project) ? project_labels_path(@project) : group_labels_path(@group), - project_id: @project&.id, - group_id: @group&.id, scoped_issue_board_feature_enabled: Gitlab.ee? && parent.feature_available?(:scoped_issue_board) ? 'true' : 'false', weights: weights.to_json } } diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml index e049afbc40b..902a0cad483 100644 --- a/app/views/shared/deploy_tokens/_form.html.haml +++ b/app/views/shared/deploy_tokens/_form.html.haml @@ -36,7 +36,7 @@ .text-secondary= s_('DeployTokens|Allows read-only access to registry images.') %fieldset.form-group.form-check - = f.check_box :write_registry, class: 'form-check-input' + = f.check_box :write_registry, class: 'form-check-input', data: { qa_selector: 'deploy_token_write_registry_checkbox' } = f.label :write_registry, 'write_registry', class: 'label-bold form-check-label' .text-secondary= s_('DeployTokens|Allows read and write access to registry images.') diff --git a/app/views/shared/doorkeeper/applications/_show.html.haml b/app/views/shared/doorkeeper/applications/_show.html.haml index 8d6b9604c1c..f533b5b5a4d 100644 --- a/app/views/shared/doorkeeper/applications/_show.html.haml +++ b/app/views/shared/doorkeeper/applications/_show.html.haml @@ -39,6 +39,9 @@ = render "shared/tokens/scopes_list", token: @application -.form-actions - = link_to _('Edit'), edit_path, class: 'gl-button btn btn-confirm wide float-left' - = render 'shared/doorkeeper/applications/delete_form', path: delete_path, submit_btn_css: 'gl-button btn btn-danger gl-ml-3' +.form-actions.gl-display-flex.gl-justify-content-space-between + %div + - if @created + = link_to _('Continue'), index_path, class: 'btn btn-confirm btn-md gl-button gl-mr-3' + = link_to _('Edit'), edit_path, class: 'btn btn-default btn-md gl-button' + = render 'shared/doorkeeper/applications/delete_form', path: delete_path, submit_btn_css: 'btn btn-danger btn-md gl-button btn-danger-secondary' diff --git a/app/views/shared/hook_logs/_content.html.haml b/app/views/shared/hook_logs/_content.html.haml index 51f44afe55e..ab6875a09f7 100644 --- a/app/views/shared/hook_logs/_content.html.haml +++ b/app/views/shared/hook_logs/_content.html.haml @@ -1,44 +1,47 @@ -%p - %strong Request URL: - POST - = hook_log.url - = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log } +%span.gl-display-flex.gl-align-items-center + %h4 + POST + = hook_log.url + %span.badge.gl-badge.badge-pill.badge-muted.sm.gl-ml-3 + = hook_log.trigger.singularize.titleize %p - %strong Trigger: - %td.d-none.d-sm-block - %span.badge.badge-gray.deploy-project-label - = hook_log.trigger.singularize.titleize -%p - %strong Elapsed time: - #{number_with_precision(hook_log.execution_duration, precision: 2)} sec -%p - %strong Request time: - = time_ago_with_tooltip(hook_log.created_at) + = _('Completed in %{duration_seconds} seconds (%{relative_time})').html_safe % { duration_seconds: number_with_precision(hook_log.execution_duration, precision: 2), relative_time: time_ago_with_tooltip(hook_log.created_at) } %hr - if hook_log.internal_error_message.present? - .bs-callout.bs-callout-danger - = hook_log.internal_error_message + .gl-alert-container + .gl-alert.gl-alert-danger + .gl-alert-container + = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + %h4.gl-alert-title= _('Internal error occured while delivering this webhook.') + .gl-alert-body + = _('Error: %{error}') % { error: hook_log.internal_error_message } + +%h4= _('Response') += render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log } +%pre.gl-mt-3 + :escaped + #{hook_log.response_body} -%h5 Request headers: +%h5= _('Headers') %pre - - hook_log.request_headers.each do |k, v| - <strong>#{k}:</strong> #{v} + - hook_log.response_headers.each do |k, v| + <span class="gl-font-weight-bold">#{k}:</span> #{v} %br -%h5 Request body: +%h4.gl-mt-6= _('Request') %pre :escaped #{Gitlab::Json.pretty_generate(hook_log.request_data)} -%h5 Response headers: + +%h5= _('Headers') %pre - - hook_log.response_headers.each do |k, v| - <strong>#{k}:</strong> #{v} + - hook_log.request_headers.each do |k, v| + <span class="gl-font-weight-bold">#{k}:</span> #{v} %br -%h5 Response body: -%pre - :escaped - #{hook_log.response_body} + + diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 81a7581d392..e097852216c 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -15,6 +15,7 @@ .d-flex.flex-column.flex-md-row.flex-grow-1.mb-lg-0.mb-md-2.mb-sm-0.w-100 - if type == :boards = render "shared/boards/switcher", board: board + .js-new-board{ data: { multiple_issue_boards_available: parent.multiple_issue_boards_available?.to_s, can_admin_board: can?(current_user, :admin_issue_board, parent).to_s, } } = form_tag page_filter_path, method: :get, class: 'filter-form js-filter-form w-100' do - if params[:search].present? = hidden_field_tag :search, params[:search] diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index 1f391e8a321..46df9b4ef9a 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -23,7 +23,7 @@ = html_escape(_('From %{code_open}%{source_title}%{code_close} into')) % { source_title: source_title, code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - if issuable.new_record? - %code#js-target-branch-title= target_title + %code#js-target-branch-title{ data: { branch_name: @merge_request.target_branch } }= target_title = link_to _('Change branches'), mr_change_branches_path(issuable) - elsif issuable.for_fork? diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml index f5f6f32d5ba..ae0fe54de4f 100644 --- a/app/views/shared/issuable/form/_type_selector.html.haml +++ b/app/views/shared/issuable/form/_type_selector.html.haml @@ -18,17 +18,19 @@ = sprite_icon('close', size: 16, css_class: 'dropdown-menu-close-icon') .dropdown-content{ data: { testid: 'issue-type-select-dropdown' } } %ul - %li.js-filter-issuable-type - = link_to new_project_issue_path(@project), class: ("is-active" if issuable.issue?) do - #{sprite_icon(work_item_type_icon(:issue), css_class: 'gl-icon')} #{_("Issue")} - %li.js-filter-issuable-type{ data: { track: { action: "select_issue_type_incident", label: "select_issue_type_incident_dropdown_option" } } } - = link_to new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }), class: ("is-active" if issuable.incident?) do - #{sprite_icon(work_item_type_icon(:incident), css_class: 'gl-icon')} #{_("Incident")} + - if create_issue_type_allowed?(@project, :issue) + %li.js-filter-issuable-type + = link_to new_project_issue_path(@project), class: ("is-active" if issuable.issue?) do + #{sprite_icon(work_item_type_icon(:issue), css_class: 'gl-icon')} #{_('Issue')} + - if create_issue_type_allowed?(@project, :incident) + %li.js-filter-issuable-type{ data: { track: { action: "select_issue_type_incident", label: "select_issue_type_incident_dropdown_option" } } } + = link_to new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }), class: ("is-active" if issuable.incident?) do + #{sprite_icon(work_item_type_icon(:incident), css_class: 'gl-icon')} #{_('Incident')} #js-type-popover - if issuable.incident? %p.form-text.text-muted - incident_docs_url = help_page_path('operations/incident_management/incidents.md') - - incident_docs_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: incident_docs_url } - = _('A %{incident_docs_start}modified issue%{incident_docs_end} to guide the resolution of incidents.').html_safe % { incident_docs_start: incident_docs_start, incident_docs_end: '</a>'.html_safe } + - incident_docs_start = format('<a href="%{url}" target="_blank" rel="noopener noreferrer">', url: incident_docs_url) + = format(_('A %{incident_docs_start}modified issue%{incident_docs_end} to guide the resolution of incidents.'), incident_docs_start: incident_docs_start, incident_docs_end: '</a>').html_safe diff --git a/app/views/shared/labels/_nav.html.haml b/app/views/shared/labels/_nav.html.haml index a0225e35c90..47e9d9b0e4a 100644 --- a/app/views/shared/labels/_nav.html.haml +++ b/app/views/shared/labels/_nav.html.haml @@ -1,14 +1,10 @@ - subscribed = params[:subscribed] .top-area.adjust - %ul.nav-links.nav.nav-tabs - %li{ class: active_when(subscribed != 'true') }> - = link_to labels_filter_path do - = _('All') + = gl_tabs_nav({ class: 'gl-flex-grow-1 gl-border-0' }) do + = gl_tab_link_to _('All'), labels_filter_path, { item_active: subscribed != 'true' } - if current_user - %li{ class: active_when(subscribed == 'true') }> - = link_to labels_filter_path(subscribed: 'true') do - = _('Subscribed') + = gl_tab_link_to _('Subscribed'), labels_filter_path(subscribed: 'true'), { item_active: subscribed == 'true' } .nav-controls = form_tag labels_filter_path, method: :get do = hidden_field_tag :subscribed, params[:subscribed] diff --git a/app/views/shared/nav/_sidebar_menu.html.haml b/app/views/shared/nav/_sidebar_menu.html.haml index 903d2d077ba..3f71368aff3 100644 --- a/app/views/shared/nav/_sidebar_menu.html.haml +++ b/app/views/shared/nav/_sidebar_menu.html.haml @@ -16,15 +16,4 @@ %span.badge.badge-pill.count{ **sidebar_menu.pill_html_options } = number_with_delimiter(sidebar_menu.pill_count) - %ul.sidebar-sub-level-items{ class: ('is-fly-out-only' unless sidebar_menu.has_renderable_items?) } - = nav_link(**sidebar_menu.all_active_routes, html_options: { class: 'fly-out-top-item' } ) do - %span.fly-out-top-item-container - %strong.fly-out-top-item-name - = sidebar_menu.title - - if sidebar_menu.has_pill? - %span.badge.badge-pill.count.fly-out-badge{ **sidebar_menu.pill_html_options } - = number_with_delimiter(sidebar_menu.pill_count) - - - if sidebar_menu.has_renderable_items? - %li.divider.fly-out-top-item - = render partial: 'shared/nav/sidebar_menu_item', collection: sidebar_menu.renderable_items + = render partial: 'shared/nav/sidebar_submenu', locals: { sidebar_menu: sidebar_menu } diff --git a/app/views/shared/nav/_sidebar_submenu.html.haml b/app/views/shared/nav/_sidebar_submenu.html.haml new file mode 100644 index 00000000000..750e6c9ee57 --- /dev/null +++ b/app/views/shared/nav/_sidebar_submenu.html.haml @@ -0,0 +1,12 @@ +%ul.sidebar-sub-level-items{ class: ('is-fly-out-only' unless sidebar_menu.has_renderable_items?) } + = nav_link(**sidebar_menu.all_active_routes, html_options: { class: 'fly-out-top-item' } ) do + %span.fly-out-top-item-container + %strong.fly-out-top-item-name + = sidebar_menu.title + - if sidebar_menu.has_pill? + %span.badge.badge-pill.count.fly-out-badge{ **sidebar_menu.pill_html_options } + = number_with_delimiter(sidebar_menu.pill_count) + + - if sidebar_menu.has_renderable_items? + %li.divider.fly-out-top-item + = render partial: 'shared/nav/sidebar_menu_item', collection: sidebar_menu.renderable_items diff --git a/app/views/shared/projects/_topics.html.haml b/app/views/shared/projects/_topics.html.haml index a7429483da1..e303f05c5df 100644 --- a/app/views/shared/projects/_topics.html.haml +++ b/app/views/shared/projects/_topics.html.haml @@ -8,7 +8,7 @@ = sprite_icon('tag', css_class: 'icon gl-relative gl-mr-2') - project.topics_to_show.each do |topic| - - explore_project_topic_path = explore_projects_path(topic: topic) + - explore_project_topic_path = topic_explore_projects_path(topic_name: topic) - if topic.length > max_project_topic_length %a{ class: "#{ project_topics_classes } str-truncated-30 has-tooltip", data: { container: "body" }, title: topic, href: explore_project_topic_path, itemprop: 'keywords' } = truncate(topic, length: max_project_topic_length) @@ -21,7 +21,7 @@ - content = capture do %span.gl-display-inline-flex.gl-flex-wrap - project.topics_not_shown.each do |topic| - - explore_project_topic_path = explore_projects_path(topic: topic) + - explore_project_topic_path = topic_explore_projects_path(topic_name: topic) - if topic.length > max_project_topic_length %a{ class: "#{ project_topics_classes } gl-mb-3 str-truncated has-tooltip", data: { container: "body" }, title: topic, href: explore_project_topic_path, itemprop: 'keywords' } = truncate(topic, length: max_project_topic_length) diff --git a/app/views/shared/runners/_shared_runners_description.html.haml b/app/views/shared/runners/_shared_runners_description.html.haml index a276f725576..e2b57a7fd73 100644 --- a/app/views/shared/runners/_shared_runners_description.html.haml +++ b/app/views/shared/runners/_shared_runners_description.html.haml @@ -1,4 +1,5 @@ -- link = link_to _('MaxBuilds'), 'https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runnersmachine-section', target: '_blank' +-# "MaxBuilds" is a runner configuration keyword so it must not be translated. +- link = link_to 'MaxBuilds', 'https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runnersmachine-section', target: '_blank' %h4 = _('Shared runners') diff --git a/app/views/shared/topics/_list.html.haml b/app/views/shared/topics/_list.html.haml new file mode 100644 index 00000000000..ddf47261d42 --- /dev/null +++ b/app/views/shared/topics/_list.html.haml @@ -0,0 +1,9 @@ +- remote = local_assigns.fetch(:remote, false) + +- if @topics.empty? + = render 'shared/empty_states/topics' +- else + .row.gl-mt-3 + = render partial: 'shared/topics/topic', collection: @topics + + = paginate_collection @topics, remote: remote diff --git a/app/views/shared/topics/_topic.html.haml b/app/views/shared/topics/_topic.html.haml new file mode 100644 index 00000000000..a47d4495777 --- /dev/null +++ b/app/views/shared/topics/_topic.html.haml @@ -0,0 +1,16 @@ +- max_topic_name_length = 30 +- detail_page_link = topic_explore_projects_path(topic_name: topic.name) + +.col-lg-3.col-md-4.col-sm-12 + .gl-card.gl-mb-5 + .gl-card-body.gl-display-flex.gl-align-items-center + .avatar-container.rect-avatar.s40.gl-flex-shrink-0 + = link_to detail_page_link do + = topic_icon(topic, class: "avatar s40") + = link_to detail_page_link do + - if topic.name.length > max_topic_name_length + %h5.str-truncated.has-tooltip{ title: topic.name } + = truncate(topic.name, length: max_topic_name_length) + - else + %h5 + = topic.name diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml index 794418b8336..f1eef5d7f0f 100644 --- a/app/views/shared/web_hooks/_index.html.haml +++ b/app/views/shared/web_hooks/_index.html.haml @@ -3,7 +3,7 @@ .card-header %h5 = hook_class.underscore.humanize.titleize.pluralize - (#{hooks.load.size}) + (#{hooks.size}) - if hooks.any? %ul.content-list diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml index 2e6d335a98d..0d904de9372 100644 --- a/app/views/users/_overview.html.haml +++ b/app/views/users/_overview.html.haml @@ -2,13 +2,28 @@ .row.d-none.d-sm-flex .col-12.calendar-block.gl-my-3 - .user-calendar.light{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: Time.zone.utc_offset } } + .user-calendar.light{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: local_time_instance(@user.timezone).now.utc_offset } } .gl-spinner.gl-spinner-md.gl-my-8 .user-calendar-error.invisible = _('There was an error loading users activity calendar.') %a.js-retry-load{ href: '#' } = s_('UserProfile|Retry') .user-calendar-activities +- if @user.user_readme + .row.justify-content-center + .col-12.col-md-10.col-lg-8.gl-my-6 + .gl-display-flex + %ol.breadcrumb.gl-breadcrumb-list.gl-mb-4 + %li.breadcrumb-item.gl-breadcrumb-item + = link_to @user.username, project_path(@user.user_project) + %span.gl-breadcrumb-separator + = sprite_icon("chevron-right", size: 16) + %li.breadcrumb-item.gl-breadcrumb-item + = link_to @user.user_readme.path, @user.user_project.readme_url + - if current_user == @user + .gl-ml-auto + = link_to _('Edit'), edit_blob_path(@user.user_project, @user.user_project.default_branch, @user.user_readme.path) + = render 'projects/blob/viewer', viewer: @user.user_readme.rich_viewer, load_async: false .row %div{ class: activity_pane_class } - if can?(current_user, :read_cross_project) diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml index 2f44a57c388..cffeb55597f 100644 --- a/app/views/users/calendar_activities.html.haml +++ b/app/views/users/calendar_activities.html.haml @@ -5,9 +5,9 @@ %ul.bordered-list - @events.sort_by(&:created_at).each do |event| %li - %span.light + %span.light.js-localtime{ :data => { :datetime => event.created_at.utc.strftime('%Y-%m-%dT%H:%M:%SZ'), :toggle => 'tooltip', :placement => 'top' } } = sprite_icon('clock', css_class: 'gl-vertical-align-text-bottom') - = event.created_at.to_time.in_time_zone.strftime('%-I:%M%P') + = event.created_at.to_time.in_time_zone(@user.timezone).strftime('%-I:%M%P') - if event.visible_to_user?(current_user) - if event.push_action? #{event.action_name} #{event.ref_type} diff --git a/app/views/users/terms/index.html.haml b/app/views/users/terms/index.html.haml index 771ee693120..92095e78f69 100644 --- a/app/views/users/terms/index.html.haml +++ b/app/views/users/terms/index.html.haml @@ -1,20 +1,23 @@ - redirect_params = { redirect: @redirect } if @redirect - accept_term_link = accept_term_path(@term, redirect_params) -.card-body.rendered-terms{ data: { qa_selector: 'terms_content' } } - = markdown_field(@term, :terms) -- if current_user - = render_if_exists 'devise/shared/form_phone_verification', accept_term_link: accept_term_link, inline: true - .card-footer.footer-block.clearfix - - if can?(current_user, :accept_terms, @term) - .float-right - = button_to accept_term_link, class: 'gl-button btn btn-confirm gl-ml-3', data: { qa_selector: 'accept_terms_button' } do - = _('Accept terms') - - else - .float-right - = link_to root_path, class: 'gl-button btn btn-confirm gl-ml-3' do - = _('Continue') - - if can?(current_user, :decline_terms, @term) - .float-right - = button_to decline_term_path(@term, redirect_params), class: 'gl-button btn btn-default gl-ml-3' do - = _('Decline and sign out') +- if Feature.enabled?(:terms_of_service_vue, current_user, default_enabled: :yaml) + #js-terms-of-service{ data: { terms_data: terms_data(@term, @redirect) } } +- else + .card-body.rendered-terms{ data: { qa_selector: 'terms_content' } } + = markdown_field(@term, :terms) + - if current_user + = render_if_exists 'devise/shared/form_phone_verification', accept_term_link: accept_term_link, inline: true + .card-footer.footer-block.clearfix + - if can?(current_user, :accept_terms, @term) + .float-right + = button_to accept_term_link, class: 'gl-button btn btn-confirm gl-ml-3', data: { qa_selector: 'accept_terms_button' } do + = _('Accept terms') + - else + .float-right + = link_to root_path, class: 'gl-button btn btn-confirm gl-ml-3' do + = _('Continue') + - if can?(current_user, :decline_terms, @term) + .float-right + = button_to decline_term_path(@term, redirect_params), class: 'gl-button btn btn-default gl-ml-3' do + = _('Decline and sign out') diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index c7ce2eb8d00..699744b355c 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -246,6 +246,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: cronjob:clusters_integrations_check_prometheus_health + :worker_name: Clusters::Integrations::CheckPrometheusHealthWorker + :feature_category: :incident_management + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:container_expiration_policy :worker_name: ContainerExpirationPolicyWorker :feature_category: :container_registry @@ -347,13 +356,22 @@ :tags: [] - :name: cronjob:issue_due_scheduler :worker_name: IssueDueSchedulerWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: :tags: [] +- :name: cronjob:issues_reschedule_stuck_issue_rebalances + :worker_name: Issues::RescheduleStuckIssueRebalancesWorker + :feature_category: :team_planning + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:jira_import_stuck_jira_import_jobs :worker_name: Gitlab::JiraImport::StuckJiraImportJobsWorker :feature_category: :importers @@ -363,6 +381,15 @@ :weight: 1 :idempotent: :tags: [] +- :name: cronjob:loose_foreign_keys_cleanup + :worker_name: LooseForeignKeys::CleanupWorker + :feature_category: :sharding + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:member_invitation_reminder_emails :worker_name: MemberInvitationReminderEmailsWorker :feature_category: :subgroups @@ -696,6 +723,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: deployment:deployments_archive_in_project + :worker_name: Deployments::ArchiveInProjectWorker + :feature_category: :continuous_delivery + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 3 + :idempotent: true + :tags: [] - :name: deployment:deployments_drop_older_deployments :worker_name: Deployments::DropOlderDeploymentsWorker :feature_category: :continuous_delivery @@ -1069,15 +1105,6 @@ :idempotent: :tags: - :needs_own_queue -- :name: incident_management:clusters_applications_check_prometheus_health - :worker_name: Clusters::Applications::CheckPrometheusHealthWorker - :feature_category: :incident_management - :has_external_dependencies: true - :urgency: :low - :resource_boundary: :unknown - :weight: 2 - :idempotent: true - :tags: [] - :name: incident_management:incident_management_add_severity_system_note :worker_name: IncidentManagement::AddSeveritySystemNoteWorker :feature_category: :incident_management @@ -1251,7 +1278,7 @@ :tags: [] - :name: mail_scheduler:mail_scheduler_issue_due :worker_name: MailScheduler::IssueDueWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -1260,7 +1287,7 @@ :tags: [] - :name: mail_scheduler:mail_scheduler_notification_service :worker_name: MailScheduler::NotificationServiceWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :low :resource_boundary: :cpu @@ -1737,7 +1764,7 @@ :tags: [] - :name: todos_destroyer:todos_destroyer_confidential_issue :worker_name: TodosDestroyer::ConfidentialIssueWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -1746,7 +1773,7 @@ :tags: [] - :name: todos_destroyer:todos_destroyer_destroyed_designs :worker_name: TodosDestroyer::DestroyedDesignsWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -1755,7 +1782,7 @@ :tags: [] - :name: todos_destroyer:todos_destroyer_destroyed_issuable :worker_name: TodosDestroyer::DestroyedIssuableWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -1764,7 +1791,7 @@ :tags: [] - :name: todos_destroyer:todos_destroyer_entity_leave :worker_name: TodosDestroyer::EntityLeaveWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -1773,7 +1800,7 @@ :tags: [] - :name: todos_destroyer:todos_destroyer_group_private :worker_name: TodosDestroyer::GroupPrivateWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -1782,7 +1809,7 @@ :tags: [] - :name: todos_destroyer:todos_destroyer_private_features :worker_name: TodosDestroyer::PrivateFeaturesWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -1791,7 +1818,7 @@ :tags: [] - :name: todos_destroyer:todos_destroyer_project_private :worker_name: TodosDestroyer::ProjectPrivateWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -2052,7 +2079,7 @@ :tags: [] - :name: email_receiver :worker_name: EmailReceiverWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :high :resource_boundary: :unknown @@ -2116,7 +2143,7 @@ :tags: [] - :name: export_csv :worker_name: ExportCsvWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :low :resource_boundary: :cpu @@ -2206,13 +2233,22 @@ :tags: [] - :name: import_issues_csv :worker_name: ImportIssuesCsvWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 2 :idempotent: true :tags: [] +- :name: integrations_create_external_cross_reference + :worker_name: Integrations::CreateExternalCrossReferenceWorker + :feature_category: :integrations + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: invalid_gpg_signature_update :worker_name: InvalidGpgSignatureUpdateWorker :feature_category: :source_code_management @@ -2233,7 +2269,7 @@ :tags: [] - :name: issuable_export_csv :worker_name: IssuableExportCsvWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :low :resource_boundary: :cpu @@ -2242,7 +2278,7 @@ :tags: [] - :name: issuable_label_links_destroy :worker_name: Issuable::LabelLinksDestroyWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -2251,7 +2287,7 @@ :tags: [] - :name: issuables_clear_groups_issue_counter :worker_name: Issuables::ClearGroupsIssueCounterWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -2260,7 +2296,7 @@ :tags: [] - :name: issue_placement :worker_name: IssuePlacementWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :high :resource_boundary: :cpu @@ -2269,7 +2305,25 @@ :tags: [] - :name: issue_rebalancing :worker_name: IssueRebalancingWorker - :feature_category: :issue_tracking + :feature_category: :team_planning + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] +- :name: issues_placement + :worker_name: Issues::PlacementWorker + :feature_category: :team_planning + :has_external_dependencies: + :urgency: :high + :resource_boundary: :cpu + :weight: 2 + :idempotent: true + :tags: [] +- :name: issues_rebalancing + :worker_name: Issues::RebalancingWorker + :feature_category: :team_planning :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -2375,6 +2429,15 @@ :weight: 1 :idempotent: :tags: [] +- :name: namespaces_invite_team_email + :worker_name: Namespaces::InviteTeamEmailWorker + :feature_category: :experimentation_activation + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: namespaces_onboarding_issue_created :worker_name: Namespaces::OnboardingIssueCreatedWorker :feature_category: :onboarding @@ -2413,7 +2476,7 @@ :tags: [] - :name: new_issue :worker_name: NewIssueWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :high :resource_boundary: :cpu @@ -2431,7 +2494,7 @@ :tags: [] - :name: new_note :worker_name: NewNoteWorker - :feature_category: :issue_tracking + :feature_category: :team_planning :has_external_dependencies: :urgency: :high :resource_boundary: :cpu @@ -2799,6 +2862,15 @@ :weight: 1 :idempotent: :tags: [] +- :name: tasks_to_be_done_create + :worker_name: TasksToBeDone::CreateWorker + :feature_category: :onboarding + :has_external_dependencies: + :urgency: :low + :resource_boundary: :cpu + :weight: 1 + :idempotent: true + :tags: [] - :name: update_external_pull_requests :worker_name: UpdateExternalPullRequestsWorker :feature_category: :source_code_management diff --git a/app/workers/authorized_project_update/project_recalculate_worker.rb b/app/workers/authorized_project_update/project_recalculate_worker.rb index 3d073f18622..1b5faee0b6f 100644 --- a/app/workers/authorized_project_update/project_recalculate_worker.rb +++ b/app/workers/authorized_project_update/project_recalculate_worker.rb @@ -7,6 +7,8 @@ module AuthorizedProjectUpdate data_consistency :always include Gitlab::ExclusiveLeaseHelpers + prepend WaitableWorker + feature_category :authentication_and_authorization urgency :high queue_namespace :authorized_project_update diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb index 46fe6c7f7ce..afe14369d43 100644 --- a/app/workers/authorized_projects_worker.rb +++ b/app/workers/authorized_projects_worker.rb @@ -25,11 +25,9 @@ class AuthorizedProjectsWorker end end - # rubocop: disable CodeReuse/ActiveRecord def perform(user_id) - user = User.find_by(id: user_id) + user = User.find_by_id(user_id) user&.refresh_authorized_projects(source: self.class.name) end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/build_hooks_worker.rb b/app/workers/build_hooks_worker.rb index a0d1d9dca45..78244e0941e 100644 --- a/app/workers/build_hooks_worker.rb +++ b/app/workers/build_hooks_worker.rb @@ -14,7 +14,7 @@ class BuildHooksWorker # rubocop:disable Scalability/IdempotentWorker # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) Ci::Build.includes({ runner: :tags }) - .find_by(id: build_id) + .find_by_id(build_id) .try(:execute_hooks) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/workers/build_queue_worker.rb b/app/workers/build_queue_worker.rb index 4ab08bbd7fe..af1136de94c 100644 --- a/app/workers/build_queue_worker.rb +++ b/app/workers/build_queue_worker.rb @@ -12,11 +12,9 @@ class BuildQueueWorker # rubocop:disable Scalability/IdempotentWorker worker_resource_boundary :cpu data_consistency :sticky - # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) - Ci::Build.find_by(id: build_id).try do |build| + Ci::Build.find_by_id(build_id).try do |build| Ci::UpdateBuildQueueService.new.tick(build) end end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb index ce39ac946a9..114bced0b22 100644 --- a/app/workers/build_success_worker.rb +++ b/app/workers/build_success_worker.rb @@ -11,13 +11,11 @@ class BuildSuccessWorker # rubocop:disable Scalability/IdempotentWorker queue_namespace :pipeline_processing urgency :high - # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) - Ci::Build.find_by(id: build_id).try do |build| + Ci::Build.find_by_id(build_id).try do |build| stop_environment(build) if build.stops_environment? end end - # rubocop: enable CodeReuse/ActiveRecord private diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb index 2a2e94cc6f1..23d8a1ec29d 100644 --- a/app/workers/chat_notification_worker.rb +++ b/app/workers/chat_notification_worker.rb @@ -16,9 +16,8 @@ class ChatNotificationWorker # rubocop:disable Scalability/IdempotentWorker RESCHEDULE_INTERVAL = 2.seconds RESCHEDULE_TIMEOUT = 5.minutes - # rubocop: disable CodeReuse/ActiveRecord def perform(build_id, reschedule_count = 0) - Ci::Build.find_by(id: build_id).try do |build| + Ci::Build.find_by_id(build_id).try do |build| send_response(build) end rescue Gitlab::Chat::Output::MissingBuildSectionError @@ -30,7 +29,6 @@ class ChatNotificationWorker # rubocop:disable Scalability/IdempotentWorker # the job instead of producing an error. self.class.perform_in(RESCHEDULE_INTERVAL, build_id, reschedule_count + 1) end - # rubocop: enable CodeReuse/ActiveRecord def send_response(build) Gitlab::Chat::Responder.responder_for(build).try do |responder| diff --git a/app/workers/ci/archive_trace_worker.rb b/app/workers/ci/archive_trace_worker.rb index 503cfc07c25..5a22a5c74ee 100644 --- a/app/workers/ci/archive_trace_worker.rb +++ b/app/workers/ci/archive_trace_worker.rb @@ -9,12 +9,10 @@ module Ci sidekiq_options retry: 3 include PipelineBackgroundQueue - # rubocop: disable CodeReuse/ActiveRecord def perform(job_id) - Ci::Build.without_archived_trace.find_by(id: job_id).try do |job| + Ci::Build.without_archived_trace.find_by_id(job_id).try do |job| Ci::ArchiveTraceService.new.execute(job, worker_name: self.class.name) end end - # rubocop: enable CodeReuse/ActiveRecord end end diff --git a/app/workers/ci/build_finished_worker.rb b/app/workers/ci/build_finished_worker.rb index f047ba8fde5..aa12bdb009e 100644 --- a/app/workers/ci/build_finished_worker.rb +++ b/app/workers/ci/build_finished_worker.rb @@ -16,7 +16,7 @@ module Ci ARCHIVE_TRACES_IN = 2.minutes.freeze def perform(build_id) - return unless build = Ci::Build.find_by(id: build_id) # rubocop: disable CodeReuse/ActiveRecord + return unless build = Ci::Build.find_by_id(build_id) return unless build.project return if build.project.pending_delete? diff --git a/app/workers/ci/build_trace_chunk_flush_worker.rb b/app/workers/ci/build_trace_chunk_flush_worker.rb index 79881ec84fd..62fc3efd4f0 100644 --- a/app/workers/ci/build_trace_chunk_flush_worker.rb +++ b/app/workers/ci/build_trace_chunk_flush_worker.rb @@ -13,12 +13,10 @@ module Ci idempotent! - # rubocop: disable CodeReuse/ActiveRecord def perform(id) - ::Ci::BuildTraceChunk.find_by(id: id).try do |chunk| + ::Ci::BuildTraceChunk.find_by_id(id).try do |chunk| chunk.persist_data! end end - # rubocop: enable CodeReuse/ActiveRecord end end diff --git a/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb b/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb index 160947fb38e..590514424bb 100644 --- a/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb +++ b/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb @@ -15,9 +15,12 @@ module Ci ::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| break unless pipeline.has_archive_artifacts? - ::Ci::UnlockArtifactsService + results = ::Ci::UnlockArtifactsService .new(pipeline.project, pipeline.user) .execute(pipeline.ci_ref, pipeline) + + log_extra_metadata_on_done(:unlocked_pipelines, results[:unlocked_pipelines]) + log_extra_metadata_on_done(:unlocked_job_artifacts, results[:unlocked_job_artifacts]) end end end diff --git a/app/workers/ci/ref_delete_unlock_artifacts_worker.rb b/app/workers/ci/ref_delete_unlock_artifacts_worker.rb index 0c217644cc4..aeadf111bfb 100644 --- a/app/workers/ci/ref_delete_unlock_artifacts_worker.rb +++ b/app/workers/ci/ref_delete_unlock_artifacts_worker.rb @@ -15,9 +15,12 @@ module Ci ::Project.find_by_id(project_id).try do |project| ::User.find_by_id(user_id).try do |user| project.ci_refs.find_by_ref_path(ref_path).try do |ci_ref| - ::Ci::UnlockArtifactsService + results = ::Ci::UnlockArtifactsService .new(project, user) .execute(ci_ref) + + log_extra_metadata_on_done(:unlocked_pipelines, results[:unlocked_pipelines]) + log_extra_metadata_on_done(:unlocked_job_artifacts, results[:unlocked_job_artifacts]) end end end diff --git a/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb b/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb index 98b4f4ad73a..533cb7c425f 100644 --- a/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb +++ b/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb @@ -21,7 +21,7 @@ module Ci # Therefore, we can deduplicate the sidekiq jobs until the on-going # assignment process has been finished. idempotent! - deduplicate :until_executed + deduplicate :until_executed, if_deduplicated: :reschedule_once def perform(resource_group_id) ::Ci::ResourceGroup.find_by_id(resource_group_id).try do |resource_group| diff --git a/app/workers/cluster_update_app_worker.rb b/app/workers/cluster_update_app_worker.rb index cd2b2e38ea4..97fdec02ba4 100644 --- a/app/workers/cluster_update_app_worker.rb +++ b/app/workers/cluster_update_app_worker.rb @@ -26,16 +26,14 @@ class ClusterUpdateAppWorker # rubocop:disable Scalability/IdempotentWorker private - # rubocop: disable CodeReuse/ActiveRecord def execute(app_name, app_id, project_id, scheduled_time) - project = Project.find_by(id: project_id) + project = Project.find_by_id(project_id) return unless project find_application(app_name, app_id) do |app| update_prometheus(app, scheduled_time, project) end end - # rubocop: enable CodeReuse/ActiveRecord def update_prometheus(app, scheduled_time, project) return unless app.managed_prometheus? diff --git a/app/workers/clusters/applications/check_prometheus_health_worker.rb b/app/workers/clusters/applications/check_prometheus_health_worker.rb deleted file mode 100644 index 4f85c5c5b7a..00000000000 --- a/app/workers/clusters/applications/check_prometheus_health_worker.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class CheckPrometheusHealthWorker - include ApplicationWorker - - data_consistency :always - - # rubocop:disable Scalability/CronWorkerContext - # This worker does not perform work scoped to a context - include CronjobQueue - # rubocop:enable Scalability/CronWorkerContext - - queue_namespace :incident_management - feature_category :incident_management - urgency :low - - idempotent! - worker_has_external_dependencies! - - def perform - demo_project_ids = Gitlab::Monitor::DemoProjects.primary_keys - - clusters = Clusters::Cluster.with_application_prometheus - .with_project_http_integrations(demo_project_ids) - - # Move to a seperate worker with scoped context if expanded to do work on customer projects - clusters.each { |cluster| Clusters::Applications::PrometheusHealthCheckService.new(cluster).execute } - end - end - end -end diff --git a/app/workers/clusters/integrations/check_prometheus_health_worker.rb b/app/workers/clusters/integrations/check_prometheus_health_worker.rb new file mode 100644 index 00000000000..0c0d86e975c --- /dev/null +++ b/app/workers/clusters/integrations/check_prometheus_health_worker.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Clusters + module Integrations + class CheckPrometheusHealthWorker + include ApplicationWorker + + data_consistency :always + + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context + include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext + + feature_category :incident_management + urgency :low + + idempotent! + worker_has_external_dependencies! + + def perform + demo_project_ids = Gitlab::Monitor::DemoProjects.primary_keys + + clusters = Clusters::Cluster.with_integration_prometheus + .with_project_http_integrations(demo_project_ids) + + # Move to a seperate worker with scoped context if expanded to do work on customer projects + clusters.each { |cluster| Clusters::Integrations::PrometheusHealthCheckService.new(cluster).execute } + end + end + end +end diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb index 3399a4f9b57..03a0b5fae00 100644 --- a/app/workers/concerns/application_worker.rb +++ b/app/workers/concerns/application_worker.rb @@ -14,6 +14,7 @@ module ApplicationWorker LOGGING_EXTRA_KEY = 'extra' DEFAULT_DELAY_INTERVAL = 1 + SAFE_PUSH_BULK_LIMIT = 1000 included do set_queue @@ -54,6 +55,12 @@ module ApplicationWorker subclass.after_set_class_attribute { subclass.set_queue } end + def with_status + status_from_class = self.sidekiq_options_hash['status_expiration'] + + set(status_expiration: status_from_class || Gitlab::SidekiqStatus::DEFAULT_EXPIRATION) + end + def generated_queue_name Gitlab::SidekiqConfig::WorkerRouter.queue_name_from_worker_name(self) end @@ -130,29 +137,62 @@ module ApplicationWorker end end + def log_bulk_perform_async? + @log_bulk_perform_async + end + + def log_bulk_perform_async! + @log_bulk_perform_async = true + end + def queue_size Sidekiq::Queue.new(queue).size end def bulk_perform_async(args_list) - Sidekiq::Client.push_bulk('class' => self, 'args' => args_list) + if log_bulk_perform_async? + Sidekiq.logger.info('class' => self.name, 'args_list' => args_list, 'args_list_count' => args_list.length, 'message' => 'Inserting multiple jobs') + end + + do_push_bulk(args_list).tap do |job_ids| + if log_bulk_perform_async? + Sidekiq.logger.info('class' => self.name, 'jid_list' => job_ids, 'jid_list_count' => job_ids.length, 'message' => 'Completed JID insertion') + end + end end def bulk_perform_in(delay, args_list, batch_size: nil, batch_delay: nil) now = Time.now.to_i - schedule = now + delay.to_i + base_schedule_at = now + delay.to_i - if schedule <= now - raise ArgumentError, _('The schedule time must be in the future!') + if base_schedule_at <= now + raise ArgumentError, 'The schedule time must be in the future!' end + schedule_at = base_schedule_at + if batch_size && batch_delay - args_list.each_slice(batch_size.to_i).with_index do |args_batch, idx| - batch_schedule = schedule + idx * batch_delay.to_i - Sidekiq::Client.push_bulk('class' => self, 'args' => args_batch, 'at' => batch_schedule) + batch_size = batch_size.to_i + batch_delay = batch_delay.to_i + + raise ArgumentError, 'batch_size should be greater than 0' unless batch_size > 0 + raise ArgumentError, 'batch_delay should be greater than 0' unless batch_delay > 0 + + # build an array of schedules corresponding to each item in `args_list` + bulk_schedule_at = Array.new(args_list.size) do |index| + batch_number = index / batch_size + base_schedule_at + (batch_number * batch_delay) + end + + schedule_at = bulk_schedule_at + end + + if Feature.enabled?(:sidekiq_push_bulk_in_batches) + in_safe_limit_batches(args_list, schedule_at) do |args_batch, schedule_at_for_batch| + Sidekiq::Client.push_bulk('class' => self, 'args' => args_batch, 'at' => schedule_at_for_batch) end else - Sidekiq::Client.push_bulk('class' => self, 'args' => args_list, 'at' => schedule) + Sidekiq::Client.push_bulk('class' => self, 'args' => args_list, 'at' => schedule_at) end end @@ -161,5 +201,44 @@ module ApplicationWorker def delay_interval DEFAULT_DELAY_INTERVAL.seconds end + + private + + def do_push_bulk(args_list) + if Feature.enabled?(:sidekiq_push_bulk_in_batches) + in_safe_limit_batches(args_list) do |args_batch, _| + Sidekiq::Client.push_bulk('class' => self, 'args' => args_batch) + end + else + Sidekiq::Client.push_bulk('class' => self, 'args' => args_list) + end + end + + def in_safe_limit_batches(args_list, schedule_at = nil, safe_limit = SAFE_PUSH_BULK_LIMIT) + # `schedule_at` could be one of + # - nil. + # - a single Numeric that represents time, like `30.minutes.from_now.to_i`. + # - an array, where each element is a Numeric that reprsents time. + # - Each element in this array would correspond to the time at which + # - the job in `args_list` at the corresponding index needs to be scheduled. + + # In the case where `schedule_at` is an array of Numeric, it needs to be sliced + # in the same manner as the `args_list`, with each slice containing `safe_limit` + # number of elements. + schedule_at = schedule_at.each_slice(safe_limit).to_a if schedule_at.is_a?(Array) + + args_list.each_slice(safe_limit).with_index.flat_map do |args_batch, index| + schedule_at_for_batch = process_schedule_at_for_batch(schedule_at, index) + + yield(args_batch, schedule_at_for_batch) + end + end + + def process_schedule_at_for_batch(schedule_at, index) + return unless schedule_at + return schedule_at[index] if schedule_at.is_a?(Array) && schedule_at.all?(Array) + + schedule_at + end end end diff --git a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb index eb1af0869bd..0a43a0fc4d2 100644 --- a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb +++ b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb @@ -8,9 +8,8 @@ module Gitlab # project_id - The ID of the GitLab project to import the note into. # hash - A Hash containing the details of the GitHub object to import. # notify_key - The Redis key to notify upon completion, if any. - # rubocop: disable CodeReuse/ActiveRecord def perform(project_id, hash, notify_key = nil) - project = Project.find_by(id: project_id) + project = Project.find_by_id(project_id) return notify_waiter(notify_key) unless project @@ -25,7 +24,6 @@ module Gitlab .perform_in(client.rate_limit_resets_in, project.id, hash, notify_key) end end - # rubocop: enable CodeReuse/ActiveRecord def try_import(*args) import(*args) diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb index d7b4578af63..225716f6bf3 100644 --- a/app/workers/concerns/gitlab/github_import/stage_methods.rb +++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb @@ -33,13 +33,13 @@ module Gitlab self.class.perform_in(client.rate_limit_resets_in, project.id) end - # rubocop: disable CodeReuse/ActiveRecord def find_project(id) # If the project has been marked as failed we want to bail out # automatically. - Project.joins_import_state.where(import_state: { status: :started }).find_by(id: id) + # rubocop: disable CodeReuse/ActiveRecord + Project.joins_import_state.where(import_state: { status: :started }).find_by_id(id) + # rubocop: enable CodeReuse/ActiveRecord end - # rubocop: enable CodeReuse/ActiveRecord def abort_on_failure false diff --git a/app/workers/concerns/gitlab/jira_import/import_worker.rb b/app/workers/concerns/gitlab/jira_import/import_worker.rb index 107b6e2e9be..d18b9ff023b 100644 --- a/app/workers/concerns/gitlab/jira_import/import_worker.rb +++ b/app/workers/concerns/gitlab/jira_import/import_worker.rb @@ -14,7 +14,7 @@ module Gitlab end def perform(project_id) - project = Project.find_by(id: project_id) # rubocop: disable CodeReuse/ActiveRecord + project = Project.find_by_id(project_id) return unless can_import?(project) diff --git a/app/workers/concerns/limited_capacity/worker.rb b/app/workers/concerns/limited_capacity/worker.rb index b4cdfda680f..bcedb4efcc0 100644 --- a/app/workers/concerns/limited_capacity/worker.rb +++ b/app/workers/concerns/limited_capacity/worker.rb @@ -47,7 +47,7 @@ module LimitedCapacity # would be occupied by a job that will be performed in the distant future. # We let the cron worker enqueue new jobs, this could be seen as our retry and # back off mechanism because the job might fail again if executed immediately. - sidekiq_options retry: 0 + sidekiq_options retry: 0, status_expiration: Gitlab::SidekiqStatus::DEFAULT_EXPIRATION deduplicate :none end diff --git a/app/workers/concerns/new_issuable.rb b/app/workers/concerns/new_issuable.rb index 482a74f49f7..d761f023cad 100644 --- a/app/workers/concerns/new_issuable.rb +++ b/app/workers/concerns/new_issuable.rb @@ -10,21 +10,17 @@ module NewIssuable user && issuable end - # rubocop: disable CodeReuse/ActiveRecord def set_user(user_id) - @user = User.find_by(id: user_id) # rubocop:disable Gitlab/ModuleWithInstanceVariables + @user = User.find_by_id(user_id) # rubocop:disable Gitlab/ModuleWithInstanceVariables log_error(User, user_id) unless @user # rubocop:disable Gitlab/ModuleWithInstanceVariables end - # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord def set_issuable(issuable_id) - @issuable = issuable_class.find_by(id: issuable_id) # rubocop:disable Gitlab/ModuleWithInstanceVariables + @issuable = issuable_class.find_by_id(issuable_id) # rubocop:disable Gitlab/ModuleWithInstanceVariables log_error(issuable_class, issuable_id) unless @issuable # rubocop:disable Gitlab/ModuleWithInstanceVariables end - # rubocop: enable CodeReuse/ActiveRecord def log_error(record_class, record_id) Gitlab::AppLogger.error("#{self.class}: couldn't find #{record_class} with ID=#{record_id}, skipping job") diff --git a/app/workers/concerns/todos_destroyer_queue.rb b/app/workers/concerns/todos_destroyer_queue.rb index 1bbccbfb1f9..1c31b64ad97 100644 --- a/app/workers/concerns/todos_destroyer_queue.rb +++ b/app/workers/concerns/todos_destroyer_queue.rb @@ -8,6 +8,6 @@ module TodosDestroyerQueue included do queue_namespace :todos_destroyer - feature_category :issue_tracking + feature_category :team_planning end end diff --git a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb index 69f5906f54c..7f7a77d0524 100644 --- a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb +++ b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb @@ -159,7 +159,10 @@ module ContainerExpirationPolicies return unless tags_count && cached_tags_count && tags_count != 0 - log_extra_metadata_on_done(:cleanup_tags_service_cache_hit_ratio, cached_tags_count / tags_count.to_f) + ratio = cached_tags_count / tags_count.to_f + ratio_as_percentage = (ratio * 100).round(2) + + log_extra_metadata_on_done(:cleanup_tags_service_cache_hit_ratio, ratio_as_percentage) end def log_truncate(result) diff --git a/app/workers/create_commit_signature_worker.rb b/app/workers/create_commit_signature_worker.rb index 01a2e109967..c35d2d3b33b 100644 --- a/app/workers/create_commit_signature_worker.rb +++ b/app/workers/create_commit_signature_worker.rb @@ -12,7 +12,6 @@ class CreateCommitSignatureWorker idempotent! loggable_arguments 0 - # rubocop: disable CodeReuse/ActiveRecord def perform(commit_shas, project_id) # Older versions of Git::BranchPushService may push a single commit ID on # the stack. We need this to be backwards compatible. @@ -20,7 +19,7 @@ class CreateCommitSignatureWorker return if commit_shas.empty? - project = Project.find_by(id: project_id) + project = Project.find_by_id(project_id) return unless project commits = project.commits_by(oids: commit_shas) @@ -44,5 +43,4 @@ class CreateCommitSignatureWorker Gitlab::AppLogger.error("Failed to create signature for commit #{commit.id}. Error: #{e.message}") end end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/database/drop_detached_partitions_worker.rb b/app/workers/database/drop_detached_partitions_worker.rb index 1e4dc20a0d2..126c46a338b 100644 --- a/app/workers/database/drop_detached_partitions_worker.rb +++ b/app/workers/database/drop_detached_partitions_worker.rb @@ -12,7 +12,7 @@ module Database def perform Gitlab::Database::Partitioning.drop_detached_partitions ensure - Gitlab::Database::Partitioning::PartitionMonitoring.new.report_metrics + Gitlab::Database::Partitioning.report_metrics end end end diff --git a/app/workers/database/partition_management_worker.rb b/app/workers/database/partition_management_worker.rb index 5a1f139dc29..ba1ad7a0e81 100644 --- a/app/workers/database/partition_management_worker.rb +++ b/app/workers/database/partition_management_worker.rb @@ -14,7 +14,7 @@ module Database def perform Gitlab::Database::Partitioning.sync_partitions ensure - Gitlab::Database::Partitioning::PartitionMonitoring.new.report_metrics + Gitlab::Database::Partitioning.report_metrics end end end diff --git a/app/workers/delete_container_repository_worker.rb b/app/workers/delete_container_repository_worker.rb index beeca559060..a4d6adc2195 100644 --- a/app/workers/delete_container_repository_worker.rb +++ b/app/workers/delete_container_repository_worker.rb @@ -15,10 +15,9 @@ class DeleteContainerRepositoryWorker # rubocop:disable Scalability/IdempotentWo attr_reader :container_repository - # rubocop: disable CodeReuse/ActiveRecord def perform(current_user_id, container_repository_id) - current_user = User.find_by(id: current_user_id) - @container_repository = ContainerRepository.find_by(id: container_repository_id) + current_user = User.find_by_id(current_user_id) + @container_repository = ContainerRepository.find_by_id(container_repository_id) project = container_repository&.project return unless current_user && container_repository && project @@ -29,7 +28,6 @@ class DeleteContainerRepositoryWorker # rubocop:disable Scalability/IdempotentWo Projects::ContainerRepository::DestroyService.new(project, current_user).execute(container_repository) end end - # rubocop: enable CodeReuse/ActiveRecord # For ExclusiveLeaseGuard concern def lease_key diff --git a/app/workers/dependency_proxy/image_ttl_group_policy_worker.rb b/app/workers/dependency_proxy/image_ttl_group_policy_worker.rb index fed469e6dc8..6a1de00ce80 100644 --- a/app/workers/dependency_proxy/image_ttl_group_policy_worker.rb +++ b/app/workers/dependency_proxy/image_ttl_group_policy_worker.rb @@ -13,9 +13,8 @@ module DependencyProxy def perform DependencyProxy::ImageTtlGroupPolicy.enabled.each do |policy| - # Technical Debt: change to read_before https://gitlab.com/gitlab-org/gitlab/-/issues/341536 - qualified_blobs = policy.group.dependency_proxy_blobs.active.updated_before(policy.ttl) - qualified_manifests = policy.group.dependency_proxy_manifests.active.updated_before(policy.ttl) + qualified_blobs = policy.group.dependency_proxy_blobs.active.read_before(policy.ttl) + qualified_manifests = policy.group.dependency_proxy_manifests.active.read_before(policy.ttl) enqueue_blob_cleanup_job if expire_artifacts(qualified_blobs, DependencyProxy::Blob) enqueue_manifest_cleanup_job if expire_artifacts(qualified_manifests, DependencyProxy::Manifest) diff --git a/app/workers/deployments/archive_in_project_worker.rb b/app/workers/deployments/archive_in_project_worker.rb new file mode 100644 index 00000000000..2de4cacbbd6 --- /dev/null +++ b/app/workers/deployments/archive_in_project_worker.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Deployments + class ArchiveInProjectWorker + include ApplicationWorker + + queue_namespace :deployment + feature_category :continuous_delivery + idempotent! + deduplicate :until_executed, including_scheduled: true + data_consistency :delayed + + def perform(project_id) + Project.find_by_id(project_id).try do |project| + Deployments::ArchiveInProjectService.new(project, nil).execute + end + end + end +end diff --git a/app/workers/detect_repository_languages_worker.rb b/app/workers/detect_repository_languages_worker.rb index 8c7ef6c9e32..702b83a3185 100644 --- a/app/workers/detect_repository_languages_worker.rb +++ b/app/workers/detect_repository_languages_worker.rb @@ -14,16 +14,14 @@ class DetectRepositoryLanguagesWorker # rubocop:disable Scalability/IdempotentWo attr_reader :project - # rubocop: disable CodeReuse/ActiveRecord def perform(project_id, user_id = nil) - @project = Project.find_by(id: project_id) + @project = Project.find_by_id(project_id) return unless project try_obtain_lease do ::Projects::DetectRepositoryLanguagesService.new(project).execute end end - # rubocop: enable CodeReuse/ActiveRecord private diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb index 51211834e06..321d492f0f3 100644 --- a/app/workers/email_receiver_worker.rb +++ b/app/workers/email_receiver_worker.rb @@ -7,7 +7,7 @@ class EmailReceiverWorker # rubocop:disable Scalability/IdempotentWorker sidekiq_options retry: 3 - feature_category :issue_tracking + feature_category :team_planning urgency :high weight 2 @@ -118,7 +118,9 @@ class EmailReceiverWorker # rubocop:disable Scalability/IdempotentWorker end if reason - EmailRejectionMailer.rejection(reason, raw, can_retry).deliver_later + receiver.mail.body = nil + + EmailRejectionMailer.rejection(reason, receiver.mail.encoded, can_retry).deliver_later end end end diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb index 0230a226567..d7bd8207f06 100644 --- a/app/workers/emails_on_push_worker.rb +++ b/app/workers/emails_on_push_worker.rb @@ -87,13 +87,14 @@ class EmailsOnPushWorker # rubocop:disable Scalability/IdempotentWorker private def send_email(recipient, project_id, options) - # Generating the body of this email can be expensive, so only do it once - @skip_premailer ||= email.present? - @email ||= Notify.repository_push_email(project_id, options) - - email.to = recipient - email.add_message_id - email.header[:skip_premailer] = true if skip_premailer - email.deliver_now + @email ||= Notify.repository_push_email(project_id, options).tap do |mail| + Premailer::Rails::Hook.perform(mail) + end + + current_email = email.dup + current_email.to = recipient + current_email.add_message_id + current_email.header[:skip_premailer] = true + current_email.deliver_now end end diff --git a/app/workers/expire_build_instance_artifacts_worker.rb b/app/workers/expire_build_instance_artifacts_worker.rb index 77b8f59e365..948e1a59b07 100644 --- a/app/workers/expire_build_instance_artifacts_worker.rb +++ b/app/workers/expire_build_instance_artifacts_worker.rb @@ -9,17 +9,17 @@ class ExpireBuildInstanceArtifactsWorker # rubocop:disable Scalability/Idempoten feature_category :build_artifacts - # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) + # rubocop: disable CodeReuse/ActiveRecord build = Ci::Build .with_expired_artifacts .reorder(nil) - .find_by(id: build_id) + .find_by_id(build_id) + # rubocop: enable CodeReuse/ActiveRecord return unless build&.project && !build.project.pending_delete Gitlab::AppLogger.info("Removing artifacts for build #{build.id}...") build.erase_erasable_artifacts! end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb index 7374f650546..3c5a7717d70 100644 --- a/app/workers/expire_job_cache_worker.rb +++ b/app/workers/expire_job_cache_worker.rb @@ -14,9 +14,8 @@ class ExpireJobCacheWorker # rubocop:disable Scalability/IdempotentWorker deduplicate :until_executing, including_scheduled: true idempotent! - # rubocop: disable CodeReuse/ActiveRecord def perform(job_id) - job = CommitStatus.preload(:pipeline, :project).find_by(id: job_id) + job = CommitStatus.preload(:pipeline, :project).find_by_id(job_id) # rubocop: disable CodeReuse/ActiveRecord return unless job pipeline = job.pipeline @@ -25,7 +24,6 @@ class ExpireJobCacheWorker # rubocop:disable Scalability/IdempotentWorker Gitlab::EtagCaching::Store.new.touch(project_job_path(project, job)) ExpirePipelineCacheWorker.perform_async(pipeline.id) end - # rubocop: enable CodeReuse/ActiveRecord private diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb index 07e6939d1c7..9a0c617da57 100644 --- a/app/workers/expire_pipeline_cache_worker.rb +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -17,13 +17,11 @@ class ExpirePipelineCacheWorker # Uncomment once https://gitlab.com/gitlab-org/gitlab/-/issues/325291 is resolved # idempotent! - # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) - pipeline = Ci::Pipeline.find_by(id: pipeline_id) + pipeline = Ci::Pipeline.find_by_id(pipeline_id) return unless pipeline Ci::ExpirePipelineCacheService.new.execute(pipeline) end - # rubocop: enable CodeReuse/ActiveRecord end # rubocop:enable Scalability/IdempotentWorker diff --git a/app/workers/export_csv_worker.rb b/app/workers/export_csv_worker.rb index 68feaa61cdd..c5feb02b2bd 100644 --- a/app/workers/export_csv_worker.rb +++ b/app/workers/export_csv_worker.rb @@ -7,7 +7,7 @@ class ExportCsvWorker # rubocop:disable Scalability/IdempotentWorker sidekiq_options retry: 3 - feature_category :issue_tracking + feature_category :team_planning worker_resource_boundary :cpu loggable_arguments 2 diff --git a/app/workers/gitlab/jira_import/import_issue_worker.rb b/app/workers/gitlab/jira_import/import_issue_worker.rb index eabe7328b92..3824cc1f3ef 100644 --- a/app/workers/gitlab/jira_import/import_issue_worker.rb +++ b/app/workers/gitlab/jira_import/import_issue_worker.rb @@ -54,7 +54,7 @@ module Gitlab label_link_attrs << build_label_attrs(issue_id, import_label_id.to_i) - Gitlab::Database.main.bulk_insert(LabelLink.table_name, label_link_attrs) # rubocop:disable Gitlab/BulkInsert + ApplicationRecord.legacy_bulk_insert(LabelLink.table_name, label_link_attrs) # rubocop:disable Gitlab/BulkInsert end def assign_issue(project_id, issue_id, assignee_ids) @@ -62,7 +62,7 @@ module Gitlab assignee_attrs = assignee_ids.map { |user_id| { issue_id: issue_id, user_id: user_id } } - Gitlab::Database.main.bulk_insert(IssueAssignee.table_name, assignee_attrs) # rubocop:disable Gitlab/BulkInsert + ApplicationRecord.legacy_bulk_insert(IssueAssignee.table_name, assignee_attrs) # rubocop:disable Gitlab/BulkInsert end def build_label_attrs(issue_id, label_id) diff --git a/app/workers/gitlab/jira_import/stage/start_import_worker.rb b/app/workers/gitlab/jira_import/stage/start_import_worker.rb index e0de3ee169e..3f6ad66e278 100644 --- a/app/workers/gitlab/jira_import/stage/start_import_worker.rb +++ b/app/workers/gitlab/jira_import/stage/start_import_worker.rb @@ -16,7 +16,7 @@ module Gitlab attr_reader :project def perform(project_id) - @project = Project.find_by(id: project_id) # rubocop: disable CodeReuse/ActiveRecord + @project = Project.find_by_id(project_id) return unless start_import diff --git a/app/workers/hashed_storage/project_migrate_worker.rb b/app/workers/hashed_storage/project_migrate_worker.rb index bcc80cc2a70..460aac3f2f2 100644 --- a/app/workers/hashed_storage/project_migrate_worker.rb +++ b/app/workers/hashed_storage/project_migrate_worker.rb @@ -16,12 +16,11 @@ module HashedStorage attr_reader :project_id - # rubocop: disable CodeReuse/ActiveRecord def perform(project_id, old_disk_path = nil) @project_id = project_id # we need to set this in order to create the lease_key try_obtain_lease do - project = Project.without_deleted.find_by(id: project_id) + project = Project.without_deleted.find_by_id(project_id) break unless project && project.storage_upgradable? old_disk_path ||= Storage::LegacyProject.new(project).disk_path @@ -29,6 +28,5 @@ module HashedStorage ::Projects::HashedStorage::MigrationService.new(project, old_disk_path, logger: logger).execute end end - # rubocop: enable CodeReuse/ActiveRecord end end diff --git a/app/workers/hashed_storage/project_rollback_worker.rb b/app/workers/hashed_storage/project_rollback_worker.rb index 07a7ab63718..91ea3dd9189 100644 --- a/app/workers/hashed_storage/project_rollback_worker.rb +++ b/app/workers/hashed_storage/project_rollback_worker.rb @@ -16,12 +16,11 @@ module HashedStorage attr_reader :project_id - # rubocop: disable CodeReuse/ActiveRecord def perform(project_id, old_disk_path = nil) @project_id = project_id # we need to set this in order to create the lease_key try_obtain_lease do - project = Project.without_deleted.find_by(id: project_id) + project = Project.without_deleted.find_by_id(project_id) break unless project old_disk_path ||= project.disk_path @@ -29,6 +28,5 @@ module HashedStorage ::Projects::HashedStorage::RollbackService.new(project, old_disk_path, logger: logger).execute end end - # rubocop: enable CodeReuse/ActiveRecord end end diff --git a/app/workers/import_issues_csv_worker.rb b/app/workers/import_issues_csv_worker.rb index 58e411c7b19..fe5b1c13d56 100644 --- a/app/workers/import_issues_csv_worker.rb +++ b/app/workers/import_issues_csv_worker.rb @@ -8,7 +8,7 @@ class ImportIssuesCsvWorker # rubocop:disable Scalability/IdempotentWorker sidekiq_options retry: 3 idempotent! - feature_category :issue_tracking + feature_category :team_planning worker_resource_boundary :cpu weight 2 diff --git a/app/workers/integrations/create_external_cross_reference_worker.rb b/app/workers/integrations/create_external_cross_reference_worker.rb new file mode 100644 index 00000000000..02c1315249e --- /dev/null +++ b/app/workers/integrations/create_external_cross_reference_worker.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Integrations + class CreateExternalCrossReferenceWorker + include ApplicationWorker + + data_consistency :delayed + + feature_category :integrations + urgency :low + idempotent! + deduplicate :until_executed, including_scheduled: true + loggable_arguments 2 + + def perform(project_id, external_issue_id, mentionable_type, mentionable_id, author_id) + project = Project.find_by_id(project_id) || return + author = User.find_by_id(author_id) || return + mentionable = find_mentionable(mentionable_type, mentionable_id, project) || return + external_issue = ExternalIssue.new(external_issue_id, project) + + project.external_issue_tracker.create_cross_reference_note( + external_issue, + mentionable, + author + ) + end + + private + + def find_mentionable(mentionable_type, mentionable_id, project) + mentionable_class = mentionable_type.safe_constantize + + # Passing an invalid mentionable_class is a developer error, so we don't want to retry the job + # but still track the exception on production, and raise it in development. + unless mentionable_class && mentionable_class < Mentionable + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(ArgumentError.new("Unexpected class '#{mentionable_type}' is not a Mentionable")) + return + end + + if mentionable_type == 'Commit' + project.commit(mentionable_id) + else + mentionable_class.find_by_id(mentionable_id) + end + end + end +end diff --git a/app/workers/invalid_gpg_signature_update_worker.rb b/app/workers/invalid_gpg_signature_update_worker.rb index c9567e102d3..7dc1e395597 100644 --- a/app/workers/invalid_gpg_signature_update_worker.rb +++ b/app/workers/invalid_gpg_signature_update_worker.rb @@ -10,13 +10,11 @@ class InvalidGpgSignatureUpdateWorker # rubocop:disable Scalability/IdempotentWo feature_category :source_code_management weight 2 - # rubocop: disable CodeReuse/ActiveRecord def perform(gpg_key_id) - gpg_key = GpgKey.find_by(id: gpg_key_id) + gpg_key = GpgKey.find_by_id(gpg_key_id) return unless gpg_key Gitlab::Gpg::InvalidGpgSignatureUpdater.new(gpg_key).run end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/issuable/label_links_destroy_worker.rb b/app/workers/issuable/label_links_destroy_worker.rb index f88c061bafb..da785550a43 100644 --- a/app/workers/issuable/label_links_destroy_worker.rb +++ b/app/workers/issuable/label_links_destroy_worker.rb @@ -7,7 +7,7 @@ module Issuable data_consistency :always idempotent! - feature_category :issue_tracking + feature_category :team_planning def perform(target_id, target_type) ::Issuable::DestroyLabelLinksService.new(target_id, target_type).execute diff --git a/app/workers/issuable_export_csv_worker.rb b/app/workers/issuable_export_csv_worker.rb index 7e2c3407772..9d543a21dc3 100644 --- a/app/workers/issuable_export_csv_worker.rb +++ b/app/workers/issuable_export_csv_worker.rb @@ -7,7 +7,7 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker sidekiq_options retry: 3 - feature_category :issue_tracking + feature_category :team_planning worker_resource_boundary :cpu loggable_arguments 2 diff --git a/app/workers/issuables/clear_groups_issue_counter_worker.rb b/app/workers/issuables/clear_groups_issue_counter_worker.rb index 9e62224b83d..82026bc21c7 100644 --- a/app/workers/issuables/clear_groups_issue_counter_worker.rb +++ b/app/workers/issuables/clear_groups_issue_counter_worker.rb @@ -8,7 +8,7 @@ module Issuables idempotent! urgency :low - feature_category :issue_tracking + feature_category :team_planning def perform(group_ids = []) return if group_ids.empty? diff --git a/app/workers/issue_due_scheduler_worker.rb b/app/workers/issue_due_scheduler_worker.rb index ad3470ae64b..ab586dce717 100644 --- a/app/workers/issue_due_scheduler_worker.rb +++ b/app/workers/issue_due_scheduler_worker.rb @@ -7,7 +7,7 @@ class IssueDueSchedulerWorker # rubocop:disable Scalability/IdempotentWorker include CronjobQueue # rubocop:disable Scalability/CronWorkerContext - feature_category :issue_tracking + feature_category :team_planning # rubocop: disable CodeReuse/ActiveRecord def perform diff --git a/app/workers/issue_placement_worker.rb b/app/workers/issue_placement_worker.rb index 22e2a8e95f4..cfd72b90a42 100644 --- a/app/workers/issue_placement_worker.rb +++ b/app/workers/issue_placement_worker.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +# todo: remove this worker and it's queue definition from all_queues after Issues::PlacementWorker is deployed +# We want to keep it for one release in case some jobs are already scheduled in the old queue so we need the worker +# to be available to finish those. All new jobs will be queued into the new queue. class IssuePlacementWorker include ApplicationWorker @@ -9,7 +12,7 @@ class IssuePlacementWorker idempotent! deduplicate :until_executed, including_scheduled: true - feature_category :issue_tracking + feature_category :team_planning urgency :high worker_resource_boundary :cpu weight 2 diff --git a/app/workers/issue_rebalancing_worker.rb b/app/workers/issue_rebalancing_worker.rb index 01984197aae..a43e76feae4 100644 --- a/app/workers/issue_rebalancing_worker.rb +++ b/app/workers/issue_rebalancing_worker.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +# todo: remove this worker and it's queue definition from all_queues after Issue::RebalancingWorker is released. +# We want to keep it for one release in case some jobs are already scheduled in the old queue so we need the worker +# to be available to finish those. All new jobs will be queued into the new queue. class IssueRebalancingWorker include ApplicationWorker @@ -9,13 +12,14 @@ class IssueRebalancingWorker idempotent! urgency :low - feature_category :issue_tracking + feature_category :team_planning deduplicate :until_executed, including_scheduled: true def perform(ignore = nil, project_id = nil, root_namespace_id = nil) # we need to have exactly one of the project_id and root_namespace_id params be non-nil raise ArgumentError, "Expected only one of the params project_id: #{project_id} and root_namespace_id: #{root_namespace_id}" if project_id && root_namespace_id return if project_id.nil? && root_namespace_id.nil? + return if ::Gitlab::Issues::Rebalancing::State.rebalance_recently_finished?(project_id, root_namespace_id) # pull the projects collection to be rebalanced either the project if namespace is not a group(i.e. user namesapce) # or the root namespace, this also makes the worker backward compatible with previous version where a project_id was diff --git a/app/workers/issues/placement_worker.rb b/app/workers/issues/placement_worker.rb new file mode 100644 index 00000000000..ec29a754128 --- /dev/null +++ b/app/workers/issues/placement_worker.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Issues + class PlacementWorker + include ApplicationWorker + + data_consistency :always + + sidekiq_options retry: 3 + + idempotent! + deduplicate :until_executed, including_scheduled: true + feature_category :team_planning + urgency :high + worker_resource_boundary :cpu + weight 2 + + # Move at most the most recent 100 issues + QUERY_LIMIT = 100 + + # rubocop: disable CodeReuse/ActiveRecord + def perform(issue_id, project_id = nil) + issue = find_issue(issue_id, project_id) + return unless issue + + # Temporary disable moving null elements because of performance problems + # For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321 + return if issue.blocked_for_repositioning? + + # Move the oldest 100 unpositioned items to the end. + # This is to deal with out-of-order execution of the worker, + # while preserving creation order. + to_place = Issue + .relative_positioning_query_base(issue) + .with_null_relative_position + .order({ created_at: :asc }, { id: :asc }) + .limit(QUERY_LIMIT + 1) + .to_a + + leftover = to_place.pop if to_place.count > QUERY_LIMIT + + Issue.move_nulls_to_end(to_place) + Issues::BaseService.new(project: nil).rebalance_if_needed(to_place.max_by(&:relative_position)) + Issues::PlacementWorker.perform_async(nil, leftover.project_id) if leftover.present? + rescue RelativePositioning::NoSpaceLeft => e + Gitlab::ErrorTracking.log_exception(e, issue_id: issue_id, project_id: project_id) + Issues::RebalancingWorker.perform_async(nil, *root_namespace_id_to_rebalance(issue, project_id)) + end + + def find_issue(issue_id, project_id) + return Issue.id_in(issue_id).take if issue_id + + project = Project.id_in(project_id).take + return unless project + + project.issues.take + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + def root_namespace_id_to_rebalance(issue, project_id) + project_id = project_id.presence || issue.project_id + Project.find(project_id)&.self_or_root_group_ids + end + end +end diff --git a/app/workers/issues/rebalancing_worker.rb b/app/workers/issues/rebalancing_worker.rb new file mode 100644 index 00000000000..466617d9fa1 --- /dev/null +++ b/app/workers/issues/rebalancing_worker.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Issues + class RebalancingWorker + include ApplicationWorker + + data_consistency :always + + sidekiq_options retry: 3 + + idempotent! + urgency :low + feature_category :team_planning + deduplicate :until_executed, including_scheduled: true + + def perform(ignore = nil, project_id = nil, root_namespace_id = nil) + # we need to have exactly one of the project_id and root_namespace_id params be non-nil + raise ArgumentError, "Expected only one of the params project_id: #{project_id} and root_namespace_id: #{root_namespace_id}" if project_id && root_namespace_id + return if project_id.nil? && root_namespace_id.nil? + + # pull the projects collection to be rebalanced either the project if namespace is not a group(i.e. user namesapce) + # or the root namespace, this also makes the worker backward compatible with previous version where a project_id was + # passed as the param + projects_to_rebalance = projects_collection(project_id, root_namespace_id) + + # something might have happened with the namespace between scheduling the worker and actually running it, + # maybe it was removed. + if projects_to_rebalance.blank? + Gitlab::ErrorTracking.log_exception( + ArgumentError.new("Projects to be rebalanced not found for arguments: project_id #{project_id}, root_namespace_id: #{root_namespace_id}"), + { project_id: project_id, root_namespace_id: root_namespace_id }) + + return + end + + Issues::RelativePositionRebalancingService.new(projects_to_rebalance).execute + rescue Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances => e + Gitlab::ErrorTracking.log_exception(e, root_namespace_id: root_namespace_id, project_id: project_id) + end + + private + + def projects_collection(project_id, root_namespace_id) + # we can have either project_id(older version) or project_id if project is part of a user namespace and not a group + # or root_namespace_id(newer version) never both. + return Project.id_in([project_id]) if project_id + + Namespace.find_by_id(root_namespace_id)&.all_projects + end + end +end diff --git a/app/workers/issues/reschedule_stuck_issue_rebalances_worker.rb b/app/workers/issues/reschedule_stuck_issue_rebalances_worker.rb new file mode 100644 index 00000000000..d1759589cc0 --- /dev/null +++ b/app/workers/issues/reschedule_stuck_issue_rebalances_worker.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Issues + class RescheduleStuckIssueRebalancesWorker + include ApplicationWorker + include CronjobQueue + + data_consistency :sticky + + idempotent! + urgency :low + feature_category :team_planning + deduplicate :until_executed, including_scheduled: true + + def perform + namespace_ids, project_ids = ::Gitlab::Issues::Rebalancing::State.fetch_rebalancing_groups_and_projects + + return if namespace_ids.blank? && project_ids.blank? + + namespaces = Namespace.id_in(namespace_ids) + projects = Project.id_in(project_ids) + + IssueRebalancingWorker.bulk_perform_async_with_contexts( + namespaces, + arguments_proc: -> (namespace) { [nil, nil, namespace.id] }, + context_proc: -> (namespace) { { namespace: namespace } } + ) + + IssueRebalancingWorker.bulk_perform_async_with_contexts( + projects, + arguments_proc: -> (project) { [nil, project.id, nil] }, + context_proc: -> (project) { { project: project } } + ) + end + end +end diff --git a/app/workers/loose_foreign_keys/cleanup_worker.rb b/app/workers/loose_foreign_keys/cleanup_worker.rb new file mode 100644 index 00000000000..b4565dbf624 --- /dev/null +++ b/app/workers/loose_foreign_keys/cleanup_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module LooseForeignKeys + class CleanupWorker + include ApplicationWorker + include Gitlab::ExclusiveLeaseHelpers + include CronjobQueue # rubocop: disable Scalability/CronWorkerContext + + feature_category :sharding + data_consistency :always + idempotent! + + def perform + return if Feature.disabled?(:loose_foreign_key_cleanup, default_enabled: :yaml) + + ttl = ModificationTracker::MAX_RUNTIME + 1.minute + in_lock(self.class.name.underscore, ttl: ttl, retries: 0) do + # TODO: Iterate over the connections + # https://gitlab.com/gitlab-org/gitlab/-/issues/341513 + stats = ProcessDeletedRecordsService.new(connection: ApplicationRecord.connection).execute + log_extra_metadata_on_done(:stats, stats) + end + end + end +end diff --git a/app/workers/mail_scheduler/issue_due_worker.rb b/app/workers/mail_scheduler/issue_due_worker.rb index 4c17f8df722..585fa43916e 100644 --- a/app/workers/mail_scheduler/issue_due_worker.rb +++ b/app/workers/mail_scheduler/issue_due_worker.rb @@ -9,7 +9,7 @@ module MailScheduler sidekiq_options retry: 3 include MailSchedulerQueue - feature_category :issue_tracking + feature_category :team_planning # rubocop: disable CodeReuse/ActiveRecord def perform(project_id) diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb index 0e6494a45d6..25c9ac5547b 100644 --- a/app/workers/mail_scheduler/notification_service_worker.rb +++ b/app/workers/mail_scheduler/notification_service_worker.rb @@ -11,7 +11,7 @@ module MailScheduler sidekiq_options retry: 3 include MailSchedulerQueue - feature_category :issue_tracking + feature_category :team_planning worker_resource_boundary :cpu loggable_arguments 0 diff --git a/app/workers/namespaces/invite_team_email_worker.rb b/app/workers/namespaces/invite_team_email_worker.rb new file mode 100644 index 00000000000..eabf33a7fba --- /dev/null +++ b/app/workers/namespaces/invite_team_email_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Namespaces + class InviteTeamEmailWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + data_consistency :always + + feature_category :experimentation_activation + urgency :low + + def perform(group_id, user_id) + # rubocop: disable CodeReuse/ActiveRecord + user = User.find_by(id: user_id) + group = Group.find_by(id: group_id) + # rubocop: enable CodeReuse/ActiveRecord + return unless user && group + + Namespaces::InviteTeamEmailService.send_email(user, group) + end + end +end diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb index 899545fc02c..13936fac1e4 100644 --- a/app/workers/new_issue_worker.rb +++ b/app/workers/new_issue_worker.rb @@ -8,7 +8,7 @@ class NewIssueWorker # rubocop:disable Scalability/IdempotentWorker sidekiq_options retry: 3 include NewIssuable - feature_category :issue_tracking + feature_category :team_planning urgency :high worker_resource_boundary :cpu weight 2 diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb index e54d84e382e..ecc78236161 100644 --- a/app/workers/new_note_worker.rb +++ b/app/workers/new_note_worker.rb @@ -7,21 +7,19 @@ class NewNoteWorker # rubocop:disable Scalability/IdempotentWorker sidekiq_options retry: 3 - feature_category :issue_tracking + feature_category :team_planning urgency :high worker_resource_boundary :cpu weight 2 # Keep extra parameter to preserve backwards compatibility with # old `NewNoteWorker` jobs (can remove later) - # rubocop: disable CodeReuse/ActiveRecord def perform(note_id, _params = {}) - if note = Note.find_by(id: note_id) + if note = Note.find_by_id(note_id) NotificationService.new.new_note(note) unless note.skip_notification? Notes::PostProcessService.new(note).execute else Gitlab::AppLogger.error("NewNoteWorker: couldn't find note with ID=#{note_id}, skipping job") end end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/pages_domain_verification_worker.rb b/app/workers/pages_domain_verification_worker.rb index 59de00d40d5..1696abd7b70 100644 --- a/app/workers/pages_domain_verification_worker.rb +++ b/app/workers/pages_domain_verification_worker.rb @@ -9,15 +9,13 @@ class PagesDomainVerificationWorker # rubocop:disable Scalability/IdempotentWork feature_category :pages - # rubocop: disable CodeReuse/ActiveRecord def perform(domain_id) return if Gitlab::Database.read_only? - domain = PagesDomain.find_by(id: domain_id) + domain = PagesDomain.find_by_id(domain_id) return unless domain VerifyPagesDomainService.new(domain).execute end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb index 5e951ab2c3a..cceafbc7d2d 100644 --- a/app/workers/pages_worker.rb +++ b/app/workers/pages_worker.rb @@ -14,15 +14,13 @@ class PagesWorker # rubocop:disable Scalability/IdempotentWorker send(action, *arg) # rubocop:disable GitlabSecurity/PublicSend end - # rubocop: disable CodeReuse/ActiveRecord def deploy(build_id) - build = Ci::Build.find_by(id: build_id) + build = Ci::Build.find_by_id(build_id) update_contents = Projects::UpdatePagesService.new(build.project, build).execute if update_contents[:status] == :success Projects::UpdatePagesConfigurationService.new(build.project).execute end end - # rubocop: enable CodeReuse/ActiveRecord def remove(namespace_path, project_path) full_path = File.join(Settings.pages.path, namespace_path, project_path) diff --git a/app/workers/pipeline_hooks_worker.rb b/app/workers/pipeline_hooks_worker.rb index c67f3860a50..eb5d0086592 100644 --- a/app/workers/pipeline_hooks_worker.rb +++ b/app/workers/pipeline_hooks_worker.rb @@ -10,12 +10,10 @@ class PipelineHooksWorker # rubocop:disable Scalability/IdempotentWorker worker_resource_boundary :cpu data_consistency :delayed - # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) - pipeline = Ci::Pipeline.find_by(id: pipeline_id) + pipeline = Ci::Pipeline.find_by_id(pipeline_id) return unless pipeline Ci::Pipelines::HookService.new(pipeline).execute end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/pipeline_metrics_worker.rb b/app/workers/pipeline_metrics_worker.rb index c2580c3d48b..4e98c7268ac 100644 --- a/app/workers/pipeline_metrics_worker.rb +++ b/app/workers/pipeline_metrics_worker.rb @@ -10,14 +10,12 @@ class PipelineMetricsWorker # rubocop:disable Scalability/IdempotentWorker urgency :high - # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) - Ci::Pipeline.find_by(id: pipeline_id).try do |pipeline| + Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| update_metrics_for_active_pipeline(pipeline) if pipeline.active? update_metrics_for_succeeded_pipeline(pipeline) if pipeline.success? end end - # rubocop: enable CodeReuse/ActiveRecord private @@ -29,11 +27,9 @@ class PipelineMetricsWorker # rubocop:disable Scalability/IdempotentWorker metrics(pipeline).update_all(latest_build_started_at: pipeline.started_at, latest_build_finished_at: pipeline.finished_at, pipeline_id: pipeline.id) end - # rubocop: disable CodeReuse/ActiveRecord def metrics(pipeline) - MergeRequest::Metrics.where(merge_request_id: merge_requests(pipeline)) + MergeRequest::Metrics.where(merge_request_id: merge_requests(pipeline)) # rubocop: disable CodeReuse/ActiveRecord end - # rubocop: enable CodeReuse/ActiveRecord def merge_requests(pipeline) pipeline.merge_requests_as_head_pipeline.map(&:id) diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb index 9370b361068..b4712aaeafb 100644 --- a/app/workers/pipeline_process_worker.rb +++ b/app/workers/pipeline_process_worker.rb @@ -16,13 +16,11 @@ class PipelineProcessWorker idempotent! deduplicate :until_executing - # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) - Ci::Pipeline.find_by(id: pipeline_id).try do |pipeline| + Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| Ci::ProcessPipelineService .new(pipeline) .execute end end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 7d0322361b8..45af15216fc 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -128,7 +128,6 @@ class PostReceive end def after_project_changes_hooks(project, user, refs, changes) - experiment(:empty_repo_upload, project: project).track_initial_write repository_update_hook_data = Gitlab::DataBuilder::Repository.update(project, user, changes, refs) SystemHooksService.new.execute_hooks(repository_update_hook_data, :repository_update_hooks) Gitlab::UsageDataCounters::SourceCodeCounter.count(:pushes) diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb index 328fdc4717c..0e90b41e28d 100644 --- a/app/workers/project_cache_worker.rb +++ b/app/workers/project_cache_worker.rb @@ -23,9 +23,8 @@ class ProjectCacheWorker # refresh, if empty all columns will be refreshed # refresh_statistics - A boolean that determines whether project statistics should # be updated. - # rubocop: disable CodeReuse/ActiveRecord def perform(project_id, files = [], statistics = [], refresh_statistics = true) - project = Project.find_by(id: project_id) + project = Project.find_by_id(project_id) return unless project @@ -37,7 +36,6 @@ class ProjectCacheWorker project.cleanup end - # rubocop: enable CodeReuse/ActiveRecord # NOTE: triggering both an immediate update and one in 15 minutes if we # successfully obtain the lease. That way, we only need to wait for the diff --git a/app/workers/propagate_service_template_worker.rb b/app/workers/propagate_service_template_worker.rb index 6b8382ae433..908f867279f 100644 --- a/app/workers/propagate_service_template_worker.rb +++ b/app/workers/propagate_service_template_worker.rb @@ -13,13 +13,11 @@ class PropagateServiceTemplateWorker # rubocop:disable Scalability/IdempotentWor LEASE_TIMEOUT = 4.hours.to_i - # rubocop: disable CodeReuse/ActiveRecord def perform(template_id) return unless try_obtain_lease_for(template_id) - Admin::PropagateServiceTemplate.propagate(Integration.find_by(id: template_id)) + Admin::PropagateServiceTemplate.propagate(Integration.find_by_id(template_id)) end - # rubocop: enable CodeReuse/ActiveRecord private diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index 12042ebc4f0..f08d8231e43 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -11,16 +11,14 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker queue_namespace :pipeline_creation feature_category :continuous_integration - # rubocop: disable CodeReuse/ActiveRecord def perform(schedule_id, user_id) - schedule = Ci::PipelineSchedule.find_by(id: schedule_id) - user = User.find_by(id: user_id) + schedule = Ci::PipelineSchedule.find_by_id(schedule_id) + user = User.find_by_id(user_id) return unless schedule && user run_pipeline_schedule(schedule, user) end - # rubocop: enable CodeReuse/ActiveRecord def run_pipeline_schedule(schedule, user) Ci::CreatePipelineService.new(schedule.project, diff --git a/app/workers/ssh_keys/expired_notification_worker.rb b/app/workers/ssh_keys/expired_notification_worker.rb index d8553b5a9a2..dc1efce51ce 100644 --- a/app/workers/ssh_keys/expired_notification_worker.rb +++ b/app/workers/ssh_keys/expired_notification_worker.rb @@ -29,7 +29,7 @@ module SshKeys ) ]) - scope = Key.expired_and_not_notified.order(order) + scope = Key.expired_today_and_not_notified.order(order) iterator = Gitlab::Pagination::Keyset::Iterator.new(scope: scope, use_union_optimization: true) iterator.each_batch(of: BATCH_SIZE) do |relation| @@ -37,7 +37,7 @@ module SshKeys users.each do |user| with_context(user: user) do - Keys::ExpiryNotificationService.new(user, { keys: user.expired_and_unnotified_keys, expiring_soon: false }).execute + Keys::ExpiryNotificationService.new(user, { keys: user.expired_today_and_unnotified_keys, expiring_soon: false }).execute end end end diff --git a/app/workers/tasks_to_be_done/create_worker.rb b/app/workers/tasks_to_be_done/create_worker.rb new file mode 100644 index 00000000000..0953f190fd0 --- /dev/null +++ b/app/workers/tasks_to_be_done/create_worker.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module TasksToBeDone + class CreateWorker + include ApplicationWorker + + data_consistency :always + idempotent! + feature_category :onboarding + urgency :low + worker_resource_boundary :cpu + + def perform(member_task_id, current_user_id, assignee_ids = []) + member_task = MemberTask.find(member_task_id) + current_user = User.find(current_user_id) + project = member_task.project + + member_task.tasks_to_be_done.each do |task| + service_class(task) + .new(project: project, current_user: current_user, assignee_ids: assignee_ids) + .execute + end + end + + private + + def service_class(task) + "TasksToBeDone::Create#{task.to_s.camelize}TaskService".constantize + end + end +end diff --git a/app/workers/update_highest_role_worker.rb b/app/workers/update_highest_role_worker.rb index d5df46c172b..064b8203d4d 100644 --- a/app/workers/update_highest_role_worker.rb +++ b/app/workers/update_highest_role_worker.rb @@ -13,17 +13,15 @@ class UpdateHighestRoleWorker idempotent! - # rubocop: disable CodeReuse/ActiveRecord def perform(user_id) - user = User.find_by(id: user_id) + user = User.find_by_id(user_id) return unless user.present? if user.active? && user.human? && !user.internal? Users::UpdateHighestMemberRoleService.new(user).execute else - UserHighestRole.where(user_id: user_id).delete_all + UserHighestRole.where(user_id: user_id).delete_all # rubocop: disable CodeReuse/ActiveRecord end end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb index 421a6e47425..5c96257cb63 100644 --- a/app/workers/update_merge_requests_worker.rb +++ b/app/workers/update_merge_requests_worker.rb @@ -13,15 +13,13 @@ class UpdateMergeRequestsWorker # rubocop:disable Scalability/IdempotentWorker weight 3 loggable_arguments 2, 3, 4 - # rubocop: disable CodeReuse/ActiveRecord def perform(project_id, user_id, oldrev, newrev, ref) - project = Project.find_by(id: project_id) + project = Project.find_by_id(project_id) return unless project - user = User.find_by(id: user_id) + user = User.find_by_id(user_id) return unless user MergeRequests::RefreshService.new(project: project, current_user: user).execute(oldrev, newrev, ref) end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/update_project_statistics_worker.rb b/app/workers/update_project_statistics_worker.rb index 4a32753ac70..45a6cc8f476 100644 --- a/app/workers/update_project_statistics_worker.rb +++ b/app/workers/update_project_statistics_worker.rb @@ -13,11 +13,9 @@ class UpdateProjectStatisticsWorker # rubocop:disable Scalability/IdempotentWork # project_id - The ID of the project for which to flush the cache. # statistics - An Array containing columns from ProjectStatistics to # refresh, if empty all columns will be refreshed - # rubocop: disable CodeReuse/ActiveRecord def perform(project_id, statistics = []) - project = Project.find_by(id: project_id) + project = Project.find_by_id(project_id) Projects::UpdateStatisticsService.new(project, nil, statistics: statistics).execute end - # rubocop: enable CodeReuse/ActiveRecord end |