diff options
Diffstat (limited to 'app')
3177 files changed, 22117 insertions, 11495 deletions
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/1F627.png b/app/assets/images/emoji/1F627.png Binary files differdeleted file mode 100644 index f99026a3bc7..00000000000 --- a/app/assets/images/emoji/1F627.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/javascripts/api.js b/app/assets/javascripts/api.js index d1396b6c4bc..8754c253881 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -5,12 +5,14 @@ import axios from './lib/utils/axios_utils'; const Api = { groupsPath: '/api/:version/groups.json', groupPath: '/api/:version/groups/:id', + groupMembersPath: '/api/:version/groups/:id/members', subgroupsPath: '/api/:version/groups/:id/subgroups', namespacesPath: '/api/:version/namespaces.json', groupProjectsPath: '/api/:version/groups/:id/projects.json', projectsPath: '/api/:version/projects.json', projectPath: '/api/:version/projects/:id', projectLabelsPath: '/:namespace_path/:project_path/labels', + projectMergeRequestsPath: '/api/:version/projects/:id/merge_requests', projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', @@ -40,6 +42,12 @@ const Api = { }); }, + groupMembers(id) { + const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id)); + + return axios.get(url); + }, + // Return groups list. Filtered by query groups(query, options, callback = $.noop) { const url = Api.buildUrl(Api.groupsPath); @@ -104,6 +112,22 @@ const Api = { return axios.get(url); }, + /** + * Get all Merge Requests for a project, eventually filtering based on + * supplied parameters + * @param projectPath + * @param params + * @returns {Promise} + */ + projectMergeRequests(projectPath, params = {}) { + const url = Api.buildUrl(Api.projectMergeRequestsPath).replace( + ':id', + encodeURIComponent(projectPath), + ); + + return axios.get(url, { params }); + }, + // Return Merge Request for project projectMergeRequest(projectPath, mergeRequestId, params = {}) { const url = Api.buildUrl(Api.projectMergeRequestPath) diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index cace8bb9dba..aeb88715c11 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -437,7 +437,7 @@ export class AwardsHandler { createAwardButtonForVotesBlock(votesBlock, emojiName) { const buttonHtml = ` - <button class="btn award-control js-emoji-btn has-tooltip active" title="You" data-placement="bottom"> + <button class="btn award-control js-emoji-btn has-tooltip active" title="You"> ${this.emoji.glEmojiTag(emojiName)} <span class="award-control-text js-counter">1</span> </button> @@ -615,10 +615,18 @@ export class AwardsHandler { let awardsHandlerPromise = null; export default function loadAwardsHandler(reload = false) { if (!awardsHandlerPromise || reload) { - awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji').then(Emoji => { - const awardsHandler = new AwardsHandler(Emoji); - awardsHandler.bindEvents(); - return awardsHandler; + awardsHandlerPromise = new Promise((resolve, reject) => { + import(/* webpackChunkName: 'emoji' */ './emoji') + .then(Emoji => { + Emoji.initEmojiMap() + .then(() => { + const awardsHandler = new AwardsHandler(Emoji); + awardsHandler.bindEvents(); + resolve(awardsHandler); + }) + .catch(() => reject); + }) + .catch(() => reject); }); } return awardsHandlerPromise; diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index 85a15b38de1..df74eb2c2f7 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -90,7 +90,7 @@ export default { }, badgeImageUrlExample() { const exampleUrl = - 'https://example.gitlab.com/%{project_path}/badges/%{default_branch}/badge.svg'; + 'https://example.gitlab.com/%{project_path}/badges/%{default_branch}/pipeline.svg'; return sprintf(s__('Badges|e.g. %{exampleUrl}'), { exampleUrl, }); diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue index 9051be1e102..cad5611c8c5 100644 --- a/app/assets/javascripts/badges/components/badge_list_row.vue +++ b/app/assets/javascripts/badges/components/badge_list_row.vue @@ -55,7 +55,7 @@ export default { :disabled="badge.isDeleting" class="btn btn-default append-right-8" type="button" - @click="editBadge(badge);" + @click="editBadge(badge)" > <icon :size="16" :aria-label="__('Edit')" name="pencil" /> </button> @@ -65,7 +65,7 @@ export default { type="button" data-toggle="modal" data-target="#delete-badge-modal" - @click="updateBadgeInModal(badge);" + @click="updateBadgeInModal(badge)" > <icon :size="16" :aria-label="__('Delete')" name="remove" /> </button> diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index 56293d5f96f..9034563d9b3 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -1,51 +1,79 @@ -import installCustomElements from 'document-register-element'; +import 'document-register-element'; import isEmojiUnicodeSupported from '../emoji/support'; +import { initEmojiMap, getEmojiInfo, emojiFallbackImageSrc, emojiImageTag } from '../emoji'; -installCustomElements(window); +class GlEmoji extends HTMLElement { + constructor() { + super(); -export default function installGlEmojiElement() { - const GlEmojiElementProto = Object.create(HTMLElement.prototype); - GlEmojiElementProto.createdCallback = function createdCallback() { - const emojiUnicode = this.textContent.trim(); - const { name, unicodeVersion, fallbackSrc, fallbackSpriteClass } = this.dataset; - - const isEmojiUnicode = - this.childNodes && - Array.prototype.every.call(this.childNodes, childNode => childNode.nodeType === 3); - const hasImageFallback = fallbackSrc && fallbackSrc.length > 0; - const hasCssSpriteFalback = fallbackSpriteClass && fallbackSpriteClass.length > 0; - - if (emojiUnicode && isEmojiUnicode && !isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)) { - // CSS sprite fallback takes precedence over image fallback - if (hasCssSpriteFalback) { - if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) { - const emojiSpriteLinkTag = document.createElement('link'); - emojiSpriteLinkTag.setAttribute('rel', 'stylesheet'); - emojiSpriteLinkTag.setAttribute('href', gon.emoji_sprites_css_path); - document.head.appendChild(emojiSpriteLinkTag); - gon.emoji_sprites_css_added = true; - } - // IE 11 doesn't like adding multiple at once :( - this.classList.add('emoji-icon'); - this.classList.add(fallbackSpriteClass); - } else { - import(/* webpackChunkName: 'emoji' */ '../emoji') - .then(({ emojiImageTag, emojiFallbackImageSrc }) => { - if (hasImageFallback) { - this.innerHTML = emojiImageTag(name, fallbackSrc); + let emojiUnicode = this.textContent.trim(); + const { fallbackSpriteClass, fallbackSrc, forceFallback } = this.dataset; + let { name, unicodeVersion } = this.dataset; + + initEmojiMap() + .then(() => { + if (!unicodeVersion) { + const emojiInfo = getEmojiInfo(name); + + if (emojiInfo) { + if (name !== emojiInfo.name) { + ({ name } = emojiInfo); + this.dataset.name = emojiInfo.name; + } + unicodeVersion = emojiInfo.u; + this.dataset.uni = unicodeVersion; + + if (forceFallback === 'true' && !fallbackSpriteClass) { + this.innerHTML = emojiImageTag(name, emojiFallbackImageSrc(name)); } else { - const src = emojiFallbackImageSrc(name); - this.innerHTML = emojiImageTag(name, src); + emojiUnicode = emojiInfo.e; + this.innerHTML = emojiInfo.e; } - }) - .catch(() => { - // do nothing - }); - } - } - }; - - document.registerElement('gl-emoji', { - prototype: GlEmojiElementProto, - }); + + this.title = emojiInfo.d; + } + } + + const isEmojiUnicode = + this.childNodes && + Array.prototype.every.call(this.childNodes, childNode => childNode.nodeType === 3); + const hasImageFallback = fallbackSrc && fallbackSrc.length > 0; + const hasCssSpriteFalback = fallbackSpriteClass && fallbackSpriteClass.length > 0; + + if ( + emojiUnicode && + isEmojiUnicode && + !isEmojiUnicodeSupported(emojiUnicode, unicodeVersion) + ) { + // CSS sprite fallback takes precedence over image fallback + if (hasCssSpriteFalback) { + if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) { + const emojiSpriteLinkTag = document.createElement('link'); + emojiSpriteLinkTag.setAttribute('rel', 'stylesheet'); + emojiSpriteLinkTag.setAttribute('href', gon.emoji_sprites_css_path); + document.head.appendChild(emojiSpriteLinkTag); + gon.emoji_sprites_css_added = true; + } + // IE 11 doesn't like adding multiple at once :( + this.classList.add('emoji-icon'); + this.classList.add(fallbackSpriteClass); + } else if (hasImageFallback) { + this.innerHTML = emojiImageTag(name, fallbackSrc); + } else { + const src = emojiFallbackImageSrc(name); + this.innerHTML = emojiImageTag(name, src); + } + } + }) + .catch(error => { + // Only reject is already handled in initEmojiMap + throw error; + }); + } +} + +export default function installGlEmojiElement() { + if (!customElements.get('gl-emoji')) { + customElements.define('gl-emoji', GlEmoji); + } } diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js index fe02096d903..58cf057b2c2 100644 --- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js @@ -1,320 +1,5 @@ -/* eslint-disable object-shorthand, no-unused-vars, no-use-before-define, no-restricted-syntax, guard-for-in, no-continue */ - import $ from 'jquery'; -import _ from 'underscore'; -import { insertText, getSelectedFragment, nodeMatchesSelector } from '~/lib/utils/common_utils'; -import { placeholderImage } from '~/lazy_loader'; - -const gfmRules = { - // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert - // GitLab Flavored Markdown (GFM) to HTML. - // These handlers consequently convert that same HTML to GFM to be copied to the clipboard. - // Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML - // from GFM should have a handler here, in reverse order. - // The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb. - InlineDiffFilter: { - 'span.idiff.addition'(el, text) { - return `{+${text}+}`; - }, - 'span.idiff.deletion'(el, text) { - return `{-${text}-}`; - }, - }, - TaskListFilter: { - 'input[type=checkbox].task-list-item-checkbox'(el) { - return `[${el.checked ? 'x' : ' '}]`; - }, - }, - ReferenceFilter: { - '.tooltip'(el) { - return ''; - }, - 'a.gfm:not([data-link=true])'(el, text) { - return el.dataset.original || text; - }, - }, - AutolinkFilter: { - a(el, text) { - // Fallback on the regular MarkdownFilter's `a` handler. - if (text !== el.getAttribute('href')) return false; - - return text; - }, - }, - TableOfContentsFilter: { - 'ul.section-nav'(el) { - return '[[_TOC_]]'; - }, - }, - EmojiFilter: { - 'img.emoji'(el) { - return el.getAttribute('alt'); - }, - 'gl-emoji'(el) { - return `:${el.getAttribute('data-name')}:`; - }, - }, - ImageLinkFilter: { - 'a.no-attachment-icon'(el, text) { - return text; - }, - }, - ImageLazyLoadFilter: { - img(el, text) { - return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`; - }, - }, - VideoLinkFilter: { - '.video-container'(el) { - const videoEl = el.querySelector('video'); - if (!videoEl) return false; - - return CopyAsGFM.nodeToGFM(videoEl); - }, - video(el) { - return `![${el.dataset.title}](${el.getAttribute('src')})`; - }, - }, - MermaidFilter: { - 'svg.mermaid'(el, text) { - const sourceEl = el.querySelector('text.source'); - if (!sourceEl) return false; - - return `\`\`\`mermaid\n${CopyAsGFM.nodeToGFM(sourceEl)}\n\`\`\``; - }, - 'svg.mermaid style, svg.mermaid g'(el, text) { - // We don't want to include the content of these elements in the copied text. - return ''; - }, - }, - MathFilter: { - 'pre.code.math[data-math-style=display]'(el, text) { - return `\`\`\`math\n${text.trim()}\n\`\`\``; - }, - 'code.code.math[data-math-style=inline]'(el, text) { - return `$\`${text}\`$`; - }, - 'span.katex-display span.katex-mathml'(el) { - const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); - if (!mathAnnotation) return false; - - return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``; - }, - 'span.katex-mathml'(el) { - const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); - if (!mathAnnotation) return false; - - return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`; - }, - 'span.katex-html'(el) { - // We don't want to include the content of this element in the copied text. - return ''; - }, - 'annotation[encoding="application/x-tex"]'(el, text) { - return text.trim(); - }, - }, - SanitizationFilter: { - 'a[name]:not([href]):empty'(el) { - return el.outerHTML; - }, - dl(el, text) { - let lines = text - .replace(/\n\n/g, '\n') - .trim() - .split('\n'); - // Add two spaces to the front of subsequent list items lines, - // or leave the line entirely blank. - lines = lines.map(l => { - const line = l.trim(); - if (line.length === 0) return ''; - - return ` ${line}`; - }); - - return `<dl>\n${lines.join('\n')}\n</dl>\n`; - }, - 'dt, dd, summary, details'(el, text) { - const tag = el.nodeName.toLowerCase(); - return `<${tag}>${text}</${tag}>\n`; - }, - 'sup, sub, kbd, q, samp, var, ruby, rt, rp, abbr'(el, text) { - const tag = el.nodeName.toLowerCase(); - return `<${tag}>${text}</${tag}>`; - }, - }, - SyntaxHighlightFilter: { - 'pre.code.highlight'(el, t) { - const text = t.trimRight(); - - let lang = el.getAttribute('lang'); - if (!lang || lang === 'plaintext') { - lang = ''; - } - - // Prefixes lines with 4 spaces if the code contains triple backticks - if (lang === '' && text.match(/^```/gm)) { - return text - .split('\n') - .map(l => { - const line = l.trim(); - if (line.length === 0) return ''; - - return ` ${line}`; - }) - .join('\n'); - } - - return `\`\`\`${lang}\n${text}\n\`\`\``; - }, - 'pre > code'(el, text) { - // Don't wrap code blocks in `` - return text; - }, - }, - MarkdownFilter: { - br(el) { - // Two spaces at the end of a line are turned into a BR - return ' '; - }, - code(el, text) { - let backtickCount = 1; - const backtickMatch = text.match(/`+/); - if (backtickMatch) { - backtickCount = backtickMatch[0].length + 1; - } - - const backticks = Array(backtickCount + 1).join('`'); - const spaceOrNoSpace = backtickCount > 1 ? ' ' : ''; - - return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks; - }, - blockquote(el, text) { - return text - .trim() - .split('\n') - .map(s => `> ${s}`.trim()) - .join('\n'); - }, - img(el) { - const imageSrc = el.src; - const imageUrl = imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || ''; - return `![${el.getAttribute('alt')}](${imageUrl})`; - }, - 'a.anchor'(el, text) { - // Don't render a Markdown link for the anchor link inside a heading - return text; - }, - a(el, text) { - return `[${text}](${el.getAttribute('href')})`; - }, - li(el, text) { - const lines = text.trim().split('\n'); - const firstLine = `- ${lines.shift()}`; - // Add four spaces to the front of subsequent list items lines, - // or leave the line entirely blank. - const nextLines = lines.map(s => { - if (s.trim().length === 0) return ''; - - return ` ${s}`; - }); - - return `${firstLine}\n${nextLines.join('\n')}`; - }, - ul(el, text) { - return text; - }, - ol(el, text) { - // LIs get a `- ` prefix by default, which we replace by `1. ` for ordered lists. - return text.replace(/^- /gm, '1. '); - }, - h1(el, text) { - return `# ${text.trim()}\n`; - }, - h2(el, text) { - return `## ${text.trim()}\n`; - }, - h3(el, text) { - return `### ${text.trim()}\n`; - }, - h4(el, text) { - return `#### ${text.trim()}\n`; - }, - h5(el, text) { - return `##### ${text.trim()}\n`; - }, - h6(el, text) { - return `###### ${text.trim()}\n`; - }, - strong(el, text) { - return `**${text}**`; - }, - em(el, text) { - return `_${text}_`; - }, - del(el, text) { - return `~~${text}~~`; - }, - hr(el) { - // extra leading \n is to ensure that there is a blank line between - // a list followed by an hr, otherwise this breaks old redcarpet rendering - return '\n-----\n'; - }, - p(el, text) { - return `${text.trim()}\n`; - }, - table(el) { - const theadEl = el.querySelector('thead'); - const tbodyEl = el.querySelector('tbody'); - if (!theadEl || !tbodyEl) return false; - - const theadText = CopyAsGFM.nodeToGFM(theadEl); - const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl); - - return [theadText, tbodyText].join('\n'); - }, - thead(el, text) { - const cells = _.map(el.querySelectorAll('th'), cell => { - let chars = CopyAsGFM.nodeToGFM(cell).length + 2; - - let before = ''; - let after = ''; - const alignment = cell.align || cell.style.textAlign; - - switch (alignment) { - case 'center': - before = ':'; - after = ':'; - chars -= 2; - break; - case 'right': - after = ':'; - chars -= 1; - break; - default: - break; - } - - chars = Math.max(chars, 3); - - const middle = Array(chars + 1).join('-'); - - return before + middle + after; - }); - - const separatorRow = `|${cells.join('|')}|`; - - return [text, separatorRow].join('\n'); - }, - tr(el) { - const cellEls = el.querySelectorAll('td, th'); - if (cellEls.length === 0) return false; - - const cells = _.map(cellEls, cell => CopyAsGFM.nodeToGFM(cell)); - return `| ${cells.join(' | ')} |`; - }, - }, -}; +import { getSelectedFragment } from '~/lib/utils/common_utils'; export class CopyAsGFM { constructor() { @@ -347,8 +32,24 @@ export class CopyAsGFM { e.preventDefault(); e.stopPropagation(); + const div = document.createElement('div'); + div.appendChild(el.cloneNode(true)); + const html = div.innerHTML; + clipboardData.setData('text/plain', el.textContent); - clipboardData.setData('text/x-gfm', this.nodeToGFM(el)); + clipboardData.setData('text/html', html); + // We are also setting this as fallback to transform the selection to gfm on paste + clipboardData.setData('text/x-gfm-html', html); + + CopyAsGFM.nodeToGFM(el) + .then(res => { + clipboardData.setData('text/x-gfm', res); + }) + .catch(() => { + // Not showing the error as Firefox might doesn't allow + // it or other browsers who have a time limit on the execution + // of the copy event + }); } static pasteGFM(e) { @@ -357,11 +58,28 @@ export class CopyAsGFM { const text = clipboardData.getData('text/plain'); const gfm = clipboardData.getData('text/x-gfm'); - if (!gfm) return; + const gfmHtml = clipboardData.getData('text/x-gfm-html'); + if (!gfm && !gfmHtml) return; e.preventDefault(); - window.gl.utils.insertText(e.target, (textBefore, textAfter) => { + // We have the original selection already converted to gfm + if (gfm) { + CopyAsGFM.insertPastedText(e.target, text, gfm); + } else { + // Due to the async copy call we are not able to produce gfm so we transform the cached HTML + const div = document.createElement('div'); + div.innerHTML = gfmHtml; + CopyAsGFM.nodeToGFM(div) + .then(transformedGfm => { + CopyAsGFM.insertPastedText(e.target, text, transformedGfm); + }) + .catch(() => {}); + } + } + + static insertPastedText(target, text, gfm) { + window.gl.utils.insertText(target, textBefore => { // If the text before the cursor contains an odd number of backticks, // we are either inside an inline code span that starts with 1 backtick // or a code block that starts with 3 backticks. @@ -443,75 +161,24 @@ export class CopyAsGFM { return codeElement; } - static nodeToGFM(node, respectWhitespaceParam = false) { - if (node.nodeType === Node.COMMENT_NODE) { - return ''; - } - - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent; - } - - const respectWhitespace = - respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE'); - - const text = this.innerGFM(node, respectWhitespace); - - if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { - return text; - } - - for (const filter in gfmRules) { - const rules = gfmRules[filter]; - - for (const selector in rules) { - const func = rules[selector]; - - if (!nodeMatchesSelector(node, selector)) continue; - - let result; - if (func.length === 2) { - // if `func` takes 2 arguments, it depends on text. - // if there is no text, we don't need to generate GFM for this node. - if (text.length === 0) continue; - - result = func(node, text); - } else { - result = func(node); - } - - if (result === false) continue; - - return result; - } - } - - return text; - } - - static innerGFM(parentNode, respectWhitespace = false) { - const nodes = parentNode.childNodes; - - const clonedParentNode = parentNode.cloneNode(true); - const clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0); - - for (let i = 0; i < nodes.length; i += 1) { - const node = nodes[i]; - const clonedNode = clonedNodes[i]; - - const text = this.nodeToGFM(node, respectWhitespace); - - // `clonedNode.replaceWith(text)` is not yet widely supported - clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode); - } - - let nodeText = clonedParentNode.innerText || clonedParentNode.textContent; - - if (!respectWhitespace) { - nodeText = nodeText.trim(); - } - - return nodeText; + static nodeToGFM(node) { + return Promise.all([ + import(/* webpackChunkName: 'gfm_copy_extra' */ 'prosemirror-model'), + import(/* webpackChunkName: 'gfm_copy_extra' */ './schema'), + import(/* webpackChunkName: 'gfm_copy_extra' */ './serializer'), + ]) + .then(([prosemirrorModel, schema, markdownSerializer]) => { + const { DOMParser } = prosemirrorModel; + const wrapEl = document.createElement('div'); + wrapEl.appendChild(node.cloneNode(true)); + const doc = DOMParser.fromSchema(schema.default).parse(wrapEl); + + const res = markdownSerializer.default.serialize(doc, { + tightLists: true, + }); + return res; + }) + .catch(() => {}); } } diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js new file mode 100644 index 00000000000..47e5fc65c48 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/editor_extensions.js @@ -0,0 +1,106 @@ +import Doc from './nodes/doc'; +import Paragraph from './nodes/paragraph'; +import Text from './nodes/text'; + +import Blockquote from './nodes/blockquote'; +import CodeBlock from './nodes/code_block'; +import HardBreak from './nodes/hard_break'; +import Heading from './nodes/heading'; +import HorizontalRule from './nodes/horizontal_rule'; +import Image from './nodes/image'; + +import Table from './nodes/table'; +import TableHead from './nodes/table_head'; +import TableBody from './nodes/table_body'; +import TableHeaderRow from './nodes/table_header_row'; +import TableRow from './nodes/table_row'; +import TableCell from './nodes/table_cell'; + +import Emoji from './nodes/emoji'; +import Reference from './nodes/reference'; + +import TableOfContents from './nodes/table_of_contents'; +import Video from './nodes/video'; + +import BulletList from './nodes/bullet_list'; +import OrderedList from './nodes/ordered_list'; +import ListItem from './nodes/list_item'; + +import DescriptionList from './nodes/description_list'; +import DescriptionTerm from './nodes/description_term'; +import DescriptionDetails from './nodes/description_details'; + +import TaskList from './nodes/task_list'; +import OrderedTaskList from './nodes/ordered_task_list'; +import TaskListItem from './nodes/task_list_item'; + +import Summary from './nodes/summary'; +import Details from './nodes/details'; + +import Bold from './marks/bold'; +import Italic from './marks/italic'; +import Strike from './marks/strike'; +import InlineDiff from './marks/inline_diff'; + +import Link from './marks/link'; +import Code from './marks/code'; +import MathMark from './marks/math'; +import InlineHTML from './marks/inline_html'; + +// The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb transform +// GitLab Flavored Markdown (GFM) to HTML. +// The nodes and marks referenced here transform that same HTML to GFM to be copied to the clipboard. +// Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML +// from GFM should have a node or mark here. +// The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb. + +export default [ + new Doc(), + new Paragraph(), + new Text(), + + new Blockquote(), + new CodeBlock(), + new HardBreak(), + new Heading({ maxLevel: 6 }), + new HorizontalRule(), + new Image(), + + new Table(), + new TableHead(), + new TableBody(), + new TableHeaderRow(), + new TableRow(), + new TableCell(), + + new Emoji(), + new Reference(), + + new TableOfContents(), + new Video(), + + new BulletList(), + new OrderedList(), + new ListItem(), + + new DescriptionList(), + new DescriptionTerm(), + new DescriptionDetails(), + + new TaskList(), + new OrderedTaskList(), + new TaskListItem(), + + new Summary(), + new Details(), + + new Bold(), + new Italic(), + new Strike(), + new InlineDiff(), + + new Link(), + new Code(), + new MathMark(), + new InlineHTML(), +]; diff --git a/app/assets/javascripts/behaviors/markdown/marks/bold.js b/app/assets/javascripts/behaviors/markdown/marks/bold.js new file mode 100644 index 00000000000..b537954c1cb --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/bold.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { Bold as BaseBold } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Bold extends BaseBold { + get toMarkdown() { + return defaultMarkdownSerializer.marks.strong; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/code.js b/app/assets/javascripts/behaviors/markdown/marks/code.js new file mode 100644 index 00000000000..a760ee80dd0 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/code.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { Code as BaseCode } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Code extends BaseCode { + get toMarkdown() { + return defaultMarkdownSerializer.marks.code; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js new file mode 100644 index 00000000000..ce425e80cd3 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js @@ -0,0 +1,41 @@ +/* eslint-disable class-methods-use-this */ + +import { Mark } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::InlineDiffFilter +export default class InlineDiff extends Mark { + get name() { + return 'inline_diff'; + } + + get schema() { + return { + attrs: { + addition: { + default: true, + }, + }, + parseDOM: [ + { tag: 'span.idiff.addition', attrs: { addition: true } }, + { tag: 'span.idiff.deletion', attrs: { addition: false } }, + ], + toDOM: node => [ + 'span', + { class: `idiff left right ${node.attrs.addition ? 'addition' : 'deletion'}` }, + 0, + ], + }; + } + + get toMarkdown() { + return { + mixable: true, + open(state, mark) { + return mark.attrs.addition ? '{+' : '{-'; + }, + close(state, mark) { + return mark.attrs.addition ? '+}' : '-}'; + }, + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js new file mode 100644 index 00000000000..ebed8698e21 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js @@ -0,0 +1,46 @@ +/* eslint-disable class-methods-use-this */ + +import { Mark } from 'tiptap'; +import _ from 'underscore'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class InlineHTML extends Mark { + get name() { + return 'inline_html'; + } + + get schema() { + return { + excludes: '', + attrs: { + tag: {}, + title: { default: null }, + }, + parseDOM: [ + { + tag: 'sup, sub, kbd, q, samp, var', + getAttrs: el => ({ tag: el.nodeName.toLowerCase() }), + }, + { + tag: 'abbr', + getAttrs: el => ({ tag: 'abbr', title: el.getAttribute('title') }), + }, + ], + toDOM: node => [node.attrs.tag, { title: node.attrs.title }, 0], + }; + } + + get toMarkdown() { + return { + mixable: true, + open(state, mark) { + return `<${mark.attrs.tag}${ + mark.attrs.title ? ` title="${state.esc(_.escape(mark.attrs.title))}"` : '' + }>`; + }, + close(state, mark) { + return `</${mark.attrs.tag}>`; + }, + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/italic.js b/app/assets/javascripts/behaviors/markdown/marks/italic.js new file mode 100644 index 00000000000..44b35c97739 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/italic.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { Italic as BaseItalic } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Italic extends BaseItalic { + get toMarkdown() { + return defaultMarkdownSerializer.marks.em; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/link.js b/app/assets/javascripts/behaviors/markdown/marks/link.js new file mode 100644 index 00000000000..5c23d6a5ceb --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/link.js @@ -0,0 +1,21 @@ +/* eslint-disable class-methods-use-this */ + +import { Link as BaseLink } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Link extends BaseLink { + get toMarkdown() { + return { + mixable: true, + open(state, mark, parent, index) { + const open = defaultMarkdownSerializer.marks.link.open(state, mark, parent, index); + return open === '<' ? '' : open; + }, + close(state, mark, parent, index) { + const close = defaultMarkdownSerializer.marks.link.close(state, mark, parent, index); + return close === '>' ? '' : close; + }, + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/math.js b/app/assets/javascripts/behaviors/markdown/marks/math.js new file mode 100644 index 00000000000..e582fb18f15 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/math.js @@ -0,0 +1,41 @@ +/* eslint-disable class-methods-use-this */ + +import { Mark } from 'tiptap'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MathFilter +export default class MathMark extends Mark { + get name() { + return 'math'; + } + + get schema() { + return { + parseDOM: [ + // Matches HTML generated by Banzai::Filter::MathFilter + { + tag: 'code.code.math[data-math-style=inline]', + priority: 51, + }, + // Matches HTML after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js + { + tag: 'span.katex', + contentElement: 'annotation[encoding="application/x-tex"]', + }, + ], + toDOM: () => ['code', { class: 'code math', 'data-math-style': 'inline' }, 0], + }; + } + + get toMarkdown() { + return { + escape: false, + open(state, mark, parent, index) { + return `$${defaultMarkdownSerializer.marks.code.open(state, mark, parent, index)}`; + }, + close(state, mark, parent, index) { + return `${defaultMarkdownSerializer.marks.code.close(state, mark, parent, index)}$`; + }, + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/strike.js b/app/assets/javascripts/behaviors/markdown/marks/strike.js new file mode 100644 index 00000000000..c2951a40a4b --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/strike.js @@ -0,0 +1,15 @@ +/* eslint-disable class-methods-use-this */ + +import { Strike as BaseStrike } from 'tiptap-extensions'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Strike extends BaseStrike { + get toMarkdown() { + return { + open: '~~', + close: '~~', + mixable: true, + expelEnclosingWhitespace: true, + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js new file mode 100644 index 00000000000..b0bc8f79643 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js @@ -0,0 +1,13 @@ +/* eslint-disable class-methods-use-this */ + +import { Blockquote as BaseBlockquote } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Blockquote extends BaseBlockquote { + toMarkdown(state, node) { + if (!node.childCount) return; + + defaultMarkdownSerializer.nodes.blockquote(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js new file mode 100644 index 00000000000..3b0792e1af8 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { BulletList as BaseBulletList } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class BulletList extends BaseBulletList { + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.bullet_list(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js new file mode 100644 index 00000000000..1e0c05eff08 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js @@ -0,0 +1,99 @@ +/* eslint-disable class-methods-use-this */ + +import { CodeBlock as BaseCodeBlock } from 'tiptap-extensions'; + +const PLAINTEXT_LANG = 'plaintext'; + +// Transforms generated HTML back to GFM for: +// - Banzai::Filter::SyntaxHighlightFilter +// - Banzai::Filter::MathFilter +// - Banzai::Filter::MermaidFilter +// - Banzai::Filter::SuggestionFilter +export default class CodeBlock extends BaseCodeBlock { + get schema() { + return { + content: 'text*', + marks: '', + group: 'block', + code: true, + defining: true, + attrs: { + lang: { default: PLAINTEXT_LANG }, + }, + parseDOM: [ + // Matches HTML generated by Banzai::Filter::SyntaxHighlightFilter, Banzai::Filter::MathFilter, Banzai::Filter::MermaidFilter, or Banzai::Filter::SuggestionFilter + { + tag: 'pre.code.highlight', + preserveWhitespace: 'full', + getAttrs: el => { + const lang = el.getAttribute('lang'); + if (!lang || lang === '') return {}; + + return { lang }; + }, + }, + // Matches HTML generated by Banzai::Filter::MathFilter, + // after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js + { + tag: 'span.katex-display', + preserveWhitespace: 'full', + contentElement: 'annotation[encoding="application/x-tex"]', + attrs: { lang: 'math' }, + }, + // Matches HTML generated by Banzai::Filter::MermaidFilter, + // after being transformed by app/assets/javascripts/behaviors/markdown/render_mermaid.js + { + tag: 'svg.mermaid', + preserveWhitespace: 'full', + contentElement: 'text.source', + attrs: { lang: 'mermaid' }, + }, + // Matches HTML generated by Banzai::Filter::SuggestionFilter, + // after being transformed by app/assets/javascripts/vue_shared/components/markdown/suggestions.vue + { + tag: '.md-suggestion', + skip: true, + }, + { + tag: '.md-suggestion-header', + ignore: true, + }, + { + tag: '.md-suggestion-diff', + preserveWhitespace: 'full', + getContent: (el, schema) => + [...el.querySelectorAll('.line_content.new span')].map(span => + schema.text(span.innerText), + ), + attrs: { lang: 'suggestion' }, + }, + ], + toDOM: node => ['pre', { class: 'code highlight', lang: node.attrs.lang }, ['code', 0]], + }; + } + + toMarkdown(state, node) { + if (!node.childCount) return; + + const { + textContent: text, + attrs: { lang }, + } = node; + + // Prefixes lines with 4 spaces if the code contains a line that starts with triple backticks + if (lang === PLAINTEXT_LANG && text.match(/^```/gm)) { + state.wrapBlock(' ', null, node, () => state.text(text, false)); + return; + } + + state.write('```'); + if (lang !== PLAINTEXT_LANG) state.write(lang); + + state.ensureNewLine(); + state.text(text, false); + state.ensureNewLine(); + + state.write('```'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_details.js b/app/assets/javascripts/behaviors/markdown/nodes/description_details.js new file mode 100644 index 00000000000..a4451d8ce8d --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/description_details.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class DescriptionDetails extends Node { + get name() { + return 'description_details'; + } + + get schema() { + return { + content: 'text*', + marks: '', + defining: true, + parseDOM: [{ tag: 'dd' }], + toDOM: () => ['dd', 0], + }; + } + + toMarkdown(state, node) { + state.flushClose(1); + state.write('<dd>'); + state.text(node.textContent, false); + state.write('</dd>'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_list.js b/app/assets/javascripts/behaviors/markdown/nodes/description_list.js new file mode 100644 index 00000000000..6aa1aca29d7 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/description_list.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class DescriptionList extends Node { + get name() { + return 'description_list'; + } + + get schema() { + return { + content: '(description_term+ description_details+)+', + group: 'block', + parseDOM: [{ tag: 'dl' }], + toDOM: () => ['dl', 0], + }; + } + + toMarkdown(state, node) { + state.write('<dl>\n'); + state.wrapBlock(' ', null, node, () => state.renderContent(node)); + state.flushClose(1); + state.ensureNewLine(); + state.write('</dl>'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_term.js b/app/assets/javascripts/behaviors/markdown/nodes/description_term.js new file mode 100644 index 00000000000..89057ec6444 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/description_term.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class DescriptionTerm extends Node { + get name() { + return 'description_term'; + } + + get schema() { + return { + content: 'text*', + marks: '', + defining: true, + parseDOM: [{ tag: 'dt' }], + toDOM: () => ['dt', 0], + }; + } + + toMarkdown(state, node) { + state.flushClose(state.closed && state.closed.type === node.type ? 1 : 2); + state.write('<dt>'); + state.text(node.textContent, false); + state.write('</dt>'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/details.js b/app/assets/javascripts/behaviors/markdown/nodes/details.js new file mode 100644 index 00000000000..1c40dbb8168 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/details.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Details extends Node { + get name() { + return 'details'; + } + + get schema() { + return { + content: 'summary block*', + group: 'block', + parseDOM: [{ tag: 'details' }], + toDOM: () => ['details', { open: true, onclick: 'return false', tabindex: '-1' }, 0], + }; + } + + toMarkdown(state, node) { + state.write('<details>\n'); + state.renderContent(node); + state.flushClose(1); + state.ensureNewLine(); + state.write('</details>'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/doc.js b/app/assets/javascripts/behaviors/markdown/nodes/doc.js new file mode 100644 index 00000000000..88b16fd85da --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/doc.js @@ -0,0 +1,15 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +export default class Doc extends Node { + get name() { + return 'doc'; + } + + get schema() { + return { + content: 'block+', + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js new file mode 100644 index 00000000000..a7cc3e828f5 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js @@ -0,0 +1,41 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::EmojiFilter +export default class Emoji extends Node { + get name() { + return 'emoji'; + } + + get schema() { + return { + inline: true, + group: 'inline', + attrs: { + name: {}, + title: {}, + moji: {}, + }, + parseDOM: [ + { + tag: 'gl-emoji', + getAttrs: el => ({ + name: el.dataset.name, + title: el.getAttribute('title'), + moji: el.textContent, + }), + }, + ], + toDOM: node => [ + 'gl-emoji', + { 'data-name': node.attrs.name, title: node.attrs.title }, + node.attrs.moji, + ], + }; + } + + toMarkdown(state, node) { + state.write(`:${node.attrs.name}:`); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js b/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js new file mode 100644 index 00000000000..59e5d8ab3e2 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js @@ -0,0 +1,10 @@ +/* eslint-disable class-methods-use-this */ + +import { HardBreak as BaseHardBreak } from 'tiptap-extensions'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class HardBreak extends BaseHardBreak { + toMarkdown(state) { + if (!state.atBlank()) state.write(' \n'); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/heading.js b/app/assets/javascripts/behaviors/markdown/nodes/heading.js new file mode 100644 index 00000000000..fec8608cf5d --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/heading.js @@ -0,0 +1,13 @@ +/* eslint-disable class-methods-use-this */ + +import { Heading as BaseHeading } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Heading extends BaseHeading { + toMarkdown(state, node) { + if (!node.childCount) return; + + defaultMarkdownSerializer.nodes.heading(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js new file mode 100644 index 00000000000..695c7160bde --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { HorizontalRule as BaseHorizontalRule } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class HorizontalRule extends BaseHorizontalRule { + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.horizontal_rule(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js new file mode 100644 index 00000000000..c225a5ed876 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js @@ -0,0 +1,52 @@ +/* eslint-disable class-methods-use-this */ + +import { Image as BaseImage } from 'tiptap-extensions'; +import { placeholderImage } from '~/lazy_loader'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +export default class Image extends BaseImage { + get schema() { + return { + attrs: { + src: {}, + alt: { + default: null, + }, + title: { + default: null, + }, + }, + group: 'inline', + inline: true, + draggable: true, + parseDOM: [ + // Matches HTML generated by Banzai::Filter::ImageLinkFilter + { + tag: 'a.no-attachment-icon', + priority: 51, + skip: true, + }, + // Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter + { + tag: 'img[src]', + getAttrs: el => { + const imageSrc = el.src; + const imageUrl = + imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || ''; + + return { + src: imageUrl, + title: el.getAttribute('title'), + alt: el.getAttribute('alt'), + }; + }, + }, + ], + toDOM: node => ['img', node.attrs], + }; + } + + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.image(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js new file mode 100644 index 00000000000..4237637ed9a --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { ListItem as BaseListItem } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class ListItem extends BaseListItem { + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.list_item(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js new file mode 100644 index 00000000000..4c1542d14ea --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js @@ -0,0 +1,10 @@ +/* eslint-disable class-methods-use-this */ + +import { OrderedList as BaseOrderedList } from 'tiptap-extensions'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class OrderedList extends BaseOrderedList { + toMarkdown(state, node) { + state.renderList(node, ' ', () => '1. '); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js new file mode 100644 index 00000000000..25c4976a1bc --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter +export default class OrderedTaskList extends Node { + get name() { + return 'ordered_task_list'; + } + + get schema() { + return { + group: 'block', + content: '(task_list_item|list_item)+', + parseDOM: [ + { + priority: 51, + tag: 'ol.task-list', + }, + ], + toDOM: () => ['ol', { class: 'task-list' }, 0], + }; + } + + toMarkdown(state, node) { + state.renderList(node, ' ', () => '1. '); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js new file mode 100644 index 00000000000..dec3207b1bb --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js @@ -0,0 +1,24 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Paragraph extends Node { + get name() { + return 'paragraph'; + } + + get schema() { + return { + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'p' }], + toDOM: () => ['p', 0], + }; + } + + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.paragraph(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/reference.js b/app/assets/javascripts/behaviors/markdown/nodes/reference.js new file mode 100644 index 00000000000..5d6bbeca833 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/reference.js @@ -0,0 +1,52 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::ReferenceFilter and subclasses +export default class Reference extends Node { + get name() { + return 'reference'; + } + + get schema() { + return { + inline: true, + group: 'inline', + atom: true, + attrs: { + className: {}, + referenceType: {}, + originalText: { default: null }, + href: {}, + text: {}, + }, + parseDOM: [ + { + tag: 'a.gfm:not([data-link=true])', + priority: 51, + getAttrs: el => ({ + className: el.className, + referenceType: el.dataset.referenceType, + originalText: el.dataset.original, + href: el.getAttribute('href'), + text: el.textContent, + }), + }, + ], + toDOM: node => [ + 'a', + { + class: node.attrs.className, + href: node.attrs.href, + 'data-reference-type': node.attrs.referenceType, + 'data-original': node.attrs.originalText, + }, + node.attrs.text, + ], + }; + } + + toMarkdown(state, node) { + state.write(node.attrs.originalText || node.attrs.text); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/summary.js b/app/assets/javascripts/behaviors/markdown/nodes/summary.js new file mode 100644 index 00000000000..2e36e316d71 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/summary.js @@ -0,0 +1,27 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Summary extends Node { + get name() { + return 'summary'; + } + + get schema() { + return { + content: 'text*', + marks: '', + defining: true, + parseDOM: [{ tag: 'summary' }], + toDOM: () => ['summary', 0], + }; + } + + toMarkdown(state, node) { + state.write('<summary>'); + state.text(node.textContent, false); + state.write('</summary>'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table.js b/app/assets/javascripts/behaviors/markdown/nodes/table.js new file mode 100644 index 00000000000..a7fcb9227cd --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table.js @@ -0,0 +1,25 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Table extends Node { + get name() { + return 'table'; + } + + get schema() { + return { + content: 'table_head table_body', + group: 'block', + isolating: true, + parseDOM: [{ tag: 'table' }], + toDOM: () => ['table', 0], + }; + } + + toMarkdown(state, node) { + state.renderContent(node); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_body.js b/app/assets/javascripts/behaviors/markdown/nodes/table_body.js new file mode 100644 index 00000000000..403556dc0c8 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_body.js @@ -0,0 +1,24 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class TableBody extends Node { + get name() { + return 'table_body'; + } + + get schema() { + return { + content: 'table_row+', + parseDOM: [{ tag: 'tbody' }], + toDOM: () => ['tbody', 0], + }; + } + + toMarkdown(state, node) { + state.flushClose(1); + state.renderContent(node); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js b/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js new file mode 100644 index 00000000000..c63bfe10e39 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js @@ -0,0 +1,35 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class TableCell extends Node { + get name() { + return 'table_cell'; + } + + get schema() { + return { + attrs: { + header: { default: false }, + align: { default: null }, + }, + content: 'inline*', + isolating: true, + parseDOM: [ + { + tag: 'td, th', + getAttrs: el => ({ + header: el.tagName === 'TH', + align: el.getAttribute('align') || el.style.textAlign, + }), + }, + ], + toDOM: node => [node.attrs.header ? 'th' : 'td', { align: node.attrs.align }, 0], + }; + } + + toMarkdown(state, node) { + state.renderInline(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_head.js b/app/assets/javascripts/behaviors/markdown/nodes/table_head.js new file mode 100644 index 00000000000..4cb94bf088c --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_head.js @@ -0,0 +1,24 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class TableHead extends Node { + get name() { + return 'table_head'; + } + + get schema() { + return { + content: 'table_header_row', + parseDOM: [{ tag: 'thead' }], + toDOM: () => ['thead', 0], + }; + } + + toMarkdown(state, node) { + state.flushClose(1); + state.renderContent(node); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js new file mode 100644 index 00000000000..e7eee636402 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js @@ -0,0 +1,43 @@ +/* eslint-disable class-methods-use-this */ + +import TableRow from './table_row'; + +const CENTER_ALIGN = 'center'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class TableHeaderRow extends TableRow { + get name() { + return 'table_header_row'; + } + + get schema() { + return { + content: 'table_cell+', + parseDOM: [ + { + tag: 'thead tr', + priority: 51, + }, + ], + toDOM: () => ['tr', 0], + }; + } + + toMarkdown(state, node) { + const cellWidths = super.toMarkdown(state, node); + + state.flushClose(1); + + state.write('|'); + node.forEach((cell, _, i) => { + if (i) state.write('|'); + + state.write(cell.attrs.align === CENTER_ALIGN ? ':' : '-'); + state.write(state.repeat('-', cellWidths[i])); + state.write(cell.attrs.align === CENTER_ALIGN || cell.attrs.align === 'right' ? ':' : '-'); + }); + state.write('|'); + + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js new file mode 100644 index 00000000000..20c7fa8a9ab --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js @@ -0,0 +1,33 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::TableOfContentsFilter +export default class TableOfContents extends Node { + get name() { + return 'table_of_contents'; + } + + get schema() { + return { + group: 'block', + atom: true, + parseDOM: [ + { + tag: 'ul.section-nav', + priority: 51, + }, + { + tag: 'p.table-of-contents', + priority: 51, + }, + ], + toDOM: () => ['p', { class: 'table-of-contents' }, 'Table of Contents'], + }; + } + + toMarkdown(state, node) { + state.write('[[_TOC_]]'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_row.js new file mode 100644 index 00000000000..5852502773a --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_row.js @@ -0,0 +1,38 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class TableRow extends Node { + get name() { + return 'table_row'; + } + + get schema() { + return { + content: 'table_cell+', + parseDOM: [{ tag: 'tr' }], + toDOM: () => ['tr', 0], + }; + } + + toMarkdown(state, node) { + const cellWidths = []; + + state.flushClose(1); + + state.write('| '); + node.forEach((cell, _, i) => { + if (i) state.write(' | '); + + const { length } = state.out; + state.render(cell, node, i); + cellWidths.push(state.out.length - length); + }); + state.write(' |'); + + state.closeBlock(node); + + return cellWidths; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js new file mode 100644 index 00000000000..ab33bc21502 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter +export default class TaskList extends Node { + get name() { + return 'task_list'; + } + + get schema() { + return { + group: 'block', + content: '(task_list_item|list_item)+', + parseDOM: [ + { + priority: 51, + tag: 'ul.task-list', + }, + ], + toDOM: () => ['ul', { class: 'task-list' }, 0], + }; + } + + toMarkdown(state, node) { + state.renderList(node, ' ', () => '* '); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js new file mode 100644 index 00000000000..d0ee7333d5e --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js @@ -0,0 +1,49 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter +export default class TaskListItem extends Node { + get name() { + return 'task_list_item'; + } + + get schema() { + return { + attrs: { + done: { + default: false, + }, + }, + defining: true, + draggable: false, + content: 'paragraph block*', + parseDOM: [ + { + priority: 51, + tag: 'li.task-list-item', + getAttrs: el => { + const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox'); + return { done: checkbox && checkbox.checked }; + }, + }, + ], + toDOM(node) { + return [ + 'li', + { class: 'task-list-item' }, + [ + 'input', + { type: 'checkbox', class: 'task-list-item-checkbox', checked: node.attrs.done }, + ], + ['div', { class: 'todo-content' }, 0], + ]; + }, + }; + } + + toMarkdown(state, node) { + state.write(`[${node.attrs.done ? 'x' : ' '}] `); + state.renderContent(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/text.js b/app/assets/javascripts/behaviors/markdown/nodes/text.js new file mode 100644 index 00000000000..84838c14999 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/text.js @@ -0,0 +1,20 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +export default class Text extends Node { + get name() { + return 'text'; + } + + get schema() { + return { + group: 'inline', + }; + } + + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.text(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/video.js b/app/assets/javascripts/behaviors/markdown/nodes/video.js new file mode 100644 index 00000000000..516f983397d --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/video.js @@ -0,0 +1,54 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::VideoLinkFilter +export default class Video extends Node { + get name() { + return 'video'; + } + + get schema() { + return { + attrs: { + src: {}, + alt: { + default: null, + }, + }, + group: 'block', + draggable: true, + parseDOM: [ + { + tag: '.video-container', + skip: true, + }, + { + tag: '.video-container p', + priority: 51, + ignore: true, + }, + { + tag: 'video[src]', + getAttrs: el => ({ src: el.getAttribute('src'), alt: el.dataset.title }), + }, + ], + toDOM: node => [ + 'video', + { + src: node.attrs.src, + width: '400', + controls: true, + 'data-setup': '{}', + 'data-title': node.attrs.alt, + }, + ], + }; + } + + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.image(state, node); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index 35380ca49fb..798114b4b0b 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -1,4 +1,5 @@ import flash from '~/flash'; +import { sprintf, __ } from '../../locale'; // Renders diagrams and flowcharts from text using Mermaid in any element with the // `js-render-mermaid` class. @@ -14,6 +15,9 @@ import flash from '~/flash'; // </pre> // +// This is an arbitary number; Can be iterated upon when suitable. +const MAX_CHAR_LIMIT = 5000; + export default function renderMermaid($els) { if (!$els.length) return; @@ -34,6 +38,21 @@ export default function renderMermaid($els) { $els.each((i, el) => { const source = el.textContent; + /** + * Restrict the rendering to a certain amount of character to + * prevent mermaidjs from hanging up the entire thread and + * causing a DoS. + */ + if (source && source.length > MAX_CHAR_LIMIT) { + el.textContent = sprintf( + __( + 'Cannot render the image. Maximum character count (%{charLimit}) has been exceeded.', + ), + { charLimit: MAX_CHAR_LIMIT }, + ); + return; + } + // Remove any extra spans added by the backend syntax highlighting. Object.assign(el, { textContent: source }); diff --git a/app/assets/javascripts/behaviors/markdown/schema.js b/app/assets/javascripts/behaviors/markdown/schema.js new file mode 100644 index 00000000000..163182ab778 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/schema.js @@ -0,0 +1,24 @@ +import { Schema } from 'prosemirror-model'; +import editorExtensions from './editor_extensions'; + +const nodes = editorExtensions + .filter(extension => extension.type === 'node') + .reduce( + (ns, { name, schema }) => ({ + ...ns, + [name]: schema, + }), + {}, + ); + +const marks = editorExtensions + .filter(extension => extension.type === 'mark') + .reduce( + (ms, { name, schema }) => ({ + ...ms, + [name]: schema, + }), + {}, + ); + +export default new Schema({ nodes, marks }); diff --git a/app/assets/javascripts/behaviors/markdown/serializer.js b/app/assets/javascripts/behaviors/markdown/serializer.js new file mode 100644 index 00000000000..70dbd8bd206 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/serializer.js @@ -0,0 +1,24 @@ +import { MarkdownSerializer } from 'prosemirror-markdown'; +import editorExtensions from './editor_extensions'; + +const nodes = editorExtensions + .filter(extension => extension.type === 'node') + .reduce( + (ns, { name, toMarkdown }) => ({ + ...ns, + [name]: toMarkdown, + }), + {}, + ); + +const marks = editorExtensions + .filter(extension => extension.type === 'mark') + .reduce( + (ms, { name, toMarkdown }) => ({ + ...ms, + [name]: toMarkdown, + }), + {}, + ); + +export default new MarkdownSerializer(nodes, marks); diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index 35f1bb6b080..7adccbb062f 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -28,16 +28,13 @@ MarkdownPreview.prototype.ajaxCache = {}; MarkdownPreview.prototype.showPreview = function($form) { var mdText; - var markdownVersion; - var url; var preview = $form.find('.js-md-preview'); + var url = preview.data('url'); if (preview.hasClass('md-preview-loading')) { return; } mdText = $form.find('textarea.markdown-area').val(); - markdownVersion = $form.attr('data-markdown-version'); - url = this.versionedPreviewPath(preview.data('url'), markdownVersion); if (mdText.trim().length === 0) { preview.text(this.emptyMessage); @@ -67,16 +64,6 @@ MarkdownPreview.prototype.showPreview = function($form) { } }; -MarkdownPreview.prototype.versionedPreviewPath = function(markdownPreviewPath, markdownVersion) { - if (typeof markdownVersion === 'undefined') { - return markdownPreviewPath; - } - - return `${markdownPreviewPath}${ - markdownPreviewPath.indexOf('?') === -1 ? '?' : '&' - }markdown_version=${markdownVersion}`; -}; - MarkdownPreview.prototype.fetchMarkdownPreview = function(text, url, success) { if (!url) { return; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 2918e1486a7..680f2031409 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -1,6 +1,5 @@ import $ from 'jquery'; import Mousetrap from 'mousetrap'; -import _ from 'underscore'; import Sidebar from '../../right_sidebar'; import Shortcuts from './shortcuts'; import { CopyAsGFM } from '../markdown/copy_as_gfm'; @@ -63,28 +62,32 @@ export default class ShortcutsIssuable extends Shortcuts { } const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true)); - const selected = CopyAsGFM.nodeToGFM(el); - - if (selected.trim() === '') { - return false; - } - - const quote = _.map(selected.split('\n'), val => `${`> ${val}`.trim()}\n`); - - // If replyField already has some content, add a newline before our quote - const separator = ($replyField.val().trim() !== '' && '\n\n') || ''; - $replyField - .val((a, current) => `${current}${separator}${quote.join('')}\n`) - .trigger('input') - .trigger('change'); - - // Trigger autosize - const event = document.createEvent('Event'); - event.initEvent('autosize:update', true, false); - $replyField.get(0).dispatchEvent(event); + const blockquoteEl = document.createElement('blockquote'); + blockquoteEl.appendChild(el); + CopyAsGFM.nodeToGFM(blockquoteEl) + .then(text => { + if (text.trim() === '') { + return false; + } + + // If replyField already has some content, add a newline before our quote + const separator = ($replyField.val().trim() !== '' && '\n\n') || ''; + $replyField + .val((a, current) => `${current}${separator}${text}\n\n`) + .trigger('input') + .trigger('change'); + + // Trigger autosize + const event = document.createEvent('Event'); + event.initEvent('autosize:update', true, false); + $replyField.get(0).dispatchEvent(event); + + // Focus the input field + $replyField.focus(); - // Focus the input field - $replyField.focus(); + return false; + }) + .catch(() => {}); return false; } diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index b07f951346e..5f64175362d 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -16,6 +16,7 @@ export default () => { const filePath = editBlobForm.data('blobFilename'); const currentAction = $('.js-file-title').data('currentAction'); const projectId = editBlobForm.data('project-id'); + const isMarkdown = editBlobForm.data('is-markdown'); const commitButton = $('.js-commit-button'); const cancelLink = $('.btn.btn-cancel'); @@ -27,7 +28,13 @@ export default () => { window.onbeforeunload = null; }); - new EditBlob(`${urlRoot}${assetsPath}`, filePath, currentAction, projectId); + new EditBlob({ + assetsPath: `${urlRoot}${assetsPath}`, + filePath, + currentAction, + projectId, + isMarkdown, + }); new NewCommitForm(editBlobForm); // returning here blocks page navigation diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 6e19548eed2..011898a5e7a 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -6,22 +6,31 @@ import createFlash from '~/flash'; import { __ } from '~/locale'; import TemplateSelectorMediator from '../blob/file_template_mediator'; import getModeByFileExtension from '~/lib/utils/ace_utils'; +import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; export default class EditBlob { - constructor(assetsPath, aceMode, currentAction, projectId) { - this.configureAceEditor(aceMode, assetsPath); + // The options object has: + // assetsPath, filePath, currentAction, projectId, isMarkdown + constructor(options) { + this.options = options; + this.configureAceEditor(); this.initModePanesAndLinks(); this.initSoftWrap(); - this.initFileSelectors(currentAction, projectId); + this.initFileSelectors(); } - configureAceEditor(filePath, assetsPath) { + configureAceEditor() { + const { filePath, assetsPath, isMarkdown } = this.options; ace.config.set('modePath', `${assetsPath}/ace`); ace.config.loadModule('ace/ext/searchbox'); ace.config.loadModule('ace/ext/modelist'); this.editor = ace.edit('editor'); + if (isMarkdown) { + addEditorMarkdownListeners(this.editor); + } + // This prevents warnings re: automatic scrolling being logged this.editor.$blockScrolling = Infinity; @@ -32,7 +41,8 @@ export default class EditBlob { } } - initFileSelectors(currentAction, projectId) { + initFileSelectors() { + const { currentAction, projectId } = this.options; this.fileTemplateMediator = new TemplateSelectorMediator({ currentAction, editor: this.editor, diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 30fbdb9e97f..f569322ab70 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -86,7 +86,7 @@ export default { class="board-card" @mousedown="mouseDown" @mousemove="mouseMove" - @mouseup="showIssue($event);" + @mouseup="showIssue($event)" > <issue-card-inner :list="list" diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 93bcb4e129e..28d96dab605 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -96,7 +96,7 @@ export default { <template> <div class="board-new-issue-form"> <div class="board-card"> - <form @submit="submit($event);"> + <form @submit="submit($event)"> <div v-if="error" class="flash-container"> <div class="flash-alert">An error occurred. Please try again.</div> </div> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 0f581c3d37d..90ab3a76342 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -184,7 +184,7 @@ export default { :title="label.description" class="badge color-label append-right-4 prepend-top-4" type="button" - @click="filterByLabel(label);" + @click="filterByLabel(label)" > {{ label.title }} </button> diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index e038198e6f0..9bc66978198 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -3,7 +3,12 @@ import dateFormat from 'dateformat'; import { GlTooltip } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import { __ } from '~/locale'; -import { getDayDifference, getTimeago, dateInWords } from '~/lib/utils/datetime_utility'; +import { + getDayDifference, + getTimeago, + dateInWords, + parsePikadayDate, +} from '~/lib/utils/datetime_utility'; export default { components: { @@ -48,13 +53,13 @@ export default { } else if (timeDifference === -1) { return __('Yesterday'); } else if (timeDifference > 0 && timeDifference < 7) { - return dateFormat(issueDueDate, 'dddd', true); + return dateFormat(issueDueDate, 'dddd'); } return standardDateFormat; }, issueDueDate() { - return new Date(this.date); + return parsePikadayDate(this.date); }, timeDifference() { const today = new Date(); diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue index 08408eb0b52..2a0008467c4 100644 --- a/app/assets/javascripts/boards/components/modal/empty_state.vue +++ b/app/assets/javascripts/boards/components/modal/empty_state.vue @@ -45,7 +45,7 @@ export default { <section class="empty-state"> <div class="row"> <div class="col-12 col-md-6 order-md-last"> - <aside class="svg-content"><img :src="emptyStateSvg" /></aside> + <aside class="svg-content d-none d-md-block"><img :src="emptyStateSvg" /></aside> </div> <div class="col-12 col-md-6 order-md-first"> <div class="text-content"> @@ -58,7 +58,7 @@ export default { v-if="activeTab === 'selected'" class="btn btn-default" type="button" - @click="changeTab('all');" + @click="changeTab('all')" > Open issues </button> diff --git a/app/assets/javascripts/boards/components/modal/footer.vue b/app/assets/javascripts/boards/components/modal/footer.vue index b1bc7d87086..d4afd9d59da 100644 --- a/app/assets/javascripts/boards/components/modal/footer.vue +++ b/app/assets/javascripts/boards/components/modal/footer.vue @@ -71,7 +71,7 @@ export default { <span class="inline add-issues-footer-to-list"> to list </span> <lists-dropdown /> </div> - <button class="btn btn-default float-right" type="button" @click="toggleModal(false);"> + <button class="btn btn-default float-right" type="button" @click="toggleModal(false)"> Cancel </button> </footer> diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue index d0e285a149e..1f0961e02d8 100644 --- a/app/assets/javascripts/boards/components/modal/header.vue +++ b/app/assets/javascripts/boards/components/modal/header.vue @@ -58,7 +58,7 @@ export default { class="close" data-dismiss="modal" aria-label="Close" - @click="toggleModal(false);" + @click="toggleModal(false)" > <span aria-hidden="true">×</span> </button> diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue index 878bb002c6c..e9ed2de859d 100644 --- a/app/assets/javascripts/boards/components/modal/list.vue +++ b/app/assets/javascripts/boards/components/modal/list.vue @@ -130,7 +130,7 @@ export default { <div :class="{ 'is-active': issue.selected }" class="board-card" - @click="toggleIssue($event, issue);" + @click="toggleIssue($event, issue)" > <issue-card-inner :issue="issue" :issue-link-base="issueLinkBase" :root-path="rootPath" /> <icon diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.vue b/app/assets/javascripts/boards/components/modal/lists_dropdown.vue index 820d0679df5..3fbe8fe1be7 100644 --- a/app/assets/javascripts/boards/components/modal/lists_dropdown.vue +++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.vue @@ -38,7 +38,7 @@ export default { :class="{ 'is-active': list.id == selected.id }" href="#" role="button" - @click.prevent="modal.selectedList = list;" + @click.prevent="modal.selectedList = list" > <span :style="{ backgroundColor: list.label.color }" class="dropdown-label-box"> </span> {{ list.title }} diff --git a/app/assets/javascripts/boards/components/modal/tabs.vue b/app/assets/javascripts/boards/components/modal/tabs.vue index 7b800a6ab97..2d2920e312e 100644 --- a/app/assets/javascripts/boards/components/modal/tabs.vue +++ b/app/assets/javascripts/boards/components/modal/tabs.vue @@ -21,12 +21,12 @@ export default { <div class="top-area prepend-top-10 append-bottom-10"> <ul class="nav-links issues-state-filters"> <li :class="{ active: activeTab == 'all' }"> - <a href="#" role="button" @click.prevent="changeTab('all');"> + <a href="#" role="button" @click.prevent="changeTab('all')"> Open issues <span class="badge badge-pill"> {{ issuesCount }} </span> </a> </li> <li :class="{ active: activeTab == 'selected' }"> - <a href="#" role="button" @click.prevent="changeTab('selected');"> + <a href="#" role="button" @click.prevent="changeTab('selected')"> Selected issues <span class="badge badge-pill"> {{ selectedCount }} </span> </a> </li> diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index d899b7fbd8c..8274647744f 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -82,7 +82,7 @@ export default { <template> <div> <label class="label-bold prepend-top-10"> Project </label> - <div ref="projectsDropdown" class="dropdown"> + <div ref="projectsDropdown" class="dropdown dropdown-projects"> <button class="dropdown-menu-toggle wide" type="button" diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index f88e9b55988..c4c5fedc615 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -58,6 +58,7 @@ export default () => { state: boardsStore.state, loading: true, boardsEndpoint: $boardApp.dataset.boardsEndpoint, + recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint, listsEndpoint: $boardApp.dataset.listsEndpoint, boardId: $boardApp.dataset.boardId, disabled: parseBoolean($boardApp.dataset.disabled), @@ -75,6 +76,7 @@ export default () => { created() { gl.boardService = new BoardService({ boardsEndpoint: this.boardsEndpoint, + recentBoardsEndpoint: this.recentBoardsEndpoint, listsEndpoint: this.listsEndpoint, bulkUpdatePath: this.bulkUpdatePath, boardId: this.boardId, diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index dd3feedbc0e..9f6d9a853da 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -244,6 +244,7 @@ class List { issue.project = data.project; issue.path = data.real_path; issue.referencePath = data.reference_path; + issue.assignableLabelsEndpoint = data.assignable_labels_endpoint; if (this.issuesSize > 1) { const moveBeforeId = this.issues[1].id; diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js index 3de6eb056c2..7d463f17ab1 100644 --- a/app/assets/javascripts/boards/services/board_service.js +++ b/app/assets/javascripts/boards/services/board_service.js @@ -2,12 +2,13 @@ import axios from '../../lib/utils/axios_utils'; import { mergeUrlParams } from '../../lib/utils/url_utility'; export default class BoardService { - constructor({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId }) { + constructor({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) { this.boardsEndpoint = boardsEndpoint; this.boardId = boardId; this.listsEndpoint = listsEndpoint; this.listsEndpointGenerate = `${listsEndpoint}/generate.json`; this.bulkUpdatePath = bulkUpdatePath; + this.recentBoardsEndpoint = `${recentBoardsEndpoint}.json`; } generateBoardsPath(id) { diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index cf70a48f076..c59fac42038 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -1,12 +1,18 @@ import Visibility from 'visibilityjs'; import Vue from 'vue'; -import initDismissableCallout from '~/dismissable_callout'; +import PersistentUserCallout from '../persistent_user_callout'; import { s__, sprintf } from '../locale'; import Flash from '../flash'; import Poll from '../lib/utils/poll'; import initSettingsPanels from '../settings_panels'; import eventHub from './event_hub'; -import { APPLICATION_STATUS, REQUEST_LOADING, REQUEST_SUCCESS, REQUEST_FAILURE } from './constants'; +import { + APPLICATION_STATUS, + REQUEST_SUBMITTED, + REQUEST_FAILURE, + UPGRADE_REQUESTED, + UPGRADE_REQUEST_FAILURE, +} from './constants'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; import Applications from './components/applications.vue'; @@ -32,6 +38,7 @@ export default class Clusters { installKnativePath, installPrometheusPath, managePrometheusPath, + hasRbac, clusterType, clusterStatus, clusterStatusReason, @@ -45,6 +52,7 @@ export default class Clusters { this.store.setManagePrometheusPath(managePrometheusPath); this.store.updateStatus(clusterStatus); this.store.updateStatusReason(clusterStatusReason); + this.store.updateRbac(hasRbac); this.service = new ClustersService({ endpoint: statusPath, installHelmEndpoint: installHelmPath, @@ -67,7 +75,7 @@ export default class Clusters { this.showTokenButton = document.querySelector('.js-show-cluster-token'); this.tokenField = document.querySelector('.js-cluster-token'); - initDismissableCallout('.js-cluster-security-warning'); + Clusters.initDismissableCallout(); initSettingsPanels(); setupToggleButtons(document.querySelector('.js-cluster-enable-toggle-area')); this.initApplications(clusterType); @@ -102,20 +110,32 @@ export default class Clusters { ingressHelpPath: this.state.ingressHelpPath, managePrometheusPath: this.state.managePrometheusPath, ingressDnsHelpPath: this.state.ingressDnsHelpPath, + rbac: this.state.rbac, }, }); }, }); } + static initDismissableCallout() { + const callout = document.querySelector('.js-cluster-security-warning'); + PersistentUserCallout.factory(callout); + } + addListeners() { if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken); eventHub.$on('installApplication', this.installApplication); + eventHub.$on('upgradeApplication', data => this.upgradeApplication(data)); + eventHub.$on('upgradeFailed', appId => this.upgradeFailed(appId)); + eventHub.$on('dismissUpgradeSuccess', appId => this.dismissUpgradeSuccess(appId)); } removeListeners() { if (this.showTokenButton) this.showTokenButton.removeEventListener('click', this.showToken); eventHub.$off('installApplication', this.installApplication); + eventHub.$off('upgradeApplication', this.upgradeApplication); + eventHub.$off('upgradeFailed', this.upgradeFailed); + eventHub.$off('dismissUpgradeSuccess', this.dismissUpgradeSuccess); } initPolling() { @@ -222,22 +242,33 @@ export default class Clusters { installApplication(data) { const appId = data.id; - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING); + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUBMITTED); this.store.updateAppProperty(appId, 'requestReason', null); + this.store.updateAppProperty(appId, 'statusReason', null); + + this.service.installApplication(appId, data.params).catch(() => { + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); + this.store.updateAppProperty( + appId, + 'requestReason', + s__('ClusterIntegration|Request to begin installing failed'), + ); + }); + } + + upgradeApplication(data) { + const appId = data.id; + this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUESTED); + this.store.updateAppProperty(appId, 'status', APPLICATION_STATUS.UPDATING); + this.service.installApplication(appId, data.params).catch(() => this.upgradeFailed(appId)); + } + + upgradeFailed(appId) { + this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUEST_FAILURE); + } - this.service - .installApplication(appId, data.params) - .then(() => { - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS); - }) - .catch(() => { - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); - this.store.updateAppProperty( - appId, - 'requestReason', - s__('ClusterIntegration|Request to begin installing failed'), - ); - }); + dismissUpgradeSuccess(appId) { + this.store.updateAppProperty(appId, 'requestStatus', null); } destroy() { diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index d4354dcfebd..5952e93b9a7 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -1,20 +1,24 @@ <script> /* eslint-disable vue/require-default-prop */ +import { GlLink } from '@gitlab/ui'; +import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; import { s__, sprintf } from '../../locale'; import eventHub from '../event_hub'; import identicon from '../../vue_shared/components/identicon.vue'; import loadingButton from '../../vue_shared/components/loading_button.vue'; import { APPLICATION_STATUS, - REQUEST_LOADING, - REQUEST_SUCCESS, + REQUEST_SUBMITTED, REQUEST_FAILURE, + UPGRADE_REQUESTED, } from '../constants'; export default { components: { loadingButton, identicon, + TimeagoTooltip, + GlLink, }, props: { id: { @@ -59,6 +63,18 @@ export default { type: String, required: false, }, + version: { + type: String, + required: false, + }, + chartRepo: { + type: String, + required: false, + }, + upgradeAvailable: { + type: Boolean, + required: false, + }, installApplicationRequestParams: { type: Object, required: false, @@ -72,11 +88,31 @@ export default { isKnownStatus() { return Object.values(APPLICATION_STATUS).includes(this.status); }, + isInstalling() { + return ( + this.status === APPLICATION_STATUS.SCHEDULED || + this.status === APPLICATION_STATUS.INSTALLING || + (this.requestStatus === REQUEST_SUBMITTED && !this.statusReason && !this.isInstalled) + ); + }, isInstalled() { return ( this.status === APPLICATION_STATUS.INSTALLED || this.status === APPLICATION_STATUS.UPDATED || - this.status === APPLICATION_STATUS.UPDATING + this.status === APPLICATION_STATUS.UPDATING || + this.status === APPLICATION_STATUS.UPDATE_ERRORED + ); + }, + canInstall() { + if (this.isInstalling) { + return false; + } + + return ( + this.status === APPLICATION_STATUS.NOT_INSTALLABLE || + this.status === APPLICATION_STATUS.INSTALLABLE || + this.status === APPLICATION_STATUS.ERROR || + this.isUnknownStatus ); }, hasLogo() { @@ -90,12 +126,7 @@ export default { return `js-cluster-application-row-${this.id}`; }, installButtonLoading() { - return ( - !this.status || - this.status === APPLICATION_STATUS.SCHEDULED || - this.status === APPLICATION_STATUS.INSTALLING || - this.requestStatus === REQUEST_LOADING - ); + return !this.status || this.status === APPLICATION_STATUS.SCHEDULED || this.isInstalling; }, installButtonDisabled() { // Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but @@ -104,30 +135,17 @@ export default { return ( ((this.status !== APPLICATION_STATUS.INSTALLABLE && this.status !== APPLICATION_STATUS.ERROR) || - this.requestStatus === REQUEST_LOADING || - this.requestStatus === REQUEST_SUCCESS) && + this.isInstalling) && this.isKnownStatus ); }, installButtonLabel() { let label; - if ( - this.status === APPLICATION_STATUS.NOT_INSTALLABLE || - this.status === APPLICATION_STATUS.INSTALLABLE || - this.status === APPLICATION_STATUS.ERROR || - this.isUnknownStatus - ) { + if (this.canInstall) { label = s__('ClusterIntegration|Install'); - } else if ( - this.status === APPLICATION_STATUS.SCHEDULED || - this.status === APPLICATION_STATUS.INSTALLING - ) { + } else if (this.isInstalling) { label = s__('ClusterIntegration|Installing'); - } else if ( - this.status === APPLICATION_STATUS.INSTALLED || - this.status === APPLICATION_STATUS.UPDATED || - this.status === APPLICATION_STATUS.UPDATING - ) { + } else if (this.isInstalled) { label = s__('ClusterIntegration|Installed'); } @@ -140,13 +158,79 @@ export default { return s__('ClusterIntegration|Manage'); }, hasError() { - return this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE; + return ( + !this.isInstalling && + (this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE) + ); }, generalErrorDescription() { return sprintf(s__('ClusterIntegration|Something went wrong while installing %{title}'), { title: this.title, }); }, + versionLabel() { + if (this.upgradeFailed) { + return s__('ClusterIntegration|Upgrade failed'); + } else if (this.isUpgrading) { + return s__('ClusterIntegration|Upgrading'); + } + + return s__('ClusterIntegration|Upgraded'); + }, + upgradeRequested() { + return this.requestStatus === UPGRADE_REQUESTED; + }, + upgradeSuccessful() { + return this.status === APPLICATION_STATUS.UPDATED; + }, + upgradeFailed() { + if (this.isUpgrading) { + return false; + } + + return this.status === APPLICATION_STATUS.UPDATE_ERRORED; + }, + upgradeFailureDescription() { + return sprintf( + s__( + 'ClusterIntegration|Something went wrong when upgrading %{title}. Please check the logs and try again.', + ), + { + title: this.title, + }, + ); + }, + upgradeSuccessDescription() { + return sprintf(s__('ClusterIntegration|%{title} upgraded successfully.'), { + title: this.title, + }); + }, + upgradeButtonLabel() { + let label; + if (this.upgradeAvailable && !this.upgradeFailed && !this.isUpgrading) { + label = s__('ClusterIntegration|Upgrade'); + } else if (this.isUpgrading) { + label = s__('ClusterIntegration|Upgrading'); + } else if (this.upgradeFailed) { + label = s__('ClusterIntegration|Retry upgrade'); + } + + return label; + }, + isUpgrading() { + // Since upgrading is handled asynchronously on the backend we need this check to prevent any delay on the frontend + return ( + this.status === APPLICATION_STATUS.UPDATING || + (this.upgradeRequested && !this.upgradeSuccessful) + ); + }, + }, + watch: { + status() { + if (this.status === APPLICATION_STATUS.UPDATE_ERRORED) { + eventHub.$emit('upgradeFailed', this.id); + } + }, }, methods: { installClicked() { @@ -155,6 +239,15 @@ export default { params: this.installApplicationRequestParams, }); }, + upgradeClicked() { + eventHub.$emit('upgradeApplication', { + id: this.id, + params: this.installApplicationRequestParams, + }); + }, + dismissUpgradeSuccess() { + eventHub.$emit('dismissUpgradeSuccess', this.id); + }, }, }; </script> @@ -208,6 +301,51 @@ export default { </li> </ul> </div> + + <div + v-if="(upgradeSuccessful || upgradeFailed) && !upgradeAvailable" + class="form-text text-muted label p-0 js-cluster-application-upgrade-details" + > + {{ versionLabel }} + + <span v-if="upgradeSuccessful"> to</span> + + <gl-link + v-if="upgradeSuccessful" + :href="chartRepo" + target="_blank" + class="js-cluster-application-upgrade-version" + > + chart v{{ version }} + </gl-link> + </div> + + <div + v-if="upgradeFailed && !isUpgrading" + class="bs-callout bs-callout-danger cluster-application-banner mt-2 mb-0 js-cluster-application-upgrade-failure-message" + > + {{ upgradeFailureDescription }} + </div> + + <div + v-if="upgradeRequested && upgradeSuccessful" + class="bs-callout bs-callout-success cluster-application-banner mt-2 mb-0 p-0 pl-3" + > + {{ upgradeSuccessDescription }} + + <button class="close cluster-application-banner-close" @click="dismissUpgradeSuccess"> + × + </button> + </div> + + <loading-button + v-if="upgradeAvailable || upgradeFailed || isUpgrading" + class="btn btn-primary js-cluster-application-upgrade-button mt-2" + :loading="isUpgrading" + :disabled="isUpgrading" + :label="upgradeButtonLabel" + @click="upgradeClicked" + /> </div> <div :class="{ 'section-25': showManageButton, 'section-15': !showManageButton }" diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 489615f1f78..0cf187d4189 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -52,6 +52,11 @@ export default { required: false, default: '', }, + rbac: { + type: Boolean, + required: false, + default: false, + }, }, data: () => ({ elasticsearchLogo, @@ -357,6 +362,9 @@ export default { :status-reason="applications.runner.statusReason" :request-status="applications.runner.requestStatus" :request-reason="applications.runner.requestReason" + :version="applications.runner.version" + :chart-repo="applications.runner.chartRepo" + :upgrade-available="applications.runner.upgradeAvailable" :disabled="!helmInstalled" title-link="https://docs.gitlab.com/runner/" > @@ -442,6 +450,18 @@ export default { title-link="https://github.com/knative/docs" > <div slot="description"> + <span v-if="!rbac"> + <p v-if="!rbac" class="bs-callout bs-callout-info append-bottom-0"> + {{ + s__(`ClusterIntegration|You must have an RBAC-enabled cluster + to install Knative.`) + }} + <a :href="helpPath" target="_blank" rel="noopener noreferrer"> + {{ __('More information') }} + </a> + </p> + <br /> + </span> <p> {{ s__(`ClusterIntegration|Knative extends Kubernetes to provide @@ -465,7 +485,7 @@ export default { /> </div> </template> - <template v-else-if="helmInstalled"> + <template v-else-if="helmInstalled && rbac"> <div class="form-group"> <label for="knative-domainname"> {{ s__('ClusterIntegration|Knative Domain Name:') }} diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index e31afadf186..39022879d91 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -12,16 +12,19 @@ export const APPLICATION_STATUS = { SCHEDULED: 'scheduled', INSTALLING: 'installing', INSTALLED: 'installed', - UPDATED: 'updated', UPDATING: 'updating', + UPDATED: 'updated', + UPDATE_ERRORED: 'update_errored', ERROR: 'errored', }; // These are only used client-side -export const REQUEST_LOADING = 'request-loading'; -export const REQUEST_SUCCESS = 'request-success'; +export const REQUEST_SUBMITTED = 'request-submitted'; export const REQUEST_FAILURE = 'request-failure'; +export const UPGRADE_REQUESTED = 'upgrade-requested'; +export const UPGRADE_REQUEST_FAILURE = 'upgrade-request-failure'; export const INGRESS = 'ingress'; export const JUPYTER = 'jupyter'; export const KNATIVE = 'knative'; +export const RUNNER = 'runner'; export const CERT_MANAGER = 'cert_manager'; diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index c750daab112..d309678be27 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -1,5 +1,6 @@ import { s__ } from '../../locale'; -import { INGRESS, JUPYTER, KNATIVE, CERT_MANAGER } from '../constants'; +import { parseBoolean } from '../../lib/utils/common_utils'; +import { INGRESS, JUPYTER, KNATIVE, CERT_MANAGER, RUNNER } from '../constants'; export default class ClusterStore { constructor() { @@ -7,6 +8,7 @@ export default class ClusterStore { helpPath: null, ingressHelpPath: null, status: null, + rbac: false, statusReason: null, applications: { helm: { @@ -38,6 +40,9 @@ export default class ClusterStore { statusReason: null, requestStatus: null, requestReason: null, + version: null, + chartRepo: 'https://gitlab.com/charts/gitlab-runner', + upgradeAvailable: null, }, prometheus: { title: s__('ClusterIntegration|Prometheus'), @@ -81,6 +86,10 @@ export default class ClusterStore { this.state.status = status; } + updateRbac(rbac) { + this.state.rbac = parseBoolean(rbac); + } + updateStatusReason(reason) { this.state.statusReason = reason; } @@ -94,7 +103,13 @@ export default class ClusterStore { this.state.statusReason = serverState.status_reason; serverState.applications.forEach(serverAppEntry => { - const { name: appId, status, status_reason: statusReason } = serverAppEntry; + const { + name: appId, + status, + status_reason: statusReason, + version, + update_available: upgradeAvailable, + } = serverAppEntry; this.state.applications[appId] = { ...(this.state.applications[appId] || {}), @@ -118,6 +133,9 @@ export default class ClusterStore { serverAppEntry.hostname || this.state.applications.knative.hostname; this.state.applications.knative.externalIp = serverAppEntry.external_ip || this.state.applications.knative.externalIp; + } else if (appId === RUNNER) { + this.state.applications.runner.version = version; + this.state.applications.runner.upgradeAvailable = upgradeAvailable; } }); } diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js index a7ed175f7a4..009153d0703 100644 --- a/app/assets/javascripts/commons/jquery.js +++ b/app/assets/javascripts/commons/jquery.js @@ -7,4 +7,3 @@ import 'vendor/jquery.caret'; import 'vendor/jquery.atwho'; import 'vendor/jquery.scrollTo'; import 'jquery.waitforimages'; -import 'select2/select2'; diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index 10f02739ec8..67fcdd082a2 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -4,6 +4,10 @@ import _ from 'underscore'; import bp from './breakpoints'; import { parseBoolean } from '~/lib/utils/common_utils'; +// NOTE: at 1200px nav sidebar should not overlap the content +// https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24555#note_134136110 +const NAV_SIDEBAR_BREAKPOINT = 1200; + export default class ContextualSidebar { constructor() { this.initDomElements(); @@ -13,6 +17,9 @@ export default class ContextualSidebar { initDomElements() { this.$page = $('.layout-page'); this.$sidebar = $('.nav-sidebar'); + + if (!this.$sidebar.length) return; + this.$innerScroll = $('.nav-sidebar-inner-scroll', this.$sidebar); this.$overlay = $('.mobile-overlay'); this.$openSidebar = $('.toggle-mobile-nav'); @@ -21,48 +28,63 @@ export default class ContextualSidebar { } bindEvents() { - document.addEventListener('click', e => { - if ( - !e.target.closest('.nav-sidebar') && - (bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'md') - ) { - this.toggleCollapsedSidebar(true); - } - }); + if (!this.$sidebar.length) return; + this.$openSidebar.on('click', () => this.toggleSidebarNav(true)); this.$closeSidebar.on('click', () => this.toggleSidebarNav(false)); this.$overlay.on('click', () => this.toggleSidebarNav(false)); this.$sidebarToggle.on('click', () => { - const value = !this.$sidebar.hasClass('sidebar-collapsed-desktop'); - this.toggleCollapsedSidebar(value); + if (!ContextualSidebar.isDesktopBreakpoint()) { + this.toggleSidebarNav(!this.$sidebar.hasClass('sidebar-expanded-mobile')); + } else { + const value = !this.$sidebar.hasClass('sidebar-collapsed-desktop'); + this.toggleCollapsedSidebar(value, true); + } }); $(window).on('resize', () => _.debounce(this.render(), 100)); } + // TODO: use the breakpoints from breakpoints.js once they have been updated for bootstrap 4 + // See documentation: https://design.gitlab.com/regions/navigation#contextual-navigation + static isDesktopBreakpoint = () => bp.windowWidth() >= NAV_SIDEBAR_BREAKPOINT; static setCollapsedCookie(value) { - if (bp.getBreakpointSize() !== 'lg') { + if (!ContextualSidebar.isDesktopBreakpoint()) { return; } Cookies.set('sidebar_collapsed', value, { expires: 365 * 10 }); } toggleSidebarNav(show) { - this.$sidebar.toggleClass('sidebar-expanded-mobile', show); - this.$overlay.toggleClass('mobile-nav-open', show); + const breakpoint = bp.getBreakpointSize(); + const dbp = ContextualSidebar.isDesktopBreakpoint(); + + this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? show : false); + this.$overlay.toggleClass( + 'mobile-nav-open', + breakpoint === 'xs' || breakpoint === 'sm' ? show : false, + ); this.$sidebar.removeClass('sidebar-collapsed-desktop'); } - toggleCollapsedSidebar(collapsed) { + toggleCollapsedSidebar(collapsed, saveCookie) { const breakpoint = bp.getBreakpointSize(); + const dbp = ContextualSidebar.isDesktopBreakpoint(); if (this.$sidebar.length) { this.$sidebar.toggleClass('sidebar-collapsed-desktop', collapsed); - this.$page.toggleClass('page-with-icon-sidebar', breakpoint === 'sm' ? true : collapsed); + this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? !collapsed : false); + this.$page.toggleClass( + 'page-with-icon-sidebar', + breakpoint === 'xs' || breakpoint === 'sm' ? true : collapsed, + ); + } + + if (saveCookie) { + ContextualSidebar.setCollapsedCookie(collapsed); } - ContextualSidebar.setCollapsedCookie(collapsed); - this.toggleSidebarOverflow(); + requestIdleCallback(() => this.toggleSidebarOverflow()); } toggleSidebarOverflow() { @@ -74,13 +96,13 @@ export default class ContextualSidebar { } render() { - const breakpoint = bp.getBreakpointSize(); + if (!this.$sidebar.length) return; - if (breakpoint === 'sm' || breakpoint === 'md') { - this.toggleCollapsedSidebar(true); - } else if (breakpoint === 'lg') { + if (!ContextualSidebar.isDesktopBreakpoint()) { + this.toggleSidebarNav(false); + } else { const collapse = parseBoolean(Cookies.get('sidebar_collapsed')); - this.toggleCollapsedSidebar(collapse); + this.toggleCollapsedSidebar(collapse, true); } } } diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index f01e6f2a639..6ffb8c4e1c0 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -113,7 +113,7 @@ export default { <div class="gl-responsive-table-row deploy-key"> <div class="table-section section-40"> <div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Deploy key') }}</div> - <div class="table-mobile-content"> + <div class="table-mobile-content qa-key"> <strong class="title qa-key-title"> {{ deployKey.title }} </strong> <div class="fingerprint qa-key-fingerprint">{{ deployKey.fingerprint }}</div> </div> diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index d4c1b07093d..1fc2b7fe859 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -4,6 +4,8 @@ import Icon from '~/vue_shared/components/icon.vue'; import { __ } from '~/locale'; import createFlash from '~/flash'; import { GlLoadingIcon } from '@gitlab/ui'; +import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; +import Mousetrap from 'mousetrap'; import eventHub from '../../notes/event_hub'; import CompareVersions from './compare_versions.vue'; import DiffFile from './diff_file.vue'; @@ -11,6 +13,13 @@ import NoChanges from './no_changes.vue'; import HiddenFilesWarning from './hidden_files_warning.vue'; import CommitWidget from './commit_widget.vue'; import TreeList from './tree_list.vue'; +import { + TREE_LIST_WIDTH_STORAGE_KEY, + INITIAL_TREE_WIDTH, + MIN_TREE_WIDTH, + MAX_TREE_WIDTH, + TREE_HIDE_STATS_WIDTH, +} from '../constants'; export default { name: 'DiffsApp', @@ -23,6 +32,7 @@ export default { CommitWidget, TreeList, GlLoadingIcon, + PanelResizer, }, props: { endpoint: { @@ -54,8 +64,12 @@ export default { }, }, data() { + const treeWidth = + parseInt(localStorage.getItem(TREE_LIST_WIDTH_STORAGE_KEY), 10) || INITIAL_TREE_WIDTH; + return { assignedDiscussions: false, + treeWidth, }; }, computed: { @@ -74,7 +88,7 @@ export default { emailPatchPath: state => state.diffs.emailPatchPath, }), ...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion']), - ...mapGetters('diffs', ['isParallelView']), + ...mapGetters('diffs', ['isParallelView', 'currentDiffIndex']), ...mapGetters(['isNotesFetched', 'getNoteableData']), targetBranch() { return { @@ -96,6 +110,9 @@ export default { this.startVersion.version_index === this.mergeRequestDiff.version_index) ); }, + hideFileStats() { + return this.treeWidth <= TREE_HIDE_STATS_WIDTH; + }, }, watch: { diffViewType() { @@ -129,6 +146,11 @@ export default { created() { this.adjustView(); eventHub.$once('fetchedNotesData', this.setDiscussions); + eventHub.$once('fetchDiffData', this.fetchData); + }, + beforeDestroy() { + eventHub.$off('fetchDiffData', this.fetchData); + this.removeEventListeners(); }, methods: { ...mapActions(['startTaskList']), @@ -138,6 +160,8 @@ export default { 'startRenderDiffsQueue', 'assignDiscussionsToDiff', 'setHighlightedRow', + 'cacheTreeListWidth', + 'scrollToFile', ]), fetchData() { this.fetchDiffFiles() @@ -176,10 +200,40 @@ export default { this.$nextTick(() => { window.mrTabs.resetViewContainer(); window.mrTabs.expandViewContainer(this.showTreeList); + this.setEventListeners(); }); + } else { + this.removeEventListeners(); + } + }, + setEventListeners() { + Mousetrap.bind(['[', 'k', ']', 'j'], (e, combo) => { + switch (combo) { + case '[': + case 'k': + this.jumpToFile(-1); + break; + case ']': + case 'j': + this.jumpToFile(+1); + break; + default: + break; + } + }); + }, + removeEventListeners() { + Mousetrap.unbind(['[', 'k', ']', 'j']); + }, + jumpToFile(step) { + const targetIndex = this.currentDiffIndex + step; + if (targetIndex >= 0 && targetIndex < this.diffFiles.length) { + this.scrollToFile(this.diffFiles[targetIndex].file_path); } }, }, + minTreeWidth: MIN_TREE_WIDTH, + maxTreeWidth: MAX_TREE_WIDTH, }; </script> @@ -205,7 +259,21 @@ export default { :data-can-create-note="getNoteableData.current_user.can_create_note" class="files d-flex prepend-top-default" > - <div v-show="showTreeList" class="diff-tree-list"><tree-list /></div> + <div + v-show="showTreeList" + :style="{ width: `${treeWidth}px` }" + class="diff-tree-list js-diff-tree-list" + > + <panel-resizer + :size.sync="treeWidth" + :start-size="treeWidth" + :min-size="$options.minTreeWidth" + :max-size="$options.maxTreeWidth" + side="right" + @resize-end="cacheTreeListWidth" + /> + <tree-list :hide-file-stats="hideFileStats" /> + </div> <div class="diff-files-holder"> <commit-widget v-if="commit" :commit="commit" /> <template v-if="renderDiffFiles"> diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index ebc4a83af4d..c02a8740a42 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -5,6 +5,7 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import CIIcon from '~/vue_shared/components/ci_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; +import initUserPopovers from '../../user_popovers'; /** * CommitItem @@ -35,20 +36,30 @@ export default { }, }, computed: { + author() { + return this.commit.author || {}; + }, authorName() { - return (this.commit.author && this.commit.author.name) || this.commit.author_name; + return this.author.name || this.commit.author_name; + }, + authorClass() { + return this.author.name ? 'js-user-link' : ''; + }, + authorId() { + return this.author.id ? this.author.id : ''; }, authorUrl() { - return ( - (this.commit.author && this.commit.author.web_url) || `mailto:${this.commit.author_email}` - ); + return this.author.web_url || `mailto:${this.commit.author_email}`; }, authorAvatar() { - return ( - (this.commit.author && this.commit.author.avatar_url) || this.commit.author_gravatar_url - ); + return this.author.avatar_url || this.commit.author_gravatar_url; }, }, + created() { + this.$nextTick(() => { + initUserPopovers(this.$el.querySelectorAll('.js-user-link')); + }); + }, }; </script> @@ -81,7 +92,13 @@ export default { </button> <div class="commiter"> - <a :href="authorUrl" v-text="authorName"></a> {{ s__('CommitWidget|authored') }} + <a + :href="authorUrl" + :class="authorClass" + :data-user-id="authorId" + v-text="authorName" + ></a> + {{ s__('CommitWidget|authored') }} <time-ago-tooltip :time="commit.authored_date" /> </div> diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index f75345d31f8..0bf2dde8b96 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -2,9 +2,11 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; -import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility'; +import { polyfillSticky } from '~/lib/utils/sticky'; import Icon from '~/vue_shared/components/icon.vue'; import CompareVersionsDropdown from './compare_versions_dropdown.vue'; +import SettingsDropdown from './settings_dropdown.vue'; +import DiffStats from './diff_stats.vue'; export default { components: { @@ -12,6 +14,8 @@ export default { Icon, GlLink, GlButton, + SettingsDropdown, + DiffStats, }, directives: { GlTooltip: GlTooltipDirective, @@ -33,27 +37,33 @@ export default { }, }, computed: { - ...mapState('diffs', ['commit', 'showTreeList', 'startVersion', 'latestVersionPath']), - ...mapGetters('diffs', ['isInlineView', 'isParallelView', 'hasCollapsedFile']), + ...mapGetters('diffs', ['hasCollapsedFile', 'diffFilesLength']), + ...mapState('diffs', [ + 'commit', + 'showTreeList', + 'startVersion', + 'latestVersionPath', + 'addedLines', + 'removedLines', + ]), comparableDiffs() { return this.mergeRequestDiffs.slice(1); }, - toggleWhitespaceText() { - if (this.isWhitespaceVisible()) { - return __('Hide whitespace changes'); - } - return __('Show whitespace changes'); - }, - toggleWhitespacePath() { - if (this.isWhitespaceVisible()) { - return mergeUrlParams({ w: 1 }, window.location.href); - } - - return mergeUrlParams({ w: 0 }, window.location.href); - }, showDropdowns() { return !this.commit && this.mergeRequestDiffs.length; }, + fileTreeIcon() { + return this.showTreeList ? 'collapse-left' : 'expand-left'; + }, + toggleFileBrowserTitle() { + return this.showTreeList ? __('Hide file browser') : __('Show file browser'); + }, + baseVersionPath() { + return this.mergeRequestDiff.base_version_path; + }, + }, + mounted() { + polyfillSticky(this.$el); }, methods: { ...mapActions('diffs', [ @@ -62,15 +72,12 @@ export default { 'expandAllFiles', 'toggleShowTreeList', ]), - isWhitespaceVisible() { - return getParameterValues('w')[0] !== '1'; - }, }, }; </script> <template> - <div class="mr-version-controls"> + <div class="mr-version-controls" :class="{ 'is-fileTreeOpen': showTreeList }"> <div class="mr-version-menus-container content-block"> <button v-gl-tooltip.hover @@ -79,10 +86,10 @@ export default { :class="{ active: showTreeList, }" - :title="__('Toggle file browser')" + :title="toggleFileBrowserTitle" @click="toggleShowTreeList" > - <icon name="hamburger" /> + <icon :name="fileTreeIcon" /> </button> <div v-if="showDropdowns" class="d-flex align-items-center compare-versions-container"> Changes between @@ -95,6 +102,7 @@ export default { and <compare-versions-dropdown :other-versions="comparableDiffs" + :base-version-path="baseVersionPath" :start-version="startVersion" :target-branch="targetBranch" class="mr-version-compare-dropdown" @@ -105,6 +113,11 @@ export default { <gl-link :href="commit.commit_url" class="monospace">{{ commit.short_id }}</gl-link> </div> <div class="inline-parallel-buttons d-none d-md-flex ml-auto"> + <diff-stats + :diff-files-length="diffFilesLength" + :added-lines="addedLines" + :removed-lines="removedLines" + /> <gl-button v-if="commit || startVersion" :href="latestVersionPath" @@ -115,31 +128,7 @@ export default { <a v-show="hasCollapsedFile" class="btn btn-default append-right-8" @click="expandAllFiles"> {{ __('Expand all') }} </a> - <a :href="toggleWhitespacePath" class="btn btn-default qa-toggle-whitespace"> - {{ toggleWhitespaceText }} - </a> - <div class="btn-group prepend-left-8"> - <button - id="inline-diff-btn" - :class="{ active: isInlineView }" - type="button" - class="btn js-inline-diff-button" - data-view-type="inline" - @click="setInlineDiffViewType" - > - {{ __('Inline') }} - </button> - <button - id="parallel-diff-btn" - :class="{ active: isParallelView }" - type="button" - class="btn js-parallel-diff-button" - data-view-type="parallel" - @click="setParallelDiffViewType" - > - {{ __('Side-by-side') }} - </button> - </div> + <settings-dropdown /> </div> </div> </div> diff --git a/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue index 8da02ed0b7c..80aec84f574 100644 --- a/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue +++ b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue @@ -34,14 +34,13 @@ export default { required: false, default: false, }, + baseVersionPath: { + type: String, + required: false, + default: null, + }, }, computed: { - baseVersion() { - return { - name: 'hii', - versionIndex: -1, - }; - }, targetVersions() { if (this.mergeRequestVersion) { return this.otherVersions; @@ -62,6 +61,9 @@ export default { ); }, href(version) { + if (this.isBase(version)) { + return this.baseVersionPath; + } if (this.showCommitCount) { return version.version_path; } @@ -129,7 +131,7 @@ export default { </strong> </div> <div> - <small class="commit-sha"> {{ version.truncated_commit_sha }} </small> + <small class="commit-sha"> {{ version.short_commit_sha }} </small> </div> <div> <small> @@ -139,7 +141,7 @@ export default { <time-ago v-if="version.created_at" :time="version.created_at" - class="js-timeago js-timeago-render" + class="js-timeago" /> </small> </div> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 42d09e44768..cb92093db32 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -1,7 +1,8 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; -import EmptyFileViewer from '~/vue_shared/components/diff_viewer/viewers/empty_file.vue'; +import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue'; +import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_preview.vue'; import InlineDiffView from './inline_diff_view.vue'; import ParallelDiffView from './parallel_diff_view.vue'; import NoteForm from '../../notes/components/note_form.vue'; @@ -9,6 +10,7 @@ import ImageDiffOverlay from './image_diff_overlay.vue'; import DiffDiscussions from './diff_discussions.vue'; import { IMAGE_DIFF_POSITION_TYPE } from '../constants'; import { getDiffMode } from '../store/utils'; +import { diffViewerModes } from '~/ide/constants'; export default { components: { @@ -18,7 +20,8 @@ export default { NoteForm, DiffDiscussions, ImageDiffOverlay, - EmptyFileViewer, + NotDiffableViewer, + NoPreviewViewer, }, props: { diffFile: { @@ -42,8 +45,17 @@ export default { diffMode() { return getDiffMode(this.diffFile); }, + diffViewerMode() { + return this.diffFile.viewer.name; + }, isTextFile() { - return this.diffFile.viewer.name === 'text'; + return this.diffViewerMode === diffViewerModes.text; + }, + noPreview() { + return this.diffViewerMode === diffViewerModes.no_preview; + }, + notDiffable() { + return this.diffViewerMode === diffViewerModes.not_diffable; }, diffFileCommentForm() { return this.getCommentFormForDiffFile(this.diffFile.file_hash); @@ -77,9 +89,8 @@ export default { <div class="diff-content"> <div class="diff-viewer"> <template v-if="isTextFile"> - <empty-file-viewer v-if="diffFile.empty" /> <inline-diff-view - v-else-if="isInlineView" + v-if="isInlineView" :diff-file="diffFile" :diff-lines="diffFile.highlighted_diff_lines || []" :help-page-path="helpPagePath" @@ -91,9 +102,12 @@ export default { :help-page-path="helpPagePath" /> </template> + <not-diffable-viewer v-else-if="notDiffable" /> + <no-preview-viewer v-else-if="noPreview" /> <diff-viewer v-else :diff-mode="diffMode" + :diff-viewer-mode="diffViewerMode" :new-path="diffFile.new_path" :new-sha="diffFile.diff_refs.head_sha" :old-path="diffFile.old_path" @@ -124,7 +138,7 @@ export default { :save-button-title="__('Comment')" class="diff-comment-form new-note discussion-form discussion-form-container" @handleFormUpdate="handleSaveNote" - @cancelForm="closeDiffFileCommentForm(diffFile.file_hash);" + @cancelForm="closeDiffFileCommentForm(diffFile.file_hash)" /> </div> </diff-viewer> diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index b2021cd6061..4c73eea4049 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -68,7 +68,7 @@ export default { }" type="button" class="js-diff-notes-toggle" - @click="toggleDiscussion({ discussionId: discussion.id });" + @click="toggleDiscussion({ discussionId: discussion.id })" > <icon v-if="discussion.expanded" name="collapse" class="collapse-icon" /> <template v-else> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 449f7007077..1141a197c6a 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -7,6 +7,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import eventHub from '../../notes/event_hub'; import DiffFileHeader from './diff_file_header.vue'; import DiffContent from './diff_content.vue'; +import { diffViewerErrors } from '~/ide/constants'; export default { components: { @@ -33,15 +34,13 @@ export default { return { isLoadingCollapsedDiff: false, forkMessageVisible: false, + isCollapsed: this.file.viewer.collapsed || false, }; }, computed: { ...mapState('diffs', ['currentDiffFileId']), ...mapGetters(['isNotesFetched']), ...mapGetters('diffs', ['getDiffFileDiscussions']), - isCollapsed() { - return this.file.collapsed || false; - }, viewBlobLink() { return sprintf( __('You can %{linkStart}view the blob%{linkEnd} instead.'), @@ -52,17 +51,6 @@ export default { false, ); }, - showExpandMessage() { - return ( - this.isCollapsed || - (!this.file.highlighted_diff_lines && - !this.isLoadingCollapsedDiff && - !this.file.too_large && - this.file.text && - !this.file.renamed_file && - !this.file.mode_changed) - ); - }, showLoadingIcon() { return this.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed); }, @@ -73,9 +61,15 @@ export default { this.file.parallel_diff_lines.length > 0 ); }, + isFileTooLarge() { + return this.file.viewer.error === diffViewerErrors.too_large; + }, + errorMessage() { + return this.file.viewer.error_message; + }, }, watch: { - 'file.collapsed': function fileCollapsedWatch(newVal, oldVal) { + isCollapsed: function fileCollapsedWatch(newVal, oldVal) { if (!newVal && oldVal && !this.hasDiffLines) { this.handleLoadCollapsedDiff(); } @@ -85,13 +79,13 @@ export default { eventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.handleLoadCollapsedDiff); }, methods: { - ...mapActions('diffs', ['loadCollapsedDiff', 'assignDiscussionsToDiff']), + ...mapActions('diffs', ['loadCollapsedDiff', 'assignDiscussionsToDiff', 'setRenderIt']), handleToggle() { if (!this.hasDiffLines) { this.handleLoadCollapsedDiff(); } else { - this.file.collapsed = !this.file.collapsed; - this.file.renderIt = true; + this.isCollapsed = !this.isCollapsed; + this.setRenderIt(this.file); } }, handleLoadCollapsedDiff() { @@ -100,8 +94,8 @@ export default { this.loadCollapsedDiff(this.file) .then(() => { this.isLoadingCollapsedDiff = false; - this.file.collapsed = false; - this.file.renderIt = true; + this.isCollapsed = false; + this.setRenderIt(this.file); }) .then(() => { requestIdleCallback( @@ -164,21 +158,25 @@ export default { Cancel </button> </div> - - <diff-content - v-if="!isCollapsed && file.renderIt" - :class="{ hidden: isCollapsed || file.too_large }" - :diff-file="file" - :help-page-path="helpPagePath" - /> <gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" /> - <div v-else-if="showExpandMessage" class="nothing-here-block diff-collapsed"> - {{ __('This diff is collapsed.') }} - <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{ - __('Click to expand it.') - }}</a> - </div> - <div v-if="file.too_large" class="nothing-here-block diff-collapsed js-too-large-diff"> + <template v-else> + <div v-if="errorMessage" class="diff-viewer"> + <div class="nothing-here-block" v-html="errorMessage"></div> + </div> + <div v-else-if="isCollapsed" class="nothing-here-block diff-collapsed"> + {{ __('This diff is collapsed.') }} + <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{ + __('Click to expand it.') + }}</a> + </div> + <diff-content + v-else + :class="{ hidden: isCollapsed || isFileTooLarge }" + :diff-file="file" + :help-page-path="helpPagePath" + /> + </template> + <div v-if="isFileTooLarge" class="nothing-here-block diff-collapsed js-too-large-diff"> {{ __('This source diff could not be displayed because it is too large.') }} <span v-html="viewBlobLink"></span> </div> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index f75a01b023b..4d33ad23f39 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -8,7 +8,9 @@ import FileIcon from '~/vue_shared/components/file_icon.vue'; import { GlTooltipDirective } from '@gitlab/ui'; import { truncateSha } from '~/lib/utils/text_utility'; import { __, s__, sprintf } from '~/locale'; +import { diffViewerModes } from '~/ide/constants'; import EditButton from './edit_button.vue'; +import DiffStats from './diff_stats.vue'; export default { components: { @@ -16,6 +18,7 @@ export default { EditButton, Icon, FileIcon, + DiffStats, }, directives: { GlTooltip: GlTooltipDirective, @@ -116,6 +119,12 @@ export default { gfmCopyText() { return `\`${this.diffFile.file_path}\``; }, + isFileRenamed() { + return this.diffFile.viewer.name === diffViewerModes.renamed; + }, + isModeChanged() { + return this.diffFile.viewer.name === diffViewerModes.mode_changed; + }, }, mounted() { polyfillSticky(this.$refs.header); @@ -145,7 +154,7 @@ export default { <div ref="header" class="js-file-title file-title file-title-flex-parent" - @click="handleToggleFile($event, true);" + @click="handleToggleFile($event, true)" > <div class="file-header-content"> <icon @@ -163,7 +172,7 @@ export default { aria-hidden="true" css-classes="js-file-icon append-right-5" /> - <span v-if="diffFile.renamed_file"> + <span v-if="isFileRenamed"> <strong v-gl-tooltip :title="diffFile.old_path" @@ -191,7 +200,7 @@ export default { css-class="btn-default btn-transparent btn-clipboard" /> - <small v-if="diffFile.mode_changed" ref="fileMode"> + <small v-if="isModeChanged" ref="fileMode"> {{ diffFile.a_mode }} → {{ diffFile.b_mode }} </small> @@ -202,6 +211,7 @@ export default { v-if="!diffFile.submodule && addMergeRequestButtons" class="file-actions d-none d-sm-block" > + <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> <template v-if="diffFile.blob && diffFile.blob.readable_text"> <button :disabled="!diffHasDiscussions(diffFile)" @@ -226,11 +236,15 @@ export default { <a v-if="diffFile.replaced_view_path" :href="diffFile.replaced_view_path" - class="btn view-file js-view-file" + class="btn view-file js-view-replaced-file" v-html="viewReplacedFileButtonText" > </a> - <a :href="diffFile.view_path" class="btn view-file js-view-file" v-html="viewFileButtonText"> + <a + :href="diffFile.view_path" + class="btn view-file js-view-file-button" + v-html="viewFileButtonText" + > </a> <a @@ -240,7 +254,7 @@ export default { :title="`View on ${diffFile.formatted_external_url}`" target="_blank" rel="noopener noreferrer" - class="btn btn-file-option" + class="btn btn-file-option js-external-url" > <icon name="external-link" /> </a> diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue index c0613d80d37..6709df48637 100644 --- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue +++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue @@ -179,7 +179,7 @@ export default { v-if="lineNumber" :data-linenumber="lineNumber" :href="lineHref" - @click="setHighlightedRow(lineCode);" + @click="setHighlightedRow(lineCode)" > </a> <diff-gutter-avatars v-if="shouldShowAvatarsOnGutter" :discussions="line.discussions" /> 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 e7569ba7b84..bb66ab36283 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -1,6 +1,7 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; import { s__ } from '~/locale'; +import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; import noteForm from '../../notes/components/note_form.vue'; import autosave from '../../notes/mixins/autosave'; import { DIFF_NOTE_TYPE } from '../constants'; @@ -9,7 +10,7 @@ export default { components: { noteForm, }, - mixins: [autosave], + mixins: [autosave, diffLineNoteFormMixin], props: { diffFileHash: { type: String, @@ -28,6 +29,11 @@ export default { type: Object, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapState({ @@ -95,8 +101,10 @@ export default { :is-editing="true" :line-code="line.line_code" :line="line" + :help-page-path="helpPagePath" save-button-title="Comment" class="diff-comment-form" + @handleFormUpdateAddToReview="addToReview" @cancelForm="handleCancelCommentForm" @handleFormUpdate="handleSaveNote" /> diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue new file mode 100644 index 00000000000..2e5855380af --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_stats.vue @@ -0,0 +1,52 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { n__ } from '~/locale'; + +export default { + components: { Icon }, + props: { + addedLines: { + type: Number, + required: true, + }, + removedLines: { + type: Number, + required: true, + }, + diffFilesLength: { + type: Number, + required: false, + default: null, + }, + }, + computed: { + filesText() { + return n__('File', 'Files', this.diffFilesLength); + }, + isCompareVersionsHeader() { + return Boolean(this.diffFilesLength); + }, + }, +}; +</script> + +<template> + <div + class="diff-stats" + :class="{ + 'is-compare-versions-header d-none d-lg-inline-flex': isCompareVersionsHeader, + 'd-inline-flex': !isCompareVersionsHeader, + }" + > + <div v-if="diffFilesLength !== null" class="diff-stats-group"> + <icon name="doc-code" class="diff-stats-icon text-secondary" /> + <strong>{{ diffFilesLength }} {{ filesText }}</strong> + </div> + <div class="diff-stats-group cgreen"> + <icon name="file-addition" class="diff-stats-icon" /> <strong>{{ addedLines }}</strong> + </div> + <div class="diff-stats-group cred"> + <icon name="file-deletion" class="diff-stats-icon" /> <strong>{{ removedLines }}</strong> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue index 5d38d545ce8..803f23b9170 100644 --- a/app/assets/javascripts/diffs/components/edit_button.vue +++ b/app/assets/javascripts/diffs/components/edit_button.vue @@ -17,12 +17,7 @@ export default { }, methods: { handleEditClick(evt) { - if (!this.canCurrentUserFork || this.canModifyBlob) { - // if we can Edit, do default Edit button behavior - return; - } - - if (this.canCurrentUserFork) { + if (this.canCurrentUserFork && !this.canModifyBlob) { evt.preventDefault(); this.$emit('showForkMessage'); } diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue index d30e64312aa..4a83c5a72a5 100644 --- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue +++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue @@ -97,7 +97,7 @@ export default { v-if="canComment" type="button" class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button" - @click="clickedImage($event.offsetX, $event.offsetY);" + @click="clickedImage($event.offsetX, $event.offsetY)" > <span class="sr-only"> {{ __('Add image comment') }} </span> </button> @@ -109,7 +109,7 @@ export default { :disabled="!shouldToggleDiscussion" class="js-image-badge" type="button" - @click="toggleDiscussion({ discussionId: discussion.id });" + @click="toggleDiscussion({ discussionId: discussion.id })" > <icon v-if="showCommentIcon" name="image-comment-dark" /> <template v-else> diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue index 814ee0b7c02..69146f1f6fd 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue @@ -54,6 +54,7 @@ export default { :diff-file-hash="diffFileHash" :line="line" :note-target-line="line" + :help-page-path="helpPagePath" /> </div> </td> diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index e781397214d..8c76a555b62 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -1,5 +1,6 @@ <script> import { mapGetters } from 'vuex'; +import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments'; import inlineDiffTableRow from './inline_diff_table_row.vue'; import inlineDiffCommentRow from './inline_diff_comment_row.vue'; @@ -7,7 +8,10 @@ export default { components: { inlineDiffCommentRow, inlineDiffTableRow, + InlineDraftCommentRow: () => + import('ee_component/batch_comments/components/inline_draft_comment_row.vue'), }, + mixins: [draftCommentsMixin], props: { diffFile: { type: Object, @@ -54,6 +58,11 @@ export default { :line="line" :help-page-path="helpPagePath" /> + <inline-draft-comment-row + v-if="shouldRenderDraftRow(diffFile.file_hash, line)" + :key="`draft_${index}`" + :draft="draftForLine(diffFile.file_hash, line)" + /> </template> </tbody> </table> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue index a65cf025cde..370cb6e339a 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue @@ -101,6 +101,7 @@ export default { :diff-file-hash="diffFileHash" :line="line.left" :note-target-line="line.left" + :help-page-path="helpPagePath" line-position="left" /> </td> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index 1bf693380db..93e754fa896 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -1,5 +1,6 @@ <script> import { mapGetters } from 'vuex'; +import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments'; import parallelDiffTableRow from './parallel_diff_table_row.vue'; import parallelDiffCommentRow from './parallel_diff_comment_row.vue'; @@ -7,7 +8,10 @@ export default { components: { parallelDiffTableRow, parallelDiffCommentRow, + ParallelDraftCommentRow: () => + import('ee_component/batch_comments/components/parallel_draft_comment_row.vue'), }, + mixins: [draftCommentsMixin], props: { diffFile: { type: Object, @@ -56,6 +60,12 @@ export default { :line-index="index" :help-page-path="helpPagePath" /> + <parallel-draft-comment-row + v-if="shouldRenderParallelDraftRow(diffFile.file_hash, line)" + :key="`drafts-${index}`" + :line="line" + :diff-file-content-sha="diffFile.file_hash" + /> </template> </tbody> </table> diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue new file mode 100644 index 00000000000..0129763161a --- /dev/null +++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue @@ -0,0 +1,92 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import { GlButton } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + GlButton, + Icon, + }, + computed: { + ...mapGetters('diffs', ['isInlineView', 'isParallelView']), + ...mapState('diffs', ['renderTreeList', 'showWhitespace']), + }, + methods: { + ...mapActions('diffs', [ + 'setInlineDiffViewType', + 'setParallelDiffViewType', + 'setRenderTreeList', + 'setShowWhitespace', + ]), + }, +}; +</script> + +<template> + <div class="dropdown"> + <button + type="button" + class="btn btn-default js-show-diff-settings" + data-toggle="dropdown" + data-display="static" + > + <icon name="settings" /> <icon name="arrow-down" /> + </button> + <div class="dropdown-menu dropdown-menu-right p-2 pt-3 pb-3"> + <div> + <span class="bold d-block mb-1">{{ __('File browser') }}</span> + <div class="btn-group d-flex"> + <gl-button + :class="{ active: !renderTreeList }" + class="w-100 js-list-view" + @click="setRenderTreeList(false)" + > + {{ __('List view') }} + </gl-button> + <gl-button + :class="{ active: renderTreeList }" + class="w-100 js-tree-view" + @click="setRenderTreeList(true)" + > + {{ __('Tree view') }} + </gl-button> + </div> + </div> + <div class="mt-2"> + <span class="bold d-block mb-1">{{ __('Compare changes') }}</span> + <div class="btn-group d-flex js-diff-view-buttons"> + <gl-button + id="inline-diff-btn" + :class="{ active: isInlineView }" + class="w-100 js-inline-diff-button" + data-view-type="inline" + @click="setInlineDiffViewType" + > + {{ __('Inline') }} + </gl-button> + <gl-button + id="parallel-diff-btn" + :class="{ active: isParallelView }" + class="w-100 js-parallel-diff-button" + data-view-type="parallel" + @click="setParallelDiffViewType" + > + {{ __('Side-by-side') }} + </gl-button> + </div> + </div> + <div class="mt-2"> + <label class="mb-0"> + <input + id="show-whitespace" + type="checkbox" + :checked="showWhitespace" + @change="setShowWhitespace({ showWhitespace: $event.target.checked, pushState: true })" + /> + {{ __('Show whitespace changes') }} + </label> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index f40a7b25fde..8fc3af15bea 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -1,13 +1,10 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; -import { parseBoolean } from '~/lib/utils/common_utils'; import Icon from '~/vue_shared/components/icon.vue'; import FileRow from '~/vue_shared/components/file_row.vue'; import FileRowStats from './file_row_stats.vue'; -const treeListStorageKey = 'mr_diff_tree_list'; - export default { directives: { GlTooltip: GlTooltipDirective, @@ -16,54 +13,51 @@ export default { Icon, FileRow, }, + props: { + hideFileStats: { + type: Boolean, + required: true, + }, + }, data() { - const treeListStored = localStorage.getItem(treeListStorageKey); - const renderTreeList = treeListStored !== null ? parseBoolean(treeListStored) : true; - return { search: '', - renderTreeList, - focusSearch: false, }; }, computed: { - ...mapState('diffs', ['tree', 'addedLines', 'removedLines']), - ...mapGetters('diffs', ['allBlobs', 'diffFilesLength']), + ...mapState('diffs', ['tree', 'renderTreeList']), + ...mapGetters('diffs', ['allBlobs']), filteredTreeList() { const search = this.search.toLowerCase().trim(); - if (search === '') return this.renderTreeList ? this.tree : this.allBlobs; + if (search === '' || this.$options.fuzzyFileFinderEnabled) + return this.renderTreeList ? this.tree : this.allBlobs; - return this.allBlobs.filter(f => f.path.toLowerCase().indexOf(search) >= 0); - }, - rowDisplayTextKey() { - if (this.renderTreeList && this.search.trim() === '') { - return 'name'; - } + return this.allBlobs.reduce((acc, folder) => { + const tree = folder.tree.filter(f => f.path.toLowerCase().indexOf(search) >= 0); - return 'path'; + if (tree.length) { + return acc.concat({ + ...folder, + tree, + }); + } + + return acc; + }, []); + }, + fileRowExtraComponent() { + return this.hideFileStats ? null : FileRowStats; }, }, methods: { - ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']), + ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile', 'toggleFileFinder']), clearSearch() { this.search = ''; - this.toggleFocusSearch(false); - }, - toggleRenderTreeList(toggle) { - this.renderTreeList = toggle; - localStorage.setItem(treeListStorageKey, this.renderTreeList); - }, - toggleFocusSearch(toggle) { - this.focusSearch = toggle; - }, - blurSearch() { - if (this.search.trim() === '') { - this.toggleFocusSearch(false); - } }, }, - FileRowStats, + shortcutKeyCharacter: `${/Mac/i.test(navigator.userAgent) ? '⌘' : 'Ctrl'}+P`, + diffTreeFiltering: gon.features && gon.features.diffTreeFiltering, }; </script> @@ -72,54 +66,39 @@ export default { <div class="append-bottom-8 position-relative tree-list-search d-flex"> <div class="flex-fill d-flex"> <icon name="search" class="position-absolute tree-list-icon" /> - <input - v-model="search" - :placeholder="s__('MergeRequest|Filter files')" - type="search" - class="form-control" - @focus="toggleFocusSearch(true);" - @blur="blurSearch" - /> - <button - v-show="search" - :aria-label="__('Clear search')" - type="button" - class="position-absolute bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0" - @click="clearSearch" - > - <icon name="close" /> - </button> - </div> - <div v-show="!focusSearch" class="btn-group prepend-left-8 tree-list-view-toggle"> - <button - v-gl-tooltip.hover - :aria-label="__('List view')" - :title="__('List view')" - :class="{ - active: !renderTreeList, - }" - class="btn btn-default pt-0 pb-0 d-flex align-items-center" - type="button" - @click="toggleRenderTreeList(false);" - > - <icon name="hamburger" /> - </button> - <button - v-gl-tooltip.hover - :aria-label="__('Tree view')" - :title="__('Tree view')" - :class="{ - active: renderTreeList, - }" - class="btn btn-default pt-0 pb-0 d-flex align-items-center" - type="button" - @click="toggleRenderTreeList(true);" - > - <icon name="file-tree" /> - </button> + <template v-if="$options.diffTreeFiltering"> + <input + v-model="search" + :placeholder="s__('MergeRequest|Filter files')" + type="search" + class="form-control" + /> + <button + v-show="search" + :aria-label="__('Clear search')" + type="button" + class="position-absolute bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0" + @click="clearSearch" + > + <icon name="close" /> + </button> + </template> + <template v-else> + <button + type="button" + class="form-control text-left text-secondary" + @click="toggleFileFinder(true)" + > + {{ s__('MergeRequest|Search files') }} + </button> + <span + class="position-absolute text-secondary diff-tree-search-shortcut" + v-html="$options.shortcutKeyCharacter" + ></span> + </template> </div> </div> - <div class="tree-list-scroll"> + <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll"> <template v-if="filteredTreeList.length"> <file-row v-for="file in filteredTreeList" @@ -127,10 +106,8 @@ export default { :file="file" :level="0" :hide-extra-on-tree="true" - :extra-component="$options.FileRowStats" + :extra-component="fileRowExtraComponent" :show-changed-icon="true" - :display-text-key="rowDisplayTextKey" - :should-truncate-start="true" @toggleTreeOpen="toggleTreeOpen" @clickFile="scrollToFile" /> @@ -139,12 +116,22 @@ export default { {{ s__('MergeRequest|No files found') }} </p> </div> - <div v-once class="pt-3 pb-3 text-center"> - {{ n__('%d changed file', '%d changed files', diffFilesLength) }} - <div> - <span class="cgreen"> {{ n__('%d addition', '%d additions', addedLines) }} </span> - <span class="cred"> {{ n__('%d deleted', '%d deletions', removedLines) }} </span> - </div> - </div> </div> </template> + +<style> +.tree-list-blobs .file-row-name { + margin-left: 12px; +} + +.diff-tree-search-shortcut { + top: 50%; + right: 10px; + transform: translateY(-50%); + pointer-events: none; +} + +.tree-list-icon:not(button) { + pointer-events: none; +} +</style> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 78a39baa4cb..7002655ea49 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -32,3 +32,13 @@ export const LINES_TO_BE_RENDERED_DIRECTLY = 100; export const MAX_LINES_TO_BE_RENDERED = 2000; export const MR_TREE_SHOW_KEY = 'mr_tree_show'; + +export const TREE_TYPE = 'tree'; +export const TREE_LIST_STORAGE_KEY = 'mr_diff_tree_list'; +export const WHITESPACE_STORAGE_KEY = 'mr_show_whitespace'; +export const TREE_LIST_WIDTH_STORAGE_KEY = 'mr_tree_list_width'; + +export const INITIAL_TREE_WIDTH = 320; +export const MIN_TREE_WIDTH = 240; +export const MAX_TREE_WIDTH = 400; +export const TREE_HIDE_STATS_WIDTH = 260; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index b130cedc24c..63954d9d412 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -1,8 +1,60 @@ import Vue from 'vue'; -import { mapState } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { getParameterValues } from '~/lib/utils/url_utility'; +import FindFile from '~/vue_shared/components/file_finder/index.vue'; +import eventHub from '../notes/event_hub'; import diffsApp from './components/app.vue'; +import { TREE_LIST_STORAGE_KEY } from './constants'; export default function initDiffsApp(store) { + const fileFinderEl = document.getElementById('js-diff-file-finder'); + + if (fileFinderEl) { + // eslint-disable-next-line no-new + new Vue({ + el: fileFinderEl, + store, + computed: { + ...mapState('diffs', ['fileFinderVisible', 'isLoading']), + ...mapGetters('diffs', ['flatBlobsList']), + }, + watch: { + fileFinderVisible(newVal, oldVal) { + if (newVal && !oldVal && !this.flatBlobsList.length) { + eventHub.$emit('fetchDiffData'); + } + }, + }, + methods: { + ...mapActions('diffs', ['toggleFileFinder', 'scrollToFile']), + openFile(file) { + window.mrTabs.tabShown('diffs'); + this.scrollToFile(file.path); + }, + }, + render(createElement) { + return createElement(FindFile, { + props: { + files: this.flatBlobsList, + visible: this.fileFinderVisible, + loading: this.isLoading, + showDiffStats: true, + clearSearchOnClose: false, + }, + on: { + toggle: this.toggleFileFinder, + click: this.openFile, + }, + class: ['diff-file-finder'], + style: { + display: this.fileFinderVisible ? '' : 'none', + }, + }); + }, + }); + } + return new Vue({ el: '#js-diffs-app', name: 'MergeRequestDiffs', @@ -26,6 +78,16 @@ export default function initDiffsApp(store) { activeTab: state => state.page.activeTab, }), }, + created() { + const treeListStored = localStorage.getItem(TREE_LIST_STORAGE_KEY); + const renderTreeList = treeListStored !== null ? parseBoolean(treeListStored) : true; + + this.setRenderTreeList(renderTreeList); + this.setShowWhitespace({ showWhitespace: getParameterValues('w')[0] !== '1' }); + }, + methods: { + ...mapActions('diffs', ['setRenderTreeList', 'setShowWhitespace']), + }, render(createElement) { return createElement('diffs-app', { props: { diff --git a/app/assets/javascripts/diffs/mixins/draft_comments.js b/app/assets/javascripts/diffs/mixins/draft_comments.js new file mode 100644 index 00000000000..cfa722b27f1 --- /dev/null +++ b/app/assets/javascripts/diffs/mixins/draft_comments.js @@ -0,0 +1,7 @@ +export default { + computed: { + shouldRenderDraftRow: () => () => false, + shouldRenderParallelDraftRow: () => () => false, + draftForLine: () => () => ({}), + }, +}; diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 00a4bb6d3a3..c40775c3259 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -5,6 +5,7 @@ import createFlash from '~/flash'; import { s__ } from '~/locale'; import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils'; import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility'; +import TreeWorker from '../workers/tree_worker'; import eventHub from '../../notes/event_hub'; import { getDiffPositionByLineCode, getNoteFormData } from './utils'; import * as types from './mutation_types'; @@ -13,7 +14,11 @@ import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME, MR_TREE_SHOW_KEY, + TREE_LIST_STORAGE_KEY, + WHITESPACE_STORAGE_KEY, + TREE_LIST_WIDTH_STORAGE_KEY, } from '../constants'; +import { diffViewerModes } from '~/ide/constants'; export const setBaseConfig = ({ commit }, options) => { const { endpoint, projectPath } = options; @@ -21,21 +26,35 @@ export const setBaseConfig = ({ commit }, options) => { }; export const fetchDiffFiles = ({ state, commit }) => { + const worker = new TreeWorker(); + commit(types.SET_LOADING, true); + worker.addEventListener('message', ({ data }) => { + commit(types.SET_TREE_DATA, data); + + worker.terminate(); + }); + return axios - .get(state.endpoint) + .get(state.endpoint, { params: { w: state.showWhitespace ? null : '1' } }) .then(res => { commit(types.SET_LOADING, false); commit(types.SET_MERGE_REQUEST_DIFFS, res.data.merge_request_diffs || []); commit(types.SET_DIFF_DATA, res.data); + + worker.postMessage(state.diffFiles); + return Vue.nextTick(); }) - .then(handleLocationHash); + .then(handleLocationHash) + .catch(() => worker.terminate()); }; export const setHighlightedRow = ({ commit }, lineCode) => { + const fileHash = lineCode.split('_')[0]; commit(types.SET_HIGHLIGHTED_ROW, lineCode); + commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash); }; // This is adding line discussions to the actual lines in the diff tree @@ -76,7 +95,7 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi commit(types.RENDER_FILE, file); } - if (file.collapsed) { + if (file.viewer.collapsed) { eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`); scrollToElement(document.getElementById(file.file_hash)); } else { @@ -90,7 +109,8 @@ export const startRenderDiffsQueue = ({ state, commit }) => { const checkItem = () => new Promise(resolve => { const nextFile = state.diffFiles.find( - file => !file.renderIt && (!file.collapsed || !file.text), + file => + !file.renderIt && (!file.viewer.collapsed || !file.viewer.name === diffViewerModes.text), ); if (nextFile) { @@ -113,6 +133,8 @@ export const startRenderDiffsQueue = ({ state, commit }) => { return checkItem(); }; +export const setRenderIt = ({ commit }, file) => commit(types.RENDER_FILE, file); + export const setInlineDiffViewType = ({ commit }) => { commit(types.SET_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE); @@ -242,8 +264,6 @@ export const scrollToFile = ({ state, commit }, path) => { document.location.hash = fileHash; commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash); - - setTimeout(() => commit(types.UPDATE_CURRENT_DIFF_FILE_ID, ''), 1000); }; export const toggleShowTreeList = ({ commit, state }) => { @@ -265,5 +285,29 @@ export const closeDiffFileCommentForm = ({ commit }, fileHash) => { commit(types.CLOSE_DIFF_FILE_COMMENT_FORM, fileHash); }; +export const setRenderTreeList = ({ commit }, renderTreeList) => { + commit(types.SET_RENDER_TREE_LIST, renderTreeList); + + localStorage.setItem(TREE_LIST_STORAGE_KEY, renderTreeList); +}; + +export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = false }) => { + commit(types.SET_SHOW_WHITESPACE, showWhitespace); + + localStorage.setItem(WHITESPACE_STORAGE_KEY, showWhitespace); + + if (pushState) { + historyPushState(showWhitespace ? '?w=0' : '?w=1'); + } +}; + +export const toggleFileFinder = ({ commit }, visible) => { + commit(types.TOGGLE_FILE_FINDER_VISIBLE, visible); +}; + +export const cacheTreeListWidth = (_, size) => { + localStorage.setItem(TREE_LIST_WIDTH_STORAGE_KEY, size); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index fdf1efbb10e..bc27e263bff 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -4,7 +4,8 @@ export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW export const isInlineView = state => state.diffViewType === INLINE_DIFF_VIEW_TYPE; -export const hasCollapsedFile = state => state.diffFiles.some(file => file.collapsed); +export const hasCollapsedFile = state => + state.diffFiles.some(file => file.viewer && file.viewer.collapsed); export const commitId = state => (state.commit && state.commit.id ? state.commit.id : null); @@ -74,12 +75,37 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) = export const getDiffFileByHash = state => fileHash => state.diffFiles.find(file => file.file_hash === fileHash); -export const allBlobs = state => Object.values(state.treeEntries).filter(f => f.type === 'blob'); +export const flatBlobsList = state => + Object.values(state.treeEntries).filter(f => f.type === 'blob'); + +export const allBlobs = (state, getters) => + getters.flatBlobsList.reduce((acc, file) => { + const { parentPath } = file; + + if (parentPath && !acc.some(f => f.path === parentPath)) { + acc.push({ + path: parentPath, + isHeader: true, + tree: [], + }); + } + + acc.find(f => f.path === parentPath).tree.push(file); + + return acc; + }, []); export const diffFilesLength = state => state.diffFiles.length; export const getCommentFormForDiffFile = state => fileHash => state.commentForms.find(form => form.fileHash === fileHash); +/** + * Returns index of a currently selected diff in diffFiles + * @returns {number} + */ +export const currentDiffIndex = state => + Math.max(0, state.diffFiles.findIndex(diff => diff.file_hash === state.currentDiffFileId)); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 98e57d52d77..47f78a5db54 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -11,6 +11,8 @@ const storedTreeShow = localStorage.getItem(MR_TREE_SHOW_KEY); export default () => ({ isLoading: true, + addedLines: null, + removedLines: null, endpoint: '', basePath: '', commit: null, @@ -27,4 +29,7 @@ export default () => ({ projectPath: '', commentForms: [], highlightedRow: null, + renderTreeList: true, + showWhitespace: true, + fileFinderVisible: false, }); diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 0338cde3658..71ad108ce88 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -18,3 +18,8 @@ export const OPEN_DIFF_FILE_COMMENT_FORM = 'OPEN_DIFF_FILE_COMMENT_FORM'; export const UPDATE_DIFF_FILE_COMMENT_FORM = 'UPDATE_DIFF_FILE_COMMENT_FORM'; export const CLOSE_DIFF_FILE_COMMENT_FORM = 'CLOSE_DIFF_FILE_COMMENT_FORM'; export const SET_HIGHLIGHTED_ROW = 'SET_HIGHLIGHTED_ROW'; + +export const SET_TREE_DATA = 'SET_TREE_DATA'; +export const SET_RENDER_TREE_LIST = 'SET_RENDER_TREE_LIST'; +export const SET_SHOW_WHITESPACE = 'SET_SHOW_WHITESPACE'; +export const TOGGLE_FILE_FINDER_VISIBLE = 'TOGGLE_FILE_FINDER_VISIBLE'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index ed4203cf5e0..5a27388863c 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -1,5 +1,4 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { sortTree } from '~/ide/stores/utils'; import { findDiffFile, addLineReferences, @@ -7,7 +6,6 @@ import { addContextLines, prepareDiffData, isDiscussionApplicableToLine, - generateTreeList, } from './utils'; import * as types from './mutation_types'; @@ -23,12 +21,9 @@ export default { [types.SET_DIFF_DATA](state, data) { prepareDiffData(data); - const { tree, treeEntries } = generateTreeList(data.diff_files); Object.assign(state, { ...convertObjectPropsToCamelCase(data), - tree: sortTree(tree), - treeEntries, }); }, @@ -149,6 +144,7 @@ export default { if (left || right) { return { + ...line, left: line.left ? mapDiscussions(line.left) : null, right: line.right ? mapDiscussions(line.right, () => !left) : null, }; @@ -239,4 +235,17 @@ export default { [types.SET_HIGHLIGHTED_ROW](state, lineCode) { state.highlightedRow = lineCode; }, + [types.SET_TREE_DATA](state, { treeEntries, tree }) { + state.treeEntries = treeEntries; + state.tree = tree; + }, + [types.SET_RENDER_TREE_LIST](state, renderTreeList) { + state.renderTreeList = renderTreeList; + }, + [types.SET_SHOW_WHITESPACE](state, showWhitespace) { + state.showWhitespace = showWhitespace; + }, + [types.TOGGLE_FILE_FINDER_VISIBLE](state, visible) { + state.fileFinderVisible = visible; + }, }; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 2fe20551642..247d1e65fea 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -1,5 +1,6 @@ import _ from 'underscore'; -import { diffModes } from '~/ide/constants'; +import { truncatePathMiddleToLength } from '~/lib/utils/text_utility'; +import { diffModes, diffViewerModes } from '~/ide/constants'; import { LINE_POSITION_LEFT, LINE_POSITION_RIGHT, @@ -11,6 +12,7 @@ import { MATCH_LINE_TYPE, LINES_TO_BE_RENDERED_DIRECTLY, MAX_LINES_TO_BE_RENDERED, + TREE_TYPE, } from '../constants'; export function findDiffFile(files, hash) { @@ -159,6 +161,7 @@ export function addContextLines(options) { const normalizedParallelLines = contextLines.map(line => ({ left: line, right: line, + line_code: line.line_code, })); if (options.bottom) { @@ -180,8 +183,6 @@ export function addContextLines(options) { export function trimFirstCharOfLineContent(line = {}) { // eslint-disable-next-line no-param-reassign delete line.text; - // eslint-disable-next-line no-param-reassign - line.discussions = []; const parsedLine = Object.assign({}, line); @@ -221,10 +222,12 @@ export function prepareDiffData(diffData) { line.line_code = getLineCode(line, u); if (line.left) { line.left = trimFirstCharOfLineContent(line.left); + line.left.discussions = []; line.left.hasForm = false; } if (line.right) { line.right = trimFirstCharOfLineContent(line.right); + line.right.discussions = []; line.right.hasForm = false; } } @@ -234,14 +237,19 @@ export function prepareDiffData(diffData) { const linesLength = file.highlighted_diff_lines.length; for (let u = 0; u < linesLength; u += 1) { const line = file.highlighted_diff_lines[u]; - Object.assign(line, { ...trimFirstCharOfLineContent(line), hasForm: false }); + Object.assign(line, { + ...trimFirstCharOfLineContent(line), + discussions: [], + hasForm: false, + }); } showingLines += file.parallel_diff_lines.length; } Object.assign(file, { renderIt: showingLines < LINES_TO_BE_RENDERED_DIRECTLY, - collapsed: file.text && showingLines > MAX_LINES_TO_BE_RENDERED, + collapsed: + file.viewer.name === diffViewerModes.text && showingLines > MAX_LINES_TO_BE_RENDERED, discussions: [], }); } @@ -289,8 +297,63 @@ export function isDiscussionApplicableToLine({ discussion, diffPosition, latestD return latestDiff && discussion.active && line_code === discussion.line_code; } -export const generateTreeList = files => - files.reduce( +export const getLowestSingleFolder = folder => { + const getFolder = (blob, start = []) => + blob.tree.reduce( + (acc, file) => { + const shouldGetFolder = file.tree.length === 1 && file.tree[0].type === TREE_TYPE; + const currentFileTypeTree = file.type === TREE_TYPE; + const path = shouldGetFolder || currentFileTypeTree ? acc.path.concat(file.name) : acc.path; + const tree = shouldGetFolder || currentFileTypeTree ? acc.tree.concat(file) : acc.tree; + + if (shouldGetFolder) { + const firstFolder = getFolder(file); + + path.push(...firstFolder.path); + tree.push(...firstFolder.tree); + } + + return { + ...acc, + path, + tree, + }; + }, + { path: start, tree: [] }, + ); + const { path, tree } = getFolder(folder, [folder.name]); + + return { + path: truncatePathMiddleToLength(path.join('/'), 40), + treeAcc: tree.length ? tree[tree.length - 1].tree : null, + }; +}; + +export const flattenTree = tree => { + const flatten = blobTree => + blobTree.reduce((acc, file) => { + const blob = file; + let treeToFlatten = blob.tree; + + if (file.type === TREE_TYPE && file.tree.length === 1) { + const { treeAcc, path } = getLowestSingleFolder(file); + + if (treeAcc) { + blob.name = path; + treeToFlatten = flatten(treeAcc); + } + } + + blob.tree = flatten(treeToFlatten); + + return acc.concat(blob); + }, []); + + return flatten(tree); +}; + +export const generateTreeList = files => { + const { treeEntries, tree } = files.reduce( (acc, file) => { const split = file.new_path.split('/'); @@ -318,6 +381,7 @@ export const generateTreeList = files => fileHash: file.file_hash, addedLines: file.added_lines, removedLines: file.removed_lines, + parentPath: parent ? `${parent.path}/` : '/', }); } else { Object.assign(entry, { @@ -334,11 +398,16 @@ export const generateTreeList = files => { treeEntries: {}, tree: [] }, ); + return { treeEntries, tree: flattenTree(tree) }; +}; + export const getDiffMode = diffFile => { const diffModeKey = Object.keys(diffModes).find(key => diffFile[`${key}_file`]); return ( diffModes[diffModeKey] || - (diffFile.mode_changed && diffModes.mode_changed) || + (diffFile.viewer && + diffFile.viewer.name === diffViewerModes.mode_changed && + diffViewerModes.mode_changed) || diffModes.replaced ); }; diff --git a/app/assets/javascripts/diffs/workers/tree_worker.js b/app/assets/javascripts/diffs/workers/tree_worker.js new file mode 100644 index 00000000000..534d737c77e --- /dev/null +++ b/app/assets/javascripts/diffs/workers/tree_worker.js @@ -0,0 +1,14 @@ +import { sortTree } from '~/ide/stores/utils'; +import { generateTreeList } from '../store/utils'; + +// eslint-disable-next-line no-restricted-globals +self.addEventListener('message', e => { + const { data } = e; + const { treeEntries, tree } = generateTreeList(data); + + // eslint-disable-next-line no-restricted-globals + self.postMessage({ + treeEntries, + tree: sortTree(tree), + }); +}); diff --git a/app/assets/javascripts/dirty_submit/dirty_submit_form.js b/app/assets/javascripts/dirty_submit/dirty_submit_form.js index d8d0fa1fac4..00e41dd0301 100644 --- a/app/assets/javascripts/dirty_submit/dirty_submit_form.js +++ b/app/assets/javascripts/dirty_submit/dirty_submit_form.js @@ -25,15 +25,16 @@ class DirtySubmitForm { DirtySubmitForm.THROTTLE_DURATION, ); this.form.addEventListener('input', throttledUpdateDirtyInput); + this.form.addEventListener('change', throttledUpdateDirtyInput); this.form.addEventListener('submit', event => this.formSubmit(event)); } updateDirtyInput(event) { - const input = event.target; + const { target } = event; - if (!input.dataset.isDirtySubmitInput) return; + if (!target.dataset.isDirtySubmitInput) return; - this.updateDirtyInputs(input); + this.updateDirtyInputs(target); this.toggleSubmission(); } diff --git a/app/assets/javascripts/dismissable_callout.js b/app/assets/javascripts/dismissable_callout.js deleted file mode 100644 index 5185b019376..00000000000 --- a/app/assets/javascripts/dismissable_callout.js +++ /dev/null @@ -1,27 +0,0 @@ -import $ from 'jquery'; -import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; -import Flash from '~/flash'; - -export default function initDismissableCallout(alertSelector) { - const alertEl = document.querySelector(alertSelector); - if (!alertEl) { - return; - } - - const closeButtonEl = alertEl.getElementsByClassName('close')[0]; - const { dismissEndpoint, featureId } = closeButtonEl.dataset; - - closeButtonEl.addEventListener('click', () => { - axios - .post(dismissEndpoint, { - feature_name: featureId, - }) - .then(() => { - $(alertEl).alert('close'); - }) - .catch(() => { - Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.')); - }); - }); -} diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index dbfcf8cc921..cb1b1173190 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -64,6 +64,7 @@ class DueDateSelect { this.saveDueDate(true); } }, + firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate($dueDateInput.val())); @@ -183,6 +184,7 @@ export default class DueDateSelectors { onSelect(dateText) { $datePicker.val(calendar.toString(dateText)); }, + firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate(datePickerVal)); diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index cd8dff40b88..36542315c4c 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -1,13 +1,58 @@ import _ from 'underscore'; -import emojiMap from 'emojis/digests.json'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; import emojiAliases from 'emojis/aliases.json'; +import axios from '../lib/utils/axios_utils'; -export const validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; +import AccessorUtilities from '../lib/utils/accessor'; + +let emojiMap = null; +let validEmojiNames = null; + +export const EMOJI_VERSION = '1'; +const EMOJI_VERSION_LOCALSTORAGE = `EMOJIS_${EMOJI_VERSION}`; + +const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + +export function initEmojiMap() { + return new Promise((resolve, reject) => { + if (emojiMap) { + resolve(emojiMap); + } else if (isLocalStorageAvailable && window.localStorage.getItem(EMOJI_VERSION_LOCALSTORAGE)) { + emojiMap = JSON.parse(window.localStorage.getItem(EMOJI_VERSION_LOCALSTORAGE)); + validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; + resolve(emojiMap); + } else { + // We load the JSON from server + axios + .get( + `${gon.asset_host || ''}${gon.relative_url_root || + ''}/-/emojis/${EMOJI_VERSION}/emojis.json`, + ) + .then(({ data }) => { + emojiMap = data; + validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; + resolve(emojiMap); + if (isLocalStorageAvailable) { + window.localStorage.setItem(EMOJI_VERSION_LOCALSTORAGE, JSON.stringify(emojiMap)); + } + }) + .catch(err => { + createFlash(s__('Emojis|Something went wrong while loading emojis.')); + reject(err); + }); + } + }); +} export function normalizeEmojiName(name) { return Object.prototype.hasOwnProperty.call(emojiAliases, name) ? emojiAliases[name] : name; } +export function getValidEmojiNames() { + return validEmojiNames; +} + export function isEmojiNameValid(name) { return validEmojiNames.indexOf(name) >= 0; } @@ -36,8 +81,8 @@ export function getEmojiCategoryMap() { }; Object.keys(emojiMap).forEach(name => { const emoji = emojiMap[name]; - if (emojiCategoryMap[emoji.category]) { - emojiCategoryMap[emoji.category].push(name); + if (emojiCategoryMap[emoji.c]) { + emojiCategoryMap[emoji.c].push(name); } }); } @@ -58,8 +103,9 @@ export function getEmojiInfo(query) { } export function emojiFallbackImageSrc(inputName) { - const { name, digest } = getEmojiInfo(inputName); - return `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/emoji/${name}-${digest}.png`; + const { name } = getEmojiInfo(inputName); + return `${gon.asset_host || ''}${gon.relative_url_root || + ''}/-/emojis/${EMOJI_VERSION}/${name}.png`; } export function emojiImageTag(name, src) { @@ -68,9 +114,8 @@ export function emojiImageTag(name, src) { export function glEmojiTag(inputName, options) { const opts = { sprite: false, forceFallback: false, ...options }; - const { name, ...emojiInfo } = getEmojiInfo(inputName); + const name = normalizeEmojiName(inputName); - const fallbackImageSrc = emojiFallbackImageSrc(name); const fallbackSpriteClass = `emoji-${name}`; const classList = []; @@ -79,24 +124,19 @@ export function glEmojiTag(inputName, options) { classList.push(fallbackSpriteClass); } const classAttribute = classList.length > 0 ? `class="${classList.join(' ')}"` : ''; + const fallbackSpriteAttribute = opts.sprite ? `data-fallback-sprite-class="${fallbackSpriteClass}"` : ''; - let contents = emojiInfo.moji; - if (opts.forceFallback && !opts.sprite) { - contents = emojiImageTag(name, fallbackImageSrc); - } + const forceFallbackAttribute = opts.forceFallback ? 'data-force-fallback="true"' : ''; return ` <gl-emoji ${classAttribute} data-name="${name}" - data-fallback-src="${fallbackImageSrc}" ${fallbackSpriteAttribute} - data-unicode-version="${emojiInfo.unicodeVersion}" - title="${emojiInfo.description}" + ${forceFallbackAttribute} > - ${contents} </gl-emoji> `; } diff --git a/app/assets/javascripts/emoji/no_emoji_validator.js b/app/assets/javascripts/emoji/no_emoji_validator.js new file mode 100644 index 00000000000..0fd4dd74953 --- /dev/null +++ b/app/assets/javascripts/emoji/no_emoji_validator.js @@ -0,0 +1,63 @@ +import { __ } from '~/locale'; +import emojiRegex from 'emoji-regex'; + +const invalidInputClass = 'gl-field-error-outline'; + +export default class NoEmojiValidator { + constructor(opts = {}) { + const container = opts.container || ''; + this.noEmojiEmelents = document.querySelectorAll(`${container} .js-block-emoji`); + + this.noEmojiEmelents.forEach(element => + element.addEventListener('input', this.eventHandler.bind(this)), + ); + } + + eventHandler(event) { + this.inputDomElement = event.target; + this.inputErrorMessage = this.inputDomElement.nextSibling; + + const { value } = this.inputDomElement; + + this.validatePattern(value); + this.setValidationStateAndMessage(); + } + + validatePattern(value) { + const pattern = emojiRegex(); + this.hasEmojis = new RegExp(pattern).test(value); + + if (this.hasEmojis) { + this.inputDomElement.setCustomValidity(__('Invalid input, please avoid emojis')); + } else { + this.inputDomElement.setCustomValidity(''); + } + } + + setValidationStateAndMessage() { + if (!this.inputDomElement.checkValidity()) { + this.setInvalidState(); + } else { + this.clearFieldValidationState(); + } + } + + clearFieldValidationState() { + this.inputDomElement.classList.remove(invalidInputClass); + this.inputErrorMessage.classList.add('hide'); + } + + setInvalidState() { + this.inputDomElement.classList.add(invalidInputClass); + this.setErrorMessage(); + } + + setErrorMessage() { + if (this.hasEmojis) { + this.inputErrorMessage.innerHTML = this.inputDomElement.validationMessage; + } else { + this.inputErrorMessage.innerHTML = this.inputDomElement.title; + } + this.inputErrorMessage.classList.remove('hide'); + } +} diff --git a/app/assets/javascripts/emoji/support/index.js b/app/assets/javascripts/emoji/support/index.js index 1f7852dd487..ed9bfb8bc78 100644 --- a/app/assets/javascripts/emoji/support/index.js +++ b/app/assets/javascripts/emoji/support/index.js @@ -5,6 +5,9 @@ import getUnicodeSupportMap from './unicode_support_map'; let browserUnicodeSupportMap; export default function isEmojiUnicodeSupportedByBrowser(emojiUnicode, unicodeVersion) { + // Our Spec browser would fail producing emoji maps + if (/\bHeadlessChrome\//.test(navigator.userAgent)) return true; + browserUnicodeSupportMap = browserUnicodeSupportMap || getUnicodeSupportMap(); return isEmojiUnicodeSupported(browserUnicodeSupportMap, emojiUnicode, unicodeVersion); } diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue new file mode 100644 index 00000000000..a8ee3f4ac10 --- /dev/null +++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue @@ -0,0 +1,108 @@ +<script> +/** + * Render modal to confirm rollback/redeploy. + */ + +import _ from 'underscore'; +import { GlModal } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; + +import eventHub from '../event_hub'; + +export default { + name: 'ConfirmRollbackModal', + + components: { + GlModal, + }, + + props: { + environment: { + type: Object, + required: true, + }, + }, + + computed: { + modalTitle() { + const title = this.environment.isLastDeployment + ? s__('Environments|Re-deploy environment %{name}?') + : s__('Environments|Rollback environment %{name}?'); + + return sprintf(title, { + name: _.escape(this.environment.name), + }); + }, + + commitShortSha() { + const { last_deployment } = this.environment; + return this.commitData(last_deployment, 'short_id'); + }, + + commitUrl() { + const { last_deployment } = this.environment; + return this.commitData(last_deployment, 'commit_path'); + }, + + commitTitle() { + const { last_deployment } = this.environment; + return this.commitData(last_deployment, 'title'); + }, + + modalText() { + const linkStart = `<a class="commit-sha" href="${_.escape(this.commitUrl)}">`; + const commitId = _.escape(this.commitShortSha); + const linkEnd = '</a>'; + const name = _.escape(this.name); + const body = this.environment.isLastDeployment + ? s__( + 'Environments|This action will relaunch the job for commit %{linkStart}%{commitId}%{linkEnd}, putting the environment in a previous version. Are you sure you want to continue?', + ) + : s__( + 'Environments|This action will run the job defined by %{name} for commit %{linkStart}%{commitId}%{linkEnd} putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?', + ); + return sprintf( + body, + { + commitId, + linkStart, + linkEnd, + name, + }, + false, + ); + }, + + modalActionText() { + return this.environment.isLastDeployment + ? s__('Environments|Re-deploy') + : s__('Environments|Rollback'); + }, + }, + + methods: { + onOk() { + eventHub.$emit('rollbackEnvironment', this.environment); + }, + + commitData(lastDeployment, key) { + if (lastDeployment && lastDeployment.commit) { + return lastDeployment.commit[key]; + } + + return ''; + }, + }, +}; +</script> +<template> + <gl-modal + :title="modalTitle" + modal-id="confirm-rollback-modal" + :ok-title="modalActionText" + ok-variant="danger" + @ok="onOk" + > + <p v-html="modalText"></p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index bd402c0eea5..6ece8b92a30 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -22,10 +22,6 @@ export default { type: Object, required: true, }, - canCreateDeployment: { - type: Boolean, - required: true, - }, canReadEnvironment: { type: Boolean, required: true, @@ -51,11 +47,7 @@ export default { <slot name="emptyState"></slot> <div v-if="!isLoading && environments.length > 0" class="table-holder"> - <environment-table - :environments="environments" - :can-create-deployment="canCreateDeployment" - :can-read-environment="canReadEnvironment" - /> + <environment-table :environments="environments" :can-read-environment="canReadEnvironment" /> <table-pagination v-if="pagination && pagination.totalPages > 1" diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 1f7dab9fbd2..208bd19f6b0 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -92,7 +92,7 @@ export default { :disabled="isActionDisabled(action)" type="button" class="js-manual-action-link no-btn btn d-flex align-items-center" - @click="onClickAction(action);" + @click="onClickAction(action)" > <span class="flex-fill"> {{ action.name }} </span> <span v-if="action.scheduledAt" class="text-secondary"> diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index f44806d82a6..1e89dce69cb 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -3,7 +3,6 @@ import Timeago from 'timeago.js'; import _ from 'underscore'; import { GlTooltipDirective } from '@gitlab/ui'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import { humanize } from '~/lib/utils/text_utility'; import Icon from '~/vue_shared/components/icon.vue'; import ActionsComponent from './environment_actions.vue'; import ExternalUrlComponent from './environment_external_url.vue'; @@ -47,12 +46,6 @@ export default { default: () => ({}), }, - canCreateDeployment: { - type: Boolean, - required: false, - default: false, - }, - canReadEnvironment: { type: Boolean, required: false, @@ -151,7 +144,7 @@ export default { }, actions() { - if (!this.model || !this.model.last_deployment || !this.canCreateDeployment) { + if (!this.model || !this.model.last_deployment) { return []; } @@ -162,7 +155,7 @@ export default { const combinedActions = (manualActions || []).concat(scheduledActions || []); return combinedActions.map(action => ({ ...action, - name: humanize(action.name), + name: action.name, })); }, @@ -561,7 +554,8 @@ export default { /> <rollback-component - v-if="canRetry && canCreateDeployment" + v-if="canRetry" + :environment="model" :is-last-deployment="isLastDeployment" :retry-url="retryUrl" /> diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index 50c86af057c..266cdc42518 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -5,29 +5,38 @@ * * Makes a post request when the button is clicked. */ +import { GlTooltipDirective, GlLoadingIcon, GlModalDirective, GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; -import { GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; +import ConfirmRollbackModal from './confirm_rollback_modal.vue'; import eventHub from '../event_hub'; export default { components: { Icon, GlLoadingIcon, + GlButton, + ConfirmRollbackModal, }, directives: { GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, }, props: { - retryUrl: { - type: String, - default: '', - }, - isLastDeployment: { type: Boolean, default: true, }, + + environment: { + type: Object, + required: true, + }, + + retryUrl: { + type: String, + required: true, + }, }, data() { return { @@ -45,23 +54,31 @@ export default { methods: { onClick() { - this.isLoading = true; - - eventHub.$emit('postAction', { endpoint: this.retryUrl }); + eventHub.$emit('requestRollbackEnvironment', { + ...this.environment, + retryUrl: this.retryUrl, + isLastDeployment: this.isLastDeployment, + }); + eventHub.$on('rollbackEnvironment', environment => { + if (environment.id === this.environment.id) { + this.isLoading = true; + } + }); }, }, }; </script> <template> - <button + <gl-button v-gl-tooltip + v-gl-modal.confirm-rollback-modal + variant="secondary" :disabled="isLoading" :title="title" - type="button" - class="btn d-none d-sm-none d-md-block" + class="d-none d-md-block" @click="onClick" > <icon v-if="isLastDeployment" name="repeat" /> <icon v-else name="redo" /> <gl-loading-icon v-if="isLoading" /> - </button> + </gl-button> </template> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index ae9459a2482..6e55c3f901a 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -6,11 +6,13 @@ import eventHub from '../event_hub'; import environmentsMixin from '../mixins/environments_mixin'; import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; import StopEnvironmentModal from './stop_environment_modal.vue'; +import ConfirmRollbackModal from './confirm_rollback_modal.vue'; export default { components: { emptyState, StopEnvironmentModal, + ConfirmRollbackModal, }, mixins: [CIPaginationMixin, environmentsMixin], @@ -24,10 +26,6 @@ export default { type: Boolean, required: true, }, - canCreateDeployment: { - type: Boolean, - required: true, - }, canReadEnvironment: { type: Boolean, required: true, @@ -91,14 +89,15 @@ export default { <template> <div :class="cssContainerClass"> <stop-environment-modal :environment="environmentInStopModal" /> + <confirm-rollback-modal :environment="environmentInRollbackModal" /> <div class="top-area"> <tabs :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" /> <div v-if="canCreateEnvironment && !isLoading" class="nav-controls"> - <a :href="newEnvironmentPath" class="btn btn-success"> - {{ s__('Environments|New environment') }} - </a> + <a :href="newEnvironmentPath" class="btn btn-success">{{ + s__('Environments|New environment') + }}</a> </div> </div> @@ -106,7 +105,6 @@ export default { :is-loading="isLoading" :environments="state.environments" :pagination="state.paginationInformation" - :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" @onChangePage="onChangePage" > diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 533e90e2222..eef141a07ba 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -3,6 +3,7 @@ * Render environments table. */ import { GlLoadingIcon } from '@gitlab/ui'; +import _ from 'underscore'; import environmentItem from './environment_item.vue'; export default { @@ -23,11 +24,14 @@ export default { required: false, default: false, }, - - canCreateDeployment: { - type: Boolean, - required: false, - default: false, + }, + computed: { + sortedEnvironments() { + return this.sortEnvironments(this.environments).map(env => + this.shouldRenderFolderContent(env) + ? { ...env, children: this.sortEnvironments(env.children) } + : env, + ); }, }, methods: { @@ -37,6 +41,30 @@ export default { shouldRenderFolderContent(env) { return env.isFolder && env.isOpen && env.children && env.children.length > 0; }, + sortEnvironments(environments) { + /* + * The sorting algorithm should sort in the following priorities: + * + * 1. folders first, + * 2. last updated descending, + * 3. by name ascending, + * + * the sorting algorithm must: + * + * 1. Sort by name ascending, + * 2. Reverse (sort by name descending), + * 3. Sort by last deployment ascending, + * 4. Reverse (last deployment descending, name ascending), + * 5. Put folders first. + */ + return _.chain(environments) + .sortBy(env => (env.isFolder ? env.folderName : env.name)) + .reverse() + .sortBy(env => (env.last_deployment ? env.last_deployment.created_at : '0000')) + .reverse() + .sortBy(env => (env.isFolder ? -1 : 1)) + .value(); + }, }, }; </script> @@ -59,12 +87,11 @@ export default { {{ s__('Environments|Updated') }} </div> </div> - <template v-for="(model, i) in environments" :model="model"> + <template v-for="(model, i) in sortedEnvironments" :model="model"> <div is="environment-item" :key="`environment-item-${i}`" :model="model" - :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" /> @@ -79,15 +106,14 @@ export default { v-for="(children, index) in model.children" :key="`env-item-${i}-${index}`" :model="children" - :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" /> <div :key="`sub-div-${i}`"> <div class="text-center prepend-top-10"> - <a :href="folderUrl(model)" class="btn btn-default"> - {{ s__('Environments|Show all') }} - </a> + <a :href="folderUrl(model)" class="btn btn-default">{{ + s__('Environments|Show all') + }}</a> </div> </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 3cf6e4ad14d..56e7f69cad6 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -15,11 +15,10 @@ export default () => const environmentsData = document.querySelector(this.$options.el).dataset; return { - endpoint: environmentsData.endpoint, - folderName: environmentsData.folderName, + endpoint: environmentsData.environmentsDataEndpoint, + folderName: environmentsData.environmentsDataFolderName, cssContainerClass: environmentsData.cssClass, - canCreateDeployment: parseBoolean(environmentsData.canCreateDeployment), - canReadEnvironment: parseBoolean(environmentsData.canReadEnvironment), + canReadEnvironment: parseBoolean(environmentsData.environmentsDataCanReadEnvironment), }; }, render(createElement) { @@ -28,7 +27,6 @@ export default () => endpoint: this.endpoint, folderName: this.folderName, cssContainerClass: this.cssContainerClass, - canCreateDeployment: this.canCreateDeployment, canReadEnvironment: this.canReadEnvironment, }, }); diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index d6f0b6115a6..80f0e00400b 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -23,10 +23,6 @@ export default { type: String, required: true, }, - canCreateDeployment: { - type: Boolean, - required: true, - }, canReadEnvironment: { type: Boolean, required: true, @@ -55,7 +51,6 @@ export default { :is-loading="isLoading" :environments="state.environments" :pagination="state.paginationInformation" - :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" @onChangePage="onChangePage" /> diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index d366e7550b7..6af66d0f86e 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -20,7 +20,6 @@ export default () => helpPagePath: environmentsData.helpPagePath, cssContainerClass: environmentsData.cssClass, canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment), - canCreateDeployment: parseBoolean(environmentsData.canCreateDeployment), canReadEnvironment: parseBoolean(environmentsData.canReadEnvironment), }; }, @@ -32,7 +31,6 @@ export default () => helpPagePath: this.helpPagePath, cssContainerClass: this.cssContainerClass, canCreateEnvironment: this.canCreateEnvironment, - canCreateDeployment: this.canCreateDeployment, canReadEnvironment: this.canReadEnvironment, }, }); diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index 96dc1f07cb9..71b6b578196 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -36,6 +36,7 @@ export default { page: getParameterByName('page') || '1', requestData: {}, environmentInStopModal: {}, + environmentInRollbackModal: {}, }; }, @@ -43,7 +44,11 @@ export default { saveData(resp) { this.isLoading = false; - if (_.isEqual(resp.config.params, this.requestData)) { + // Prevent the absence of the nested flag from causing mismatches + const response = this.filterNilValues(resp.config.params); + const request = this.filterNilValues(this.requestData); + + if (_.isEqual(response, request)) { this.store.storeAvailableCount(resp.data.available_count); this.store.storeStoppedCount(resp.data.stopped_count); this.store.storeEnvironments(resp.data.environments); @@ -51,6 +56,10 @@ export default { } }, + filterNilValues(obj) { + return _.omit(obj, value => _.isUndefined(value) || _.isNull(value)); + }, + /** * Handles URL and query parameter changes. * When the user uses the pagination or the tabs, @@ -64,10 +73,9 @@ export default { // fetch new data return this.service .fetchEnvironments(this.requestData) - .then(response => this.successCallback(response)) - .then(() => { - // restart polling - this.poll.restart({ data: this.requestData }); + .then(response => { + this.successCallback(response); + this.poll.enable({ data: this.requestData, response }); }) .catch(() => { this.errorCallback(); @@ -109,6 +117,10 @@ export default { this.environmentInStopModal = environment; }, + updateRollbackModal(environment) { + this.environmentInRollbackModal = environment; + }, + stopEnvironment(environment) { const endpoint = environment.stop_path; const errorMessage = s__( @@ -116,6 +128,16 @@ export default { ); this.postAction({ endpoint, errorMessage }); }, + + rollbackEnvironment(environment) { + const { retryUrl, isLastDeployment } = environment; + const errorMessage = isLastDeployment + ? s__('Environments|An error occurred while re-deploying the environment, please try again') + : s__( + 'Environments|An error occurred while rolling back the environment, please try again', + ); + this.postAction({ endpoint: retryUrl, errorMessage }); + }, }, computed: { @@ -143,7 +165,7 @@ export default { */ created() { this.service = new EnvironmentsService(this.endpoint); - this.requestData = { page: this.page, scope: this.scope }; + this.requestData = { page: this.page, scope: this.scope, nested: true }; this.poll = new Poll({ resource: this.service, @@ -174,11 +196,17 @@ export default { eventHub.$on('postAction', this.postAction); eventHub.$on('requestStopEnvironment', this.updateStopModal); eventHub.$on('stopEnvironment', this.stopEnvironment); + + eventHub.$on('requestRollbackEnvironment', this.updateRollbackModal); + eventHub.$on('rollbackEnvironment', this.rollbackEnvironment); }, beforeDestroy() { eventHub.$off('postAction', this.postAction); eventHub.$off('requestStopEnvironment', this.updateStopModal); eventHub.$off('stopEnvironment', this.stopEnvironment); + + eventHub.$off('requestRollbackEnvironment', this.updateRollbackModal); + eventHub.$off('rollbackEnvironment', this.rollbackEnvironment); }, }; diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js index 4e07ccba91a..cb4ff6856db 100644 --- a/app/assets/javascripts/environments/services/environments_service.js +++ b/app/assets/javascripts/environments/services/environments_service.js @@ -7,8 +7,8 @@ export default class EnvironmentsService { } fetchEnvironments(options = {}) { - const { scope, page } = options; - return axios.get(this.environmentsEndpoint, { params: { scope, page } }); + const { scope, page, nested } = options; + return axios.get(this.environmentsEndpoint, { params: { scope, page, nested } }); } // eslint-disable-next-line class-methods-use-this diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index 5808a2d4afa..ac9a31c202c 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -20,7 +20,8 @@ export default class EnvironmentsStore { * * Stores the received environments. * - * In the main environments endpoint, each environment has the following schema + * In the main environments endpoint (with { nested: true } in params), each folder + * has the following schema: * { name: String, size: Number, latest: Object } * In the endpoint to retrieve environments from each folder, the environment does * not have the `latest` key and the data is all in the root level. diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue new file mode 100644 index 00000000000..43ae54133af --- /dev/null +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -0,0 +1,124 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlEmptyState, GlButton, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { __ } from '~/locale'; + +export default { + fields: [ + { key: 'error', label: __('Open errors') }, + { key: 'events', label: __('Events') }, + { key: 'users', label: __('Users') }, + { key: 'lastSeen', label: __('Last seen') }, + ], + components: { + GlEmptyState, + GlButton, + GlLink, + GlLoadingIcon, + GlTable, + Icon, + TimeAgo, + }, + props: { + indexPath: { + type: String, + required: true, + }, + enableErrorTrackingLink: { + type: String, + required: true, + }, + errorTrackingEnabled: { + type: Boolean, + required: true, + }, + illustrationPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['errors', 'externalUrl', 'loading']), + }, + created() { + if (this.errorTrackingEnabled) { + this.startPolling(this.indexPath); + } + }, + methods: { + ...mapActions(['startPolling', 'restartPolling']), + }, +}; +</script> + +<template> + <div> + <div v-if="errorTrackingEnabled"> + <div v-if="loading" class="py-3"> + <gl-loading-icon :size="3" /> + </div> + <div v-else> + <div class="d-flex justify-content-end"> + <gl-button class="my-3 ml-auto" variant="primary" :href="externalUrl" target="_blank"> + {{ __('View in Sentry') }} + <icon name="external-link" /> + </gl-button> + </div> + <gl-table :items="errors" :fields="$options.fields" :show-empty="true"> + <template slot="HEAD_events" slot-scope="data"> + <div class="text-right">{{ data.label }}</div> + </template> + <template slot="HEAD_users" slot-scope="data"> + <div class="text-right">{{ data.label }}</div> + </template> + <template slot="error" slot-scope="errors"> + <div class="d-flex flex-column"> + <div class="d-flex"> + <gl-link :href="errors.item.externalUrl" class="d-flex text-dark" target="_blank"> + <strong>{{ errors.item.title.trim() }}</strong> + <icon name="external-link" class="ml-1" /> + </gl-link> + <span class="text-secondary ml-2">{{ errors.item.culprit }}</span> + </div> + {{ errors.item.message || __('No details available') }} + </div> + </template> + + <template slot="events" slot-scope="errors"> + <div class="text-right">{{ errors.item.count }}</div> + </template> + + <template slot="users" slot-scope="errors"> + <div class="text-right">{{ errors.item.userCount }}</div> + </template> + + <template slot="lastSeen" slot-scope="errors"> + <div class="d-flex align-items-center"> + <icon name="calendar" css-classes="text-secondary mr-1" /> + <time-ago :time="errors.item.lastSeen" class="text-secondary" /> + </div> + </template> + <template slot="empty"> + <div ref="empty"> + {{ __('No errors to display.') }} + <gl-link class="js-try-again" @click="restartPolling"> + {{ __('Check again') }} + </gl-link> + </div> + </template> + </gl-table> + </div> + </div> + <div v-else> + <gl-empty-state + :title="__('Get started with error tracking')" + :description="__('Monitor your errors by integrating with Sentry')" + :primary-button-text="__('Enable error tracking')" + :primary-button-link="enableErrorTrackingLink" + :svg-path="illustrationPath" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/error_tracking/index.js b/app/assets/javascripts/error_tracking/index.js new file mode 100644 index 00000000000..3d609448efe --- /dev/null +++ b/app/assets/javascripts/error_tracking/index.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import store from './store'; +import ErrorTrackingList from './components/error_tracking_list.vue'; + +export default () => { + // eslint-disable-next-line no-new + new Vue({ + el: '#js-error_tracking', + components: { + ErrorTrackingList, + }, + store, + render(createElement) { + const domEl = document.querySelector(this.$options.el); + const { indexPath, enableErrorTrackingLink, illustrationPath } = domEl.dataset; + let { errorTrackingEnabled } = domEl.dataset; + + errorTrackingEnabled = parseBoolean(errorTrackingEnabled); + + return createElement('error-tracking-list', { + props: { + indexPath, + enableErrorTrackingLink, + errorTrackingEnabled, + illustrationPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/error_tracking/services/index.js b/app/assets/javascripts/error_tracking/services/index.js new file mode 100644 index 00000000000..ab89521dc46 --- /dev/null +++ b/app/assets/javascripts/error_tracking/services/index.js @@ -0,0 +1,7 @@ +import axios from '~/lib/utils/axios_utils'; + +export default { + getErrorList({ endpoint }) { + return axios.get(endpoint); + }, +}; diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js new file mode 100644 index 00000000000..d42e4f145dc --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/actions.js @@ -0,0 +1,52 @@ +import Service from '../services'; +import * as types from './mutation_types'; +import createFlash from '~/flash'; +import Poll from '~/lib/utils/poll'; +import { __, sprintf } from '~/locale'; + +let eTagPoll; + +export function startPolling({ commit, dispatch }, endpoint) { + eTagPoll = new Poll({ + resource: Service, + method: 'getErrorList', + data: { endpoint }, + successCallback: ({ data }) => { + if (!data) { + return; + } + commit(types.SET_ERRORS, data.errors); + commit(types.SET_EXTERNAL_URL, data.external_url); + commit(types.SET_LOADING, false); + dispatch('stopPolling'); + }, + errorCallback: response => { + let errorMessage = ''; + if (response && response.data && response.data.message) { + errorMessage = response.data.message; + } + commit(types.SET_LOADING, false); + createFlash( + sprintf(__(`Failed to load errors from Sentry. Error message: %{errorMessage}`), { + errorMessage, + }), + ); + }, + }); + + eTagPoll.makeRequest(); +} + +export const stopPolling = () => { + if (eTagPoll) eTagPoll.stop(); +}; + +export function restartPolling({ commit }) { + commit(types.SET_ERRORS, []); + commit(types.SET_EXTERNAL_URL, ''); + commit(types.SET_LOADING, true); + + if (eTagPoll) eTagPoll.restart(); +} + +export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/index.js b/app/assets/javascripts/error_tracking/store/index.js new file mode 100644 index 00000000000..3136682fb64 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/index.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + state: { + errors: [], + externalUrl: '', + loading: true, + }, + actions, + mutations, + }); + +export default createStore(); diff --git a/app/assets/javascripts/error_tracking/store/mutation_types.js b/app/assets/javascripts/error_tracking/store/mutation_types.js new file mode 100644 index 00000000000..f9d77a6b08e --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/mutation_types.js @@ -0,0 +1,3 @@ +export const SET_ERRORS = 'SET_ERRORS'; +export const SET_EXTERNAL_URL = 'SET_EXTERNAL_URL'; +export const SET_LOADING = 'SET_LOADING'; diff --git a/app/assets/javascripts/error_tracking/store/mutations.js b/app/assets/javascripts/error_tracking/store/mutations.js new file mode 100644 index 00000000000..e4bd81db9c9 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/mutations.js @@ -0,0 +1,14 @@ +import * as types from './mutation_types'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +export default { + [types.SET_ERRORS](state, data) { + state.errors = convertObjectPropsToCamelCase(data, { deep: true }); + }, + [types.SET_EXTERNAL_URL](state, url) { + state.externalUrl = url; + }, + [types.SET_LOADING](state, loading) { + state.loading = loading; + }, +}; diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue new file mode 100644 index 00000000000..50eb3e63b7c --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/components/app.vue @@ -0,0 +1,129 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import { GlButton } from '@gitlab/ui'; +import ProjectDropdown from './project_dropdown.vue'; +import ErrorTrackingForm from './error_tracking_form.vue'; + +export default { + components: { ProjectDropdown, ErrorTrackingForm, GlButton }, + props: { + initialApiHost: { + type: String, + required: false, + default: '', + }, + initialEnabled: { + type: String, + required: true, + }, + initialProject: { + type: String, + required: false, + default: null, + }, + initialToken: { + type: String, + required: false, + default: '', + }, + listProjectsEndpoint: { + type: String, + required: true, + }, + operationsSettingsEndpoint: { + type: String, + required: true, + }, + }, + computed: { + ...mapGetters([ + 'dropdownLabel', + 'hasProjects', + 'invalidProjectLabel', + 'isProjectInvalid', + 'projectSelectionLabel', + ]), + ...mapState([ + 'apiHost', + 'connectError', + 'connectSuccessful', + 'enabled', + 'projects', + 'selectedProject', + 'settingsLoading', + 'token', + ]), + }, + created() { + this.setInitialState({ + apiHost: this.initialApiHost, + enabled: this.initialEnabled, + project: this.initialProject, + token: this.initialToken, + listProjectsEndpoint: this.listProjectsEndpoint, + operationsSettingsEndpoint: this.operationsSettingsEndpoint, + }); + }, + methods: { + ...mapActions([ + 'fetchProjects', + 'setInitialState', + 'updateApiHost', + 'updateEnabled', + 'updateSelectedProject', + 'updateSettings', + 'updateToken', + ]), + handleSubmit() { + this.updateSettings(); + }, + }, +}; +</script> + +<template> + <div> + <div class="form-check form-group"> + <input + id="error-tracking-enabled" + :checked="enabled" + class="form-check-input" + type="checkbox" + @change="updateEnabled($event.target.checked)" + /> + <label class="form-check-label" for="error-tracking-enabled">{{ + s__('ErrorTracking|Active') + }}</label> + </div> + <error-tracking-form + :api-host="apiHost" + :connect-error="connectError" + :connect-successful="connectSuccessful" + :token="token" + @handle-connect="fetchProjects" + @update-api-host="updateApiHost" + @update-token="updateToken" + /> + <div class="form-group"> + <project-dropdown + :has-projects="hasProjects" + :invalid-project-label="invalidProjectLabel" + :is-project-invalid="isProjectInvalid" + :dropdown-label="dropdownLabel" + :project-selection-label="projectSelectionLabel" + :projects="projects" + :selected-project="selectedProject" + :token="token" + @select-project="updateSelectedProject" + /> + </div> + <gl-button + :disabled="settingsLoading" + class="js-error-tracking-button" + variant="success" + @click="handleSubmit" + > + {{ __('Save changes') }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue new file mode 100644 index 00000000000..060d8e25227 --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue @@ -0,0 +1,91 @@ +<script> +import { GlButton, GlFormInput } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { GlButton, GlFormInput, Icon }, + props: { + apiHost: { + type: String, + required: true, + }, + connectError: { + type: Boolean, + required: true, + }, + connectSuccessful: { + type: Boolean, + required: true, + }, + token: { + type: String, + required: true, + }, + }, + computed: { + tokenInputState() { + return this.connectError ? false : null; + }, + }, +}; +</script> + +<template> + <div> + <div class="form-group"> + <label class="label-bold" for="error-tracking-api-host">{{ __('Sentry API URL') }}</label> + <div class="row"> + <div class="col-8 col-md-9 gl-pr-0"> + <gl-form-input + id="error-tracking-api-host" + :value="apiHost" + placeholder="https://mysentryserver.com" + @input="$emit('update-api-host', $event)" + /> + </div> + </div> + <p class="form-text text-muted"> + {{ s__('ErrorTracking|Find your hostname in your Sentry account settings page') }} + </p> + </div> + <div class="form-group" :class="{ 'gl-show-field-errors': connectError }"> + <label class="label-bold" for="error-tracking-token">{{ + s__('ErrorTracking|Auth Token') + }}</label> + <div class="row"> + <div class="col-8 col-md-9 gl-pr-0"> + <gl-form-input + id="error-tracking-token" + :value="token" + :state="tokenInputState" + @input="$emit('update-token', $event)" + /> + </div> + <div class="col-4 col-md-3 gl-pl-0"> + <gl-button + class="js-error-tracking-connect prepend-left-5" + @click="$emit('handle-connect')" + > + {{ __('Connect') }} + </gl-button> + <icon + v-show="connectSuccessful" + class="js-error-tracking-connect-success prepend-left-5 text-success align-middle" + :aria-label="__('Projects Successfully Retrieved')" + name="check-circle" + /> + </div> + </div> + <p v-if="connectError" class="gl-field-error"> + {{ s__('ErrorTracking|Connection has failed. Re-check Auth Token and try again.') }} + </p> + <p v-else class="form-text text-muted"> + {{ + s__( + "ErrorTracking|After adding your Auth Token, use the 'Connect' button to load projects", + ) + }} + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue new file mode 100644 index 00000000000..82df02afafd --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue @@ -0,0 +1,82 @@ +<script> +import { GlDropdown, GlDropdownHeader, GlDropdownItem } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { getDisplayName } from '../utils'; + +export default { + components: { + GlDropdown, + GlDropdownHeader, + GlDropdownItem, + Icon, + }, + props: { + dropdownLabel: { + type: String, + required: true, + }, + hasProjects: { + type: Boolean, + required: true, + }, + invalidProjectLabel: { + type: String, + required: true, + }, + isProjectInvalid: { + type: Boolean, + required: true, + }, + projects: { + type: Array, + required: true, + }, + selectedProject: { + type: Object, + required: false, + default: null, + }, + projectSelectionLabel: { + type: String, + required: true, + }, + token: { + type: String, + required: true, + }, + }, + methods: { + getDisplayName, + }, +}; +</script> + +<template> + <div :class="{ 'gl-show-field-errors': isProjectInvalid }"> + <label class="label-bold" for="project-dropdown">{{ __('Project') }}</label> + <div class="row"> + <gl-dropdown + id="project-dropdown" + class="col-8 col-md-9 gl-pr-0" + :disabled="!hasProjects" + menu-class="w-100 mw-100" + toggle-class="dropdown-menu-toggle w-100 gl-field-error-outline" + :text="dropdownLabel" + > + <gl-dropdown-item + v-for="project in projects" + :key="`${project.organizationSlug}.${project.slug}`" + class="w-100" + @click="$emit('select-project', project)" + >{{ getDisplayName(project) }}</gl-dropdown-item + > + </gl-dropdown> + </div> + <p v-if="isProjectInvalid" class="js-project-dropdown-error gl-field-error"> + {{ invalidProjectLabel }} + </p> + <p v-else-if="!hasProjects" class="js-project-dropdown-label form-text text-muted"> + {{ projectSelectionLabel }} + </p> + </div> +</template> diff --git a/app/assets/javascripts/error_tracking_settings/index.js b/app/assets/javascripts/error_tracking_settings/index.js new file mode 100644 index 00000000000..ce315963723 --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/index.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import ErrorTrackingSettings from './components/app.vue'; +import createStore from './store'; + +export default () => { + const formContainerEl = document.querySelector('.js-error-tracking-form'); + const { + dataset: { apiHost, enabled, project, token, listProjectsEndpoint, operationsSettingsEndpoint }, + } = formContainerEl; + + return new Vue({ + el: formContainerEl, + store: createStore(), + render(createElement) { + return createElement(ErrorTrackingSettings, { + props: { + initialApiHost: apiHost, + initialEnabled: enabled, + initialProject: project, + initialToken: token, + listProjectsEndpoint, + operationsSettingsEndpoint, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js new file mode 100644 index 00000000000..95105797807 --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/store/actions.js @@ -0,0 +1,91 @@ +import { __ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import createFlash from '~/flash'; +import { transformFrontendSettings } from '../utils'; +import * as types from './mutation_types'; + +export const requestProjects = ({ commit }) => { + commit(types.RESET_CONNECT); +}; + +export const receiveProjectsSuccess = ({ commit }, projects) => { + commit(types.UPDATE_CONNECT_SUCCESS); + commit(types.RECEIVE_PROJECTS, projects); +}; + +export const receiveProjectsError = ({ commit }) => { + commit(types.UPDATE_CONNECT_ERROR); + commit(types.CLEAR_PROJECTS); +}; + +export const fetchProjects = ({ dispatch, state }) => { + dispatch('requestProjects'); + return axios + .post(state.listProjectsEndpoint, { + error_tracking_setting: { + api_host: state.apiHost, + token: state.token, + }, + }) + .then(({ data: { projects } }) => { + dispatch('receiveProjectsSuccess', projects); + }) + .catch(() => { + dispatch('receiveProjectsError'); + }); +}; + +export const requestSettings = ({ commit }) => { + commit(types.UPDATE_SETTINGS_LOADING, true); +}; + +export const receiveSettingsError = ({ commit }, { response = {} }) => { + const message = response.data && response.data.message ? response.data.message : ''; + + createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert'); + commit(types.UPDATE_SETTINGS_LOADING, false); +}; + +export const updateSettings = ({ dispatch, state }) => { + dispatch('requestSettings'); + return axios + .patch(state.operationsSettingsEndpoint, { + project: { + error_tracking_setting_attributes: { + ...transformFrontendSettings(state), + }, + }, + }) + .then(() => { + refreshCurrentPage(); + }) + .catch(err => { + dispatch('receiveSettingsError', err); + }); +}; + +export const updateApiHost = ({ commit }, apiHost) => { + commit(types.UPDATE_API_HOST, apiHost); + commit(types.RESET_CONNECT); +}; + +export const updateEnabled = ({ commit }, enabled) => { + commit(types.UPDATE_ENABLED, enabled); +}; + +export const updateToken = ({ commit }, token) => { + commit(types.UPDATE_TOKEN, token); + commit(types.RESET_CONNECT); +}; + +export const updateSelectedProject = ({ commit }, selectedProject) => { + commit(types.UPDATE_SELECTED_PROJECT, selectedProject); +}; + +export const setInitialState = ({ commit }, data) => { + commit(types.SET_INITIAL_STATE, data); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/error_tracking_settings/store/getters.js b/app/assets/javascripts/error_tracking_settings/store/getters.js new file mode 100644 index 00000000000..a008b181907 --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/store/getters.js @@ -0,0 +1,44 @@ +import _ from 'underscore'; +import { __, s__, sprintf } from '~/locale'; +import { getDisplayName } from '../utils'; + +export const hasProjects = state => !!state.projects && state.projects.length > 0; + +export const isProjectInvalid = (state, getters) => + !!state.selectedProject && + getters.hasProjects && + !state.projects.some(project => _.isMatch(state.selectedProject, project)); + +export const dropdownLabel = (state, getters) => { + if (state.selectedProject !== null) { + return getDisplayName(state.selectedProject); + } + if (!getters.hasProjects) { + return s__('ErrorTracking|No projects available'); + } + return s__('ErrorTracking|Select project'); +}; + +export const invalidProjectLabel = state => { + if (state.selectedProject) { + return sprintf( + __('Project "%{name}" is no longer available. Select another project to continue.'), + { + name: state.selectedProject.name, + }, + ); + } + return ''; +}; + +export const projectSelectionLabel = state => { + if (state.token) { + return s__( + "ErrorTracking|Click 'Connect' to re-establish the connection to Sentry and activate the dropdown.", + ); + } + return s__('ErrorTracking|To enable project selection, enter a valid Auth Token'); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/error_tracking_settings/store/index.js b/app/assets/javascripts/error_tracking_settings/store/index.js new file mode 100644 index 00000000000..560f265a2ea --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + state: createState(), + actions, + getters, + mutations, + }); diff --git a/app/assets/javascripts/error_tracking_settings/store/mutation_types.js b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js new file mode 100644 index 00000000000..b4f8a237947 --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js @@ -0,0 +1,11 @@ +export const CLEAR_PROJECTS = 'CLEAR_PROJECTS'; +export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; +export const RECEIVE_PROJECTS = 'RECEIVE_PROJECTS'; +export const RESET_CONNECT = 'RESET_CONNECT'; +export const UPDATE_API_HOST = 'UPDATE_API_HOST'; +export const UPDATE_CONNECT_ERROR = 'UPDATE_CONNECT_ERROR'; +export const UPDATE_CONNECT_SUCCESS = 'UPDATE_CONNECT_SUCCESS'; +export const UPDATE_ENABLED = 'UPDATE_ENABLED'; +export const UPDATE_SELECTED_PROJECT = 'UPDATE_SELECTED_PROJECT'; +export const UPDATE_SETTINGS_LOADING = 'UPDATE_SETTINGS_LOADING'; +export const UPDATE_TOKEN = 'UPDATE_TOKEN'; diff --git a/app/assets/javascripts/error_tracking_settings/store/mutations.js b/app/assets/javascripts/error_tracking_settings/store/mutations.js new file mode 100644 index 00000000000..4089d1ee94e --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/store/mutations.js @@ -0,0 +1,61 @@ +import _ from 'underscore'; +import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; +import * as types from './mutation_types'; +import { projectKeys } from '../utils'; + +export default { + [types.CLEAR_PROJECTS](state) { + state.projects = []; + }, + [types.RECEIVE_PROJECTS](state, projects) { + state.projects = projects + .map(convertObjectPropsToCamelCase) + // The `pick` strips out extra properties returned from Sentry. + // Such properties could be problematic later, e.g. when checking whether `projects` contains `selectedProject` + .map(project => _.pick(project, projectKeys)); + }, + [types.RESET_CONNECT](state) { + state.connectSuccessful = false; + state.connectError = false; + }, + [types.SET_INITIAL_STATE]( + state, + { apiHost, enabled, project, token, listProjectsEndpoint, operationsSettingsEndpoint }, + ) { + state.enabled = parseBoolean(enabled); + state.apiHost = apiHost; + state.token = token; + state.listProjectsEndpoint = listProjectsEndpoint; + state.operationsSettingsEndpoint = operationsSettingsEndpoint; + + if (project) { + state.selectedProject = _.pick( + convertObjectPropsToCamelCase(JSON.parse(project)), + projectKeys, + ); + } + }, + [types.UPDATE_API_HOST](state, apiHost) { + state.apiHost = apiHost; + }, + [types.UPDATE_ENABLED](state, enabled) { + state.enabled = enabled; + }, + [types.UPDATE_TOKEN](state, token) { + state.token = token; + }, + [types.UPDATE_SELECTED_PROJECT](state, selectedProject) { + state.selectedProject = selectedProject; + }, + [types.UPDATE_SETTINGS_LOADING](state, settingsLoading) { + state.settingsLoading = settingsLoading; + }, + [types.UPDATE_CONNECT_SUCCESS](state) { + state.connectSuccessful = true; + state.connectError = false; + }, + [types.UPDATE_CONNECT_ERROR](state) { + state.connectSuccessful = false; + state.connectError = true; + }, +}; diff --git a/app/assets/javascripts/error_tracking_settings/store/state.js b/app/assets/javascripts/error_tracking_settings/store/state.js new file mode 100644 index 00000000000..98219d33f4d --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/store/state.js @@ -0,0 +1,12 @@ +export default () => ({ + apiHost: '', + enabled: false, + token: '', + projects: [], + selectedProject: null, + settingsLoading: false, + connectSuccessful: false, + connectError: false, + listProjectsEndpoint: '', + operationsSettingsEndpoint: '', +}); diff --git a/app/assets/javascripts/error_tracking_settings/utils.js b/app/assets/javascripts/error_tracking_settings/utils.js new file mode 100644 index 00000000000..6613e04ee0e --- /dev/null +++ b/app/assets/javascripts/error_tracking_settings/utils.js @@ -0,0 +1,18 @@ +export const projectKeys = ['name', 'organizationName', 'organizationSlug', 'slug']; + +export const transformFrontendSettings = ({ apiHost, enabled, token, selectedProject }) => { + const project = selectedProject + ? { + slug: selectedProject.slug, + name: selectedProject.name, + organization_name: selectedProject.organizationName, + organization_slug: selectedProject.organizationSlug, + } + : null; + + return { api_host: apiHost || null, enabled, token: token || null, project }; +}; + +export const getDisplayName = project => `${project.organizationName} | ${project.name}`; + +export default () => {}; diff --git a/app/assets/javascripts/feature_highlight/feature_highlight.js b/app/assets/javascripts/feature_highlight/feature_highlight.js index 173fe7c69de..be55e6923c6 100644 --- a/app/assets/javascripts/feature_highlight/feature_highlight.js +++ b/app/assets/javascripts/feature_highlight/feature_highlight.js @@ -31,12 +31,14 @@ export function setupFeatureHighlightPopover(id, debounceTimeout = 300) { .removeAttr('disabled'); } +const getPriority = e => parseInt(e.dataset.highlightPriority, 10) || 0; + export function findHighestPriorityFeature() { let priorityFeature; const sortedFeatureEls = [].slice .call(document.querySelectorAll('.js-feature-highlight')) - .sort((a, b) => (a.dataset.highlightPriority || 0) < (b.dataset.highlightPriority || 0)); + .sort((a, b) => getPriority(b) - getPriority(a)); const [priorityFeatureEl] = sortedFeatureEls; if (priorityFeatureEl) { diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js new file mode 100644 index 00000000000..54ea936252e --- /dev/null +++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js @@ -0,0 +1,16 @@ +export default IssuableTokenKeys => { + const wipToken = { + key: 'wip', + type: 'string', + param: '', + symbol: '', + icon: 'admin', + tag: 'Yes or No', + lowercaseValueOnSubmit: true, + uppercaseTokenName: true, + capitalizeTokenValue: true, + }; + + IssuableTokenKeys.tokenKeys.push(wipToken); + IssuableTokenKeys.tokenKeysWithAlternative.push(wipToken); +}; diff --git a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js index 934375023ba..691d165c585 100644 --- a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js @@ -17,6 +17,14 @@ const tokenKeys = [ icon: 'cube', tag: 'type', }, + { + key: 'tag', + type: 'array', + param: 'name[]', + symbol: '~', + icon: 'tag', + tag: '~tag', + }, ]; const AdminRunnersFilteredSearchTokenKeys = new FilteredSearchTokenKeys(tokenKeys); diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js new file mode 100644 index 00000000000..e2f9c03ee65 --- /dev/null +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -0,0 +1,133 @@ +import DropdownHint from './dropdown_hint'; +import DropdownUser from './dropdown_user'; +import DropdownNonUser from './dropdown_non_user'; +import DropdownEmoji from './dropdown_emoji'; +import NullDropdown from './null_dropdown'; +import DropdownAjaxFilter from './dropdown_ajax_filter'; +import DropdownUtils from './dropdown_utils'; + +export default class AvailableDropdownMappings { + constructor(container, baseEndpoint, groupsOnly, includeAncestorGroups, includeDescendantGroups) { + this.container = container; + this.baseEndpoint = baseEndpoint; + this.groupsOnly = groupsOnly; + this.includeAncestorGroups = includeAncestorGroups; + this.includeDescendantGroups = includeDescendantGroups; + } + + getAllowedMappings(supportedTokens) { + return this.buildMappings(supportedTokens, this.getMappings()); + } + + buildMappings(supportedTokens, availableMappings) { + const allowedMappings = { + hint: { + reference: null, + gl: DropdownHint, + element: this.container.querySelector('#js-dropdown-hint'), + }, + }; + + supportedTokens.forEach(type => { + if (availableMappings[type]) { + allowedMappings[type] = availableMappings[type]; + } + }); + + return allowedMappings; + } + + getMappings() { + return { + author: { + reference: null, + gl: DropdownUser, + element: this.container.querySelector('#js-dropdown-author'), + }, + assignee: { + reference: null, + gl: DropdownUser, + element: this.container.querySelector('#js-dropdown-assignee'), + }, + milestone: { + reference: null, + gl: DropdownNonUser, + extraArguments: { + endpoint: this.getMilestoneEndpoint(), + symbol: '%', + }, + element: this.container.querySelector('#js-dropdown-milestone'), + }, + label: { + reference: null, + gl: DropdownNonUser, + extraArguments: { + endpoint: this.getLabelsEndpoint(), + symbol: '~', + preprocessing: DropdownUtils.duplicateLabelPreprocessing, + }, + element: this.container.querySelector('#js-dropdown-label'), + }, + 'my-reaction': { + reference: null, + gl: DropdownEmoji, + element: this.container.querySelector('#js-dropdown-my-reaction'), + }, + wip: { + reference: null, + gl: DropdownNonUser, + element: this.container.querySelector('#js-dropdown-wip'), + }, + confidential: { + reference: null, + gl: DropdownNonUser, + element: this.container.querySelector('#js-dropdown-confidential'), + }, + status: { + reference: null, + gl: NullDropdown, + element: this.container.querySelector('#js-dropdown-admin-runner-status'), + }, + type: { + reference: null, + gl: NullDropdown, + element: this.container.querySelector('#js-dropdown-admin-runner-type'), + }, + tag: { + reference: null, + gl: DropdownAjaxFilter, + extraArguments: { + endpoint: this.getRunnerTagsEndpoint(), + symbol: '~', + }, + element: this.container.querySelector('#js-dropdown-runner-tag'), + }, + }; + } + + getMilestoneEndpoint() { + return `${this.baseEndpoint}/milestones.json`; + } + + getLabelsEndpoint() { + let endpoint = `${this.baseEndpoint}/labels.json?`; + + if (this.groupsOnly) { + endpoint = `${endpoint}only_group_labels=true&`; + } + + if (this.includeAncestorGroups) { + endpoint = `${endpoint}include_ancestor_groups=true&`; + } + + if (this.includeDescendantGroups) { + endpoint = `${endpoint}include_descendant_groups=true`; + } + + return endpoint; + } + + getRunnerTagsEndpoint() { + return `${this.baseEndpoint}/admin/runners/tag_list.json`; + } +} diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue index 6b1a934d3fe..19bc3313373 100644 --- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue @@ -66,7 +66,7 @@ export default { <button type="button" class="filtered-search-history-dropdown-item" - @click="onItemActivated(item.text);" + @click="onItemActivated(item.text)" > <span> <span @@ -88,7 +88,7 @@ export default { <button type="button" class="filtered-search-history-clear-button" - @click="onRequestClearRecentSearches($event);" + @click="onRequestClearRecentSearches($event)" > Clear recent searches </button> diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js new file mode 100644 index 00000000000..b27bb63c220 --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js @@ -0,0 +1,68 @@ +import createFlash from '../flash'; +import AjaxFilter from '../droplab/plugins/ajax_filter'; +import FilteredSearchDropdown from './filtered_search_dropdown'; +import DropdownUtils from './dropdown_utils'; +import FilteredSearchTokenizer from './filtered_search_tokenizer'; +import { __ } from '~/locale'; + +export default class DropdownAjaxFilter extends FilteredSearchDropdown { + constructor(options = {}) { + const { tokenKeys, endpoint, symbol } = options; + + super(options); + + this.tokenKeys = tokenKeys; + this.endpoint = endpoint; + this.symbol = symbol; + + this.config = { + AjaxFilter: this.ajaxFilterConfig(), + }; + } + + ajaxFilterConfig() { + return { + endpoint: `${gon.relative_url_root || ''}${this.endpoint}`, + searchKey: 'search', + searchValueFunction: this.getSearchInput.bind(this), + loadingTemplate: this.loadingTemplate, + onError() { + createFlash(__('An error occurred fetching the dropdown data.')); + }, + }; + } + + itemClicked(e) { + super.itemClicked(e, selected => + selected.querySelector('.dropdown-light-content').innerText.trim(), + ); + } + + renderContent(forceShowList = false) { + this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config); + super.renderContent(forceShowList); + } + + getSearchInput() { + const query = DropdownUtils.getSearchInput(this.input); + const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get()); + + let value = lastToken || ''; + + if (value[0] === this.symbol) { + value = value.slice(1); + } + + // Removes the first character if it is a quotation so that we can search + // with multiple words + if (value[0] === '"' || value[0] === "'") { + value = value.slice(1); + } + + return value; + } + + init() { + this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init(); + } +} diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js index d5027590bb7..f1e7be6bde1 100644 --- a/app/assets/javascripts/filtered_search/dropdown_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_user.js @@ -1,54 +1,34 @@ -import Flash from '../flash'; -import AjaxFilter from '../droplab/plugins/ajax_filter'; -import FilteredSearchDropdown from './filtered_search_dropdown'; import { addClassIfElementExists } from '../lib/utils/dom_utils'; -import DropdownUtils from './dropdown_utils'; -import FilteredSearchTokenizer from './filtered_search_tokenizer'; +import DropdownAjaxFilter from './dropdown_ajax_filter'; -export default class DropdownUser extends FilteredSearchDropdown { +export default class DropdownUser extends DropdownAjaxFilter { constructor(options = {}) { - const { tokenKeys } = options; - super(options); - this.config = { - AjaxFilter: { - endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`, - searchKey: 'search', - params: { - active: true, - group_id: this.getGroupId(), - project_id: this.getProjectId(), - current_user: true, - }, - searchValueFunction: this.getSearchInput.bind(this), - loadingTemplate: this.loadingTemplate, - onLoadingFinished: () => { - this.hideCurrentUser(); - }, - onError() { - /* eslint-disable no-new */ - new Flash('An error occurred fetching the dropdown data.'); - /* eslint-enable no-new */ - }, + super({ + ...options, + endpoint: '/autocomplete/users.json', + symbol: '@', + }); + } + + ajaxFilterConfig() { + return { + ...super.ajaxFilterConfig(), + params: { + active: true, + group_id: this.getGroupId(), + project_id: this.getProjectId(), + current_user: true, + }, + onLoadingFinished: () => { + this.hideCurrentUser(); }, }; - this.tokenKeys = tokenKeys; } hideCurrentUser() { addClassIfElementExists(this.dropdown.querySelector('.js-current-user'), 'hidden'); } - itemClicked(e) { - super.itemClicked(e, selected => - selected.querySelector('.dropdown-light-content').innerText.trim(), - ); - } - - renderContent(forceShowList = false) { - this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config); - super.renderContent(forceShowList); - } - getGroupId() { return this.input.getAttribute('data-group-id'); } @@ -56,27 +36,4 @@ export default class DropdownUser extends FilteredSearchDropdown { getProjectId() { return this.input.getAttribute('data-project-id'); } - - getSearchInput() { - const query = DropdownUtils.getSearchInput(this.input); - const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get()); - - let value = lastToken || ''; - - if (value[0] === '@') { - value = value.slice(1); - } - - // Removes the first character if it is a quotation so that we can search - // with multiple words - if (value[0] === '"' || value[0] === "'") { - value = value.slice(1); - } - - return value; - } - - init() { - this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init(); - } } diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index 57ec6603d80..cb0a84b490b 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -1,13 +1,9 @@ +import AvailableDropdownMappings from 'ee_else_ce/filtered_search/available_dropdown_mappings'; import _ from 'underscore'; import DropLab from '~/droplab/drop_lab'; import FilteredSearchContainer from './container'; import FilteredSearchTokenKeys from './filtered_search_token_keys'; import DropdownUtils from './dropdown_utils'; -import DropdownHint from './dropdown_hint'; -import DropdownEmoji from './dropdown_emoji'; -import DropdownNonUser from './dropdown_non_user'; -import DropdownUser from './dropdown_user'; -import NullDropdown from './null_dropdown'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; export default class FilteredSearchDropdownManager { @@ -49,96 +45,15 @@ export default class FilteredSearchDropdownManager { setupMapping() { const supportedTokens = this.filteredSearchTokenKeys.getKeys(); - const allowedMappings = { - hint: { - reference: null, - gl: DropdownHint, - element: this.container.querySelector('#js-dropdown-hint'), - }, - }; - const availableMappings = { - author: { - reference: null, - gl: DropdownUser, - element: this.container.querySelector('#js-dropdown-author'), - }, - assignee: { - reference: null, - gl: DropdownUser, - element: this.container.querySelector('#js-dropdown-assignee'), - }, - milestone: { - reference: null, - gl: DropdownNonUser, - extraArguments: { - endpoint: this.getMilestoneEndpoint(), - symbol: '%', - }, - element: this.container.querySelector('#js-dropdown-milestone'), - }, - label: { - reference: null, - gl: DropdownNonUser, - extraArguments: { - endpoint: this.getLabelsEndpoint(), - symbol: '~', - preprocessing: DropdownUtils.duplicateLabelPreprocessing, - }, - element: this.container.querySelector('#js-dropdown-label'), - }, - 'my-reaction': { - reference: null, - gl: DropdownEmoji, - element: this.container.querySelector('#js-dropdown-my-reaction'), - }, - wip: { - reference: null, - gl: DropdownNonUser, - element: this.container.querySelector('#js-dropdown-wip'), - }, - status: { - reference: null, - gl: NullDropdown, - element: this.container.querySelector('#js-dropdown-admin-runner-status'), - }, - type: { - reference: null, - gl: NullDropdown, - element: this.container.querySelector('#js-dropdown-admin-runner-type'), - }, - }; - - supportedTokens.forEach(type => { - if (availableMappings[type]) { - allowedMappings[type] = availableMappings[type]; - } - }); - - this.mapping = allowedMappings; - } - - getMilestoneEndpoint() { - const endpoint = `${this.baseEndpoint}/milestones.json`; - - return endpoint; - } - - getLabelsEndpoint() { - let endpoint = `${this.baseEndpoint}/labels.json?`; - - if (this.groupsOnly) { - endpoint = `${endpoint}only_group_labels=true&`; - } - - if (this.includeAncestorGroups) { - endpoint = `${endpoint}include_ancestor_groups=true&`; - } - - if (this.includeDescendantGroups) { - endpoint = `${endpoint}include_descendant_groups=true`; - } + const availableMappings = new AvailableDropdownMappings( + this.container, + this.baseEndpoint, + this.groupsOnly, + this.includeAncestorGroups, + this.includeDescendantGroups, + ); - return endpoint; + this.mapping = availableMappings.getAllowedMappings(supportedTokens); } static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) { diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 4a2af02b40a..33c82778c79 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -593,7 +593,7 @@ export default class FilteredSearchManager { tokens.forEach(token => { const condition = this.filteredSearchTokenKeys.searchByConditionKeyValue( token.key, - token.value.toLowerCase(), + token.value, ); const tokenConfig = this.filteredSearchTokenKeys.searchByKey(token.key) || {}; const { param } = tokenConfig; diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js index e01dedbb57c..11ed85504ec 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -65,25 +65,27 @@ export default class FilteredSearchTokenKeys { searchByConditionKeyValue(key, value) { return ( - this.conditions.find(condition => condition.tokenKey === key && condition.value === value) || - null + this.conditions.find( + condition => + condition.tokenKey === key && condition.value.toLowerCase() === value.toLowerCase(), + ) || null ); } - addExtraTokensForMergeRequests() { - const wipToken = { - key: 'wip', + addExtraTokensForIssues() { + const confidentialToken = { + key: 'confidential', type: 'string', param: '', symbol: '', - icon: 'admin', + icon: 'eye-slash', tag: 'Yes or No', lowercaseValueOnSubmit: true, - uppercaseTokenName: true, + uppercaseTokenName: false, capitalizeTokenValue: true, }; - this.tokenKeys.push(wipToken); - this.tokenKeysWithAlternative.push(wipToken); + this.tokenKeys.push(confidentialToken); + this.tokenKeysWithAlternative.push(confidentialToken); } } diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index fba31f16d65..7746908714e 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -1,10 +1,6 @@ -import _ from 'underscore'; -import AjaxCache from '~/lib/utils/ajax_cache'; +import VisualTokenValue from 'ee_else_ce/filtered_search/visual_token_value'; import { objectToQueryString } from '~/lib/utils/common_utils'; -import Flash from '../flash'; import FilteredSearchContainer from './container'; -import UsersCache from '../lib/utils/users_cache'; -import DropdownUtils from './dropdown_utils'; export default class FilteredSearchVisualTokens { static getLastVisualTokenBeforeInput() { @@ -20,21 +16,6 @@ export default class FilteredSearchVisualTokens { }; } - /** - * Returns a computed API endpoint - * and query string composed of values from endpointQueryParams - * @param {String} endpoint - * @param {String} endpointQueryParams - */ - static getEndpointWithQueryParams(endpoint, endpointQueryParams) { - if (!endpointQueryParams) { - return endpoint; - } - - const queryString = objectToQueryString(JSON.parse(endpointQueryParams)); - return `${endpoint}?${queryString}`; - } - static unselectTokens() { const otherTokens = FilteredSearchContainer.container.querySelectorAll( '.js-visual-token .selectable.selected', @@ -76,114 +57,15 @@ export default class FilteredSearchVisualTokens { `; } - static setTokenStyle(tokenContainer, backgroundColor, textColor) { - const token = tokenContainer; - - token.style.backgroundColor = backgroundColor; - token.style.color = textColor; - - if (textColor === '#FFFFFF') { - const removeToken = token.querySelector('.remove-token'); - removeToken.classList.add('inverted'); - } - - return token; - } - - static updateLabelTokenColor(tokenValueContainer, tokenValue) { - const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search'); - const { baseEndpoint } = filteredSearchInput.dataset; - const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams( - `${baseEndpoint}/labels.json`, - filteredSearchInput.dataset.endpointQueryParams, - ); - - return AjaxCache.retrieve(labelsEndpoint) - .then(labels => { - const matchingLabel = (labels || []).find( - label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue, - ); - - if (!matchingLabel) { - return; - } - - FilteredSearchVisualTokens.setTokenStyle( - tokenValueContainer, - matchingLabel.color, - matchingLabel.text_color, - ); - }) - .catch(() => new Flash('An error occurred while fetching label colors.')); - } - - static updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) { - const username = tokenValue.replace(/^@/, ''); - return ( - UsersCache.retrieve(username) - .then(user => { - if (!user) { - return; - } - - /* eslint-disable no-param-reassign */ - tokenValueContainer.dataset.originalValue = tokenValue; - tokenValueElement.innerHTML = ` - <img class="avatar s20" src="${user.avatar_url}" alt=""> - ${_.escape(user.name)} - `; - /* eslint-enable no-param-reassign */ - }) - // ignore error and leave username in the search bar - .catch(() => {}) - ); - } - - static updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) { - const container = tokenValueContainer; - const element = tokenValueElement; - - return ( - import(/* webpackChunkName: 'emoji' */ '../emoji') - .then(Emoji => { - if (!Emoji.isEmojiNameValid(tokenValue)) { - return; - } - - container.dataset.originalValue = tokenValue; - element.innerHTML = Emoji.glEmojiTag(tokenValue); - }) - // ignore error and leave emoji name in the search bar - .catch(() => {}) - ); - } - static renderVisualTokenValue(parentElement, tokenName, tokenValue) { + const tokenType = tokenName.toLowerCase(); const tokenValueContainer = parentElement.querySelector('.value-container'); const tokenValueElement = tokenValueContainer.querySelector('.value'); tokenValueElement.innerText = tokenValue; - if (tokenValue === 'none' || tokenValue === 'any') { - return; - } + const visualTokenValue = new VisualTokenValue(tokenValue, tokenType); - const tokenType = tokenName.toLowerCase(); - - if (tokenType === 'label') { - FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue); - } else if (tokenType === 'author' || tokenType === 'assignee') { - FilteredSearchVisualTokens.updateUserTokenAppearance( - tokenValueContainer, - tokenValueElement, - tokenValue, - ); - } else if (tokenType === 'my-reaction') { - FilteredSearchVisualTokens.updateEmojiTokenAppearance( - tokenValueContainer, - tokenValueElement, - tokenValue, - ); - } + visualTokenValue.render(tokenValueContainer, tokenValueElement); } static addVisualTokenElement(name, value, options = {}) { @@ -318,6 +200,21 @@ export default class FilteredSearchVisualTokens { } } + /** + * Returns a computed API endpoint + * and query string composed of values from endpointQueryParams + * @param {String} endpoint + * @param {String} endpointQueryParams + */ + static getEndpointWithQueryParams(endpoint, endpointQueryParams) { + if (!endpointQueryParams) { + return endpoint; + } + + const queryString = objectToQueryString(JSON.parse(endpointQueryParams)); + return `${endpoint}?${queryString}`; + } + static editToken(token) { const input = FilteredSearchContainer.container.querySelector('.filtered-search'); diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js index b494b7e2de0..fd61030eb13 100644 --- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js @@ -60,52 +60,52 @@ export const conditions = [ { url: 'assignee_id=None', tokenKey: 'assignee', - value: 'none', + value: 'None', }, { url: 'assignee_id=Any', tokenKey: 'assignee', - value: 'any', + value: 'Any', }, { url: 'milestone_title=None', tokenKey: 'milestone', - value: 'none', + value: 'None', }, { url: 'milestone_title=Any', tokenKey: 'milestone', - value: 'any', + value: 'Any', }, { url: 'milestone_title=%23upcoming', tokenKey: 'milestone', - value: 'upcoming', + value: 'Upcoming', }, { url: 'milestone_title=%23started', tokenKey: 'milestone', - value: 'started', + value: 'Started', }, { url: 'label_name[]=None', tokenKey: 'label', - value: 'none', + value: 'None', }, { url: 'label_name[]=Any', - tokenKey: 'any', - value: 'any', + tokenKey: 'label', + value: 'Any', }, { url: 'my_reaction_emoji=None', tokenKey: 'my-reaction', - value: 'none', + value: 'None', }, { url: 'my_reaction_emoji=Any', tokenKey: 'my-reaction', - value: 'any', + value: 'Any', }, ]; diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js new file mode 100644 index 00000000000..7f6f41c18f7 --- /dev/null +++ b/app/assets/javascripts/filtered_search/visual_token_value.js @@ -0,0 +1,125 @@ +import _ from 'underscore'; +import FilteredSearchContainer from '~/filtered_search/container'; +import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens'; +import AjaxCache from '~/lib/utils/ajax_cache'; +import DropdownUtils from '~/filtered_search/dropdown_utils'; +import Flash from '~/flash'; +import UsersCache from '~/lib/utils/users_cache'; + +export default class VisualTokenValue { + constructor(tokenValue, tokenType) { + this.tokenValue = tokenValue; + this.tokenType = tokenType; + } + + render(tokenValueContainer, tokenValueElement) { + const { tokenType } = this; + + if (['none', 'any'].includes(tokenType)) { + return; + } + + if (tokenType === 'label') { + this.updateLabelTokenColor(tokenValueContainer); + } else if (tokenType === 'author' || tokenType === 'assignee') { + this.updateUserTokenAppearance(tokenValueContainer, tokenValueElement); + } else if (tokenType === 'my-reaction') { + this.updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement); + } + } + + updateUserTokenAppearance(tokenValueContainer, tokenValueElement) { + const { tokenValue } = this; + const username = this.tokenValue.replace(/^@/, ''); + + return ( + UsersCache.retrieve(username) + .then(user => { + if (!user) { + return; + } + + /* eslint-disable no-param-reassign */ + tokenValueContainer.dataset.originalValue = tokenValue; + tokenValueElement.innerHTML = ` + <img class="avatar s20" src="${user.avatar_url}" alt=""> + ${_.escape(user.name)} + `; + /* eslint-enable no-param-reassign */ + }) + // ignore error and leave username in the search bar + .catch(() => {}) + ); + } + + updateLabelTokenColor(tokenValueContainer) { + const { tokenValue } = this; + const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search'); + const { baseEndpoint } = filteredSearchInput.dataset; + const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams( + `${baseEndpoint}/labels.json`, + filteredSearchInput.dataset.endpointQueryParams, + ); + + return AjaxCache.retrieve(labelsEndpoint) + .then(labels => { + const matchingLabel = (labels || []).find( + label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue, + ); + + if (!matchingLabel) { + return; + } + + VisualTokenValue.setTokenStyle( + tokenValueContainer, + matchingLabel.color, + matchingLabel.text_color, + ); + }) + .catch(() => new Flash('An error occurred while fetching label colors.')); + } + + static setTokenStyle(tokenValueContainer, backgroundColor, textColor) { + const token = tokenValueContainer; + + token.style.backgroundColor = backgroundColor; + token.style.color = textColor; + + if (textColor === '#FFFFFF') { + const removeToken = token.querySelector('.remove-token'); + removeToken.classList.add('inverted'); + } + + return token; + } + + updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement) { + const container = tokenValueContainer; + const element = tokenValueElement; + const value = this.tokenValue; + + return ( + import(/* webpackChunkName: 'emoji' */ '../emoji') + .then(Emoji => { + Emoji.initEmojiMap() + .then(() => { + if (!Emoji.isEmojiNameValid(value)) { + return; + } + + container.dataset.originalValue = value; + element.innerHTML = Emoji.glEmojiTag(value); + }) + // ignore error and leave emoji name in the search bar + .catch(err => { + throw err; + }); + }) + // ignore error and leave emoji name in the search bar + .catch(importError => { + throw importError; + }) + ); + } +} diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 3ac00c51df4..2b6af9060d1 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -24,6 +24,9 @@ export const slope = (a, b) => (b.y - a.y) / (b.x - a.x); let headerHeight = 50; export const getHeaderHeight = () => headerHeight; +const setHeaderHeight = () => { + headerHeight = sidebar.offsetTop; +}; export const isSidebarCollapsed = () => sidebar && sidebar.classList.contains('sidebar-collapsed-desktop'); @@ -186,7 +189,7 @@ export default () => { }); } - headerHeight = document.querySelector('.nav-sidebar').offsetTop; + requestIdleCallback(setHeaderHeight); items.forEach(el => { const subItems = el.querySelector('.sidebar-sub-level-items'); diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue index 63531f1f246..968e255e1fc 100644 --- a/app/assets/javascripts/frequent_items/components/app.vue +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -47,6 +47,12 @@ export default { } eventHub.$on(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler); + + // As we init it through requestIdleCallback it could be that the dropdown is already open + const namespaceDropdown = document.getElementById(`nav-${this.namespace}-dropdown`); + if (namespaceDropdown && namespaceDropdown.classList.contains('show')) { + this.dropdownOpenHandler(); + } }, beforeDestroy() { eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler); 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 2cbc7c7077b..42d14b65b3a 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 @@ -82,8 +82,14 @@ export default { <li class="frequent-items-list-item-container"> <a :href="webUrl" class="clearfix"> <div class="frequent-items-item-avatar-container"> - <img v-if="hasAvatar" :src="avatarUrl" class="avatar s32" /> - <identicon v-else :entity-id="itemId" :entity-name="itemName" size-class="s32" /> + <img v-if="hasAvatar" :src="avatarUrl" class="avatar rect-avatar s32" /> + <identicon + v-else + :entity-id="itemId" + :entity-name="itemName" + size-class="s32" + class="rect-avatar" + /> </div> <div class="frequent-items-item-metadata-container"> <div :title="itemName" class="frequent-items-item-title" v-html="highlightedItemName"></div> diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js index 5157ff211dc..6263acbab8e 100644 --- a/app/assets/javascripts/frequent_items/index.js +++ b/app/assets/javascripts/frequent_items/index.js @@ -17,7 +17,7 @@ const frequentItemDropdowns = [ }, ]; -document.addEventListener('DOMContentLoaded', () => { +const initFrequentItemDropdowns = () => { frequentItemDropdowns.forEach(dropdown => { const { namespace, key } = dropdown; const el = document.getElementById(`js-${namespace}-dropdown`); @@ -66,4 +66,8 @@ document.addEventListener('DOMContentLoaded', () => { }, }); }); +}; + +document.addEventListener('DOMContentLoaded', () => { + requestIdleCallback(initFrequentItemDropdowns); }); diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index c14eb936930..50ea13edf63 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -195,11 +195,15 @@ class GfmAutoComplete { title += ` (${m.count})`; } + const GROUP_TYPE = 'Group'; + const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase(); + + const rectAvatarClass = m.type === GROUP_TYPE ? 'rect-avatar' : ''; const imgAvatar = `<img src="${m.avatar_url}" alt="${ m.username - }" class="avatar avatar-inline center s26"/>`; - const txtAvatar = `<div class="avatar center avatar-inline s26">${autoCompleteAvatar}</div>`; + }" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`; + const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`; return { username: m.username, @@ -221,13 +225,13 @@ class GfmAutoComplete { displayTpl(value) { let tmpl = GfmAutoComplete.Loading.template; if (value.title != null) { - tmpl = GfmAutoComplete.Issues.templateFunction(value.id, value.title); + tmpl = GfmAutoComplete.Issues.templateFunction(value); } return tmpl; }, data: GfmAutoComplete.defaultLoadingData, - // eslint-disable-next-line no-template-curly-in-string - insertTpl: '${atwho-at}${id}', + insertTpl: GfmAutoComplete.Issues.insertTemplateFunction, + skipSpecialCharacterTest: true, callbacks: { ...this.getDefaultCallbacks(), beforeSave(issues) { @@ -238,6 +242,7 @@ class GfmAutoComplete { return { id: i.iid, title: sanitize(i.title), + reference: i.reference, search: `${i.iid} ${i.title}`, }; }); @@ -256,7 +261,7 @@ class GfmAutoComplete { displayTpl(value) { let tmpl = GfmAutoComplete.Loading.template; if (value.title != null) { - tmpl = GfmAutoComplete.Milestones.template; + tmpl = GfmAutoComplete.Milestones.templateFunction(value.title); } return tmpl; }, @@ -287,13 +292,13 @@ class GfmAutoComplete { displayTpl(value) { let tmpl = GfmAutoComplete.Loading.template; if (value.title != null) { - tmpl = GfmAutoComplete.Issues.templateFunction(value.id, value.title); + tmpl = GfmAutoComplete.Issues.templateFunction(value); } return tmpl; }, data: GfmAutoComplete.defaultLoadingData, - // eslint-disable-next-line no-template-curly-in-string - insertTpl: '${atwho-at}${id}', + insertTpl: GfmAutoComplete.Issues.insertTemplateFunction, + skipSpecialCharacterTest: true, callbacks: { ...this.getDefaultCallbacks(), beforeSave(merges) { @@ -304,6 +309,7 @@ class GfmAutoComplete { return { id: m.iid, title: sanitize(m.title), + reference: m.reference, search: `${m.iid} ${m.title}`, }; }); @@ -323,7 +329,7 @@ class GfmAutoComplete { searchKey: 'search', data: GfmAutoComplete.defaultLoadingData, displayTpl(value) { - let tmpl = GfmAutoComplete.Labels.template; + let tmpl = GfmAutoComplete.Labels.templateFunction(value.color, value.title); if (GfmAutoComplete.isLoading(value)) { tmpl = GfmAutoComplete.Loading.template; } @@ -397,7 +403,7 @@ class GfmAutoComplete { displayTpl(value) { let tmpl = GfmAutoComplete.Loading.template; if (value.title != null) { - tmpl = GfmAutoComplete.Issues.templateFunction(value.id, value.title); + tmpl = GfmAutoComplete.Issues.templateFunction(value); } return tmpl; }, @@ -481,9 +487,15 @@ class GfmAutoComplete { this.loadData($input, at, this.cachedData[at]); } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { import(/* webpackChunkName: 'emoji' */ './emoji') - .then(({ validEmojiNames, glEmojiTag }) => { - this.loadData($input, at, validEmojiNames); - GfmAutoComplete.glEmojiTag = glEmojiTag; + .then(({ initEmojiMap, getValidEmojiNames, glEmojiTag }) => { + initEmojiMap() + .then(() => { + this.loadData($input, at, getValidEmojiNames()); + GfmAutoComplete.glEmojiTag = glEmojiTag; + }) + .catch(() => { + this.isLoadingData[at] = false; + }); }) .catch(() => { this.isLoadingData[at] = false; @@ -588,20 +600,27 @@ GfmAutoComplete.Members = { }, }; GfmAutoComplete.Labels = { - template: - // eslint-disable-next-line no-template-curly-in-string - '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>', + templateFunction(color, title) { + return `<li><span class="dropdown-label-box" style="background: ${_.escape( + color, + )}"></span> ${_.escape(title)}</li>`; + }, }; // Issues, MergeRequests and Snippets GfmAutoComplete.Issues = { - templateFunction(id, title) { - return `<li><small>${id}</small> ${_.escape(title)}</li>`; + insertTemplateFunction(value) { + // eslint-disable-next-line no-template-curly-in-string + return value.reference || '${atwho-at}${id}'; + }, + templateFunction({ id, title, reference }) { + return `<li><small>${reference || id}</small> ${_.escape(title)}</li>`; }, }; // Milestones GfmAutoComplete.Milestones = { - // eslint-disable-next-line no-template-curly-in-string - template: '<li>${title}</li>', + templateFunction(title) { + return `<li>${_.escape(title)}</li>`; + }, }; GfmAutoComplete.Loading = { template: diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index a8ac2f510a4..27d8669b256 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -656,23 +656,7 @@ GitLabDropdown = (function() { if (this.options.renderMenu) { return this.options.renderMenu(html); } else { - var ul = document.createElement('ul'); - - for (var i = 0; i < html.length; i += 1) { - var el = html[i]; - - if (el instanceof $) { - el = el.get(0); - } - - if (typeof el === 'string') { - ul.innerHTML += el; - } else { - ul.appendChild(el); - } - } - - return ul; + return $('<ul>').append(html); } }; diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index f842d2d74db..f5e2e46237f 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -51,8 +51,6 @@ export default class GLForm { // form and textarea event listeners this.addEventListeners(); addMarkdownListeners(this.form); - // hide discard button - this.form.find('.js-note-discard').hide(); this.form.show(); if (this.isAutosizeable) this.setupAutosize(); } diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 688bd37cc56..d5130cd331d 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -88,7 +88,7 @@ export default { </div> <div :class="{ 'content-loading': group.isChildrenLoading }" - class="avatar-container s24 d-none d-sm-flex" + class="avatar-container rect-avatar s24 d-none d-sm-flex" > <a :href="group.relativePath" class="no-expand"> <img v-if="hasAvatar" :src="group.avatarUrl" class="avatar s24" /> diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index 2049760fe29..bdadbb1bb2a 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -4,93 +4,97 @@ import Api from './api'; import { normalizeHeaders } from './lib/utils/common_utils'; export default function groupsSelect() { - // Needs to be accessible in rspec - window.GROUP_SELECT_PER_PAGE = 20; - $('.ajax-groups-select').each(function setAjaxGroupsSelect2() { - const $select = $(this); - const allAvailable = $select.data('allAvailable'); - const skipGroups = $select.data('skipGroups') || []; - const parentGroupID = $select.data('parentId'); - const groupsPath = parentGroupID - ? Api.subgroupsPath.replace(':id', parentGroupID) - : Api.groupsPath; + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + // Needs to be accessible in rspec + window.GROUP_SELECT_PER_PAGE = 20; + $('.ajax-groups-select').each(function setAjaxGroupsSelect2() { + const $select = $(this); + const allAvailable = $select.data('allAvailable'); + const skipGroups = $select.data('skipGroups') || []; + const parentGroupID = $select.data('parentId'); + const groupsPath = parentGroupID + ? Api.subgroupsPath.replace(':id', parentGroupID) + : Api.groupsPath; - $select.select2({ - placeholder: 'Search for a group', - allowClear: $select.hasClass('allowClear'), - multiple: $select.hasClass('multiselect'), - minimumInputLength: 0, - ajax: { - url: Api.buildUrl(groupsPath), - dataType: 'json', - quietMillis: 250, - transport(params) { - axios[params.type.toLowerCase()](params.url, { - params: params.data, - }) - .then(res => { - const results = res.data || []; - const headers = normalizeHeaders(res.headers); - const currentPage = parseInt(headers['X-PAGE'], 10) || 0; - const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; - const more = currentPage < totalPages; + $select.select2({ + placeholder: 'Search for a group', + allowClear: $select.hasClass('allowClear'), + multiple: $select.hasClass('multiselect'), + minimumInputLength: 0, + ajax: { + url: Api.buildUrl(groupsPath), + dataType: 'json', + quietMillis: 250, + transport(params) { + axios[params.type.toLowerCase()](params.url, { + params: params.data, + }) + .then(res => { + const results = res.data || []; + const headers = normalizeHeaders(res.headers); + const currentPage = parseInt(headers['X-PAGE'], 10) || 0; + const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; + const more = currentPage < totalPages; - params.success({ - results, - pagination: { - more, - }, - }); - }) - .catch(params.error); - }, - data(search, page) { - return { - search, - page, - per_page: window.GROUP_SELECT_PER_PAGE, - all_available: allAvailable, - }; - }, - results(data, page) { - if (data.length) return { results: [] }; + params.success({ + results, + pagination: { + more, + }, + }); + }) + .catch(params.error); + }, + data(search, page) { + return { + search, + page, + per_page: window.GROUP_SELECT_PER_PAGE, + all_available: allAvailable, + }; + }, + results(data, page) { + if (data.length) return { results: [] }; - const groups = data.length ? data : data.results || []; - const more = data.pagination ? data.pagination.more : false; - const results = groups.filter(group => skipGroups.indexOf(group.id) === -1); + const groups = data.length ? data : data.results || []; + const more = data.pagination ? data.pagination.more : false; + const results = groups.filter(group => skipGroups.indexOf(group.id) === -1); - return { - results, - page, - more, - }; - }, - }, - // eslint-disable-next-line consistent-return - initSelection(element, callback) { - const id = $(element).val(); - if (id !== '') { - return Api.group(id, callback); - } - }, - formatResult(object) { - return `<div class='group-result'> <div class='group-name'>${ - object.full_name - }</div> <div class='group-path'>${object.full_path}</div> </div>`; - }, - formatSelection(object) { - return object.full_name; - }, - dropdownCssClass: 'ajax-groups-dropdown select2-infinite', - // we do not want to escape markup since we are displaying html in results - escapeMarkup(m) { - return m; - }, - }); + return { + results, + page, + more, + }; + }, + }, + // eslint-disable-next-line consistent-return + initSelection(element, callback) { + const id = $(element).val(); + if (id !== '') { + return Api.group(id, callback); + } + }, + formatResult(object) { + return `<div class='group-result'> <div class='group-name'>${ + object.full_name + }</div> <div class='group-path'>${object.full_path}</div> </div>`; + }, + formatSelection(object) { + return object.full_name; + }, + dropdownCssClass: 'ajax-groups-dropdown select2-infinite', + // we do not want to escape markup since we are displaying html in results + escapeMarkup(m) { + return m; + }, + }); - $select.on('select2-loaded', () => { - const dropdown = document.querySelector('.select2-infinite .select2-results'); - dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`; - }); - }); + $select.on('select2-loaded', () => { + const dropdown = document.querySelector('.select2-infinite .select2-results'); + dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`; + }); + }); + }) + .catch(() => {}); } diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index 2fa7a219ea8..3d846310008 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -23,7 +23,7 @@ export default function initTodoToggle() { }); } -document.addEventListener('DOMContentLoaded', () => { +function initStatusTriggers() { const setStatusModalTriggerEl = document.querySelector('.js-set-status-modal-trigger'); const setStatusModalWrapperEl = document.querySelector('.js-set-status-modal-wrapper'); @@ -72,4 +72,8 @@ document.addEventListener('DOMContentLoaded', () => { }, }); } +} + +document.addEventListener('DOMContentLoaded', () => { + requestIdleCallback(initStatusTriggers); }); diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index a1f66ff764d..7c769ab7fa0 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -45,7 +45,7 @@ export default { data-placement="right" type="button" class="ide-sidebar-link js-ide-edit-mode" - @click.prevent="changedActivityView($event, $options.activityBarViews.edit);" + @click.prevent="changedActivityView($event, $options.activityBarViews.edit)" > <icon name="code" /> </button> @@ -62,7 +62,7 @@ export default { data-placement="right" type="button" class="ide-sidebar-link js-ide-review-mode" - @click.prevent="changedActivityView($event, $options.activityBarViews.review);" + @click.prevent="changedActivityView($event, $options.activityBarViews.review)" > <icon name="file-modified" /> </button> @@ -79,7 +79,7 @@ export default { data-placement="right" type="button" class="ide-sidebar-link js-ide-commit-mode" - @click.prevent="changedActivityView($event, $options.activityBarViews.commit);" + @click.prevent="changedActivityView($event, $options.activityBarViews.commit)" > <icon name="commit" /> </button> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue index 5119dbf32eb..11d5d9639b6 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue @@ -44,7 +44,7 @@ export default { <div class="d-flex ide-commit-editor-header align-items-center"> <file-icon :file-name="activeFile.name" :size="16" class="mr-2" /> <strong class="mr-2"> {{ activeFile.path }} </strong> - <changed-file-icon :file="activeFile" class="ml-0" /> + <changed-file-icon :file="activeFile" :is-centered="false" /> <div class="ml-auto"> <button v-if="!isStaged" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index a1094570275..4f1260de0bc 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -87,7 +87,7 @@ export default { }, }, discardModalText: __( - "You will loose all the unstaged changes you've made in this project. This action cannot be undone.", + "You will lose all the unstaged changes you've made in this project. This action cannot be undone.", ), }; </script> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue index 6f1ded91753..00b2d236da3 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue @@ -111,8 +111,8 @@ export default { name="commit-message" @scroll="handleScroll" @input="onInput" - @focus="updateIsFocused(true);" - @blur="updateIsFocused(false);" + @focus="updateIsFocused(true)" + @blur="updateIsFocused(false)" @keydown.ctrl.enter="onCtrlEnter" @keydown.meta.enter="onCtrlEnter" > diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue index 3525084b1cb..2b44438f849 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -65,7 +65,7 @@ export default { :disabled="disabled" type="radio" name="commit-action" - @change="updateCommitAction($event.target.value);" + @change="updateCommitAction($event.target.value)" /> <span class="prepend-left-10"> <span v-if="label" class="ide-radio-label"> {{ label }} </span> <slot v-else></slot> @@ -76,7 +76,7 @@ export default { :placeholder="newBranchName" type="text" class="form-control monospace" - @input="updateBranchName($event.target.value);" + @input="updateBranchName($event.target.value)" /> </div> </fieldset> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue index 02c2004d495..09c9d135614 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue @@ -48,7 +48,7 @@ export default { data-container="body" data-boundary="viewport" data-placement="bottom" - @click.stop.prevent="stageChange(path);" + @click.stop.prevent="stageChange(path)" > <icon :size="16" name="mobile-issue-close" class="ml-auto mr-auto" /> </button> @@ -70,9 +70,9 @@ export default { :header-title-text="modalTitle" :footer-primary-button-text="__('Discard changes')" footer-primary-button-variant="danger" - @submit="discardFileChanges(path);" + @submit="discardFileChanges(path)" > - {{ __("You will loose all changes you've made to this file. This action cannot be undone.") }} + {{ __("You will lose all changes you've made to this file. This action cannot be undone.") }} </gl-modal> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue index ce41fcdb087..0567ef54ff3 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue @@ -33,7 +33,7 @@ export default { data-container="body" data-boundary="viewport" data-placement="bottom" - @click.stop.prevent="unstageChange(path);" + @click.stop.prevent="unstageChange(path)" > <icon :size="16" name="redo" class="ml-auto mr-auto" /> </button> diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue index 5f99261ec39..732fa0786b0 100644 --- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue +++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue @@ -40,7 +40,7 @@ export default { 'is-active': viewer === $options.viewerTypes.mr, }" href="#" - @click.prevent="changeMode($options.viewerTypes.mr);" + @click.prevent="changeMode($options.viewerTypes.mr)" > <strong class="dropdown-menu-inner-title"> {{ mergeReviewLine }} </strong> <span class="dropdown-menu-inner-content"> @@ -54,7 +54,7 @@ export default { 'is-active': viewer === $options.viewerTypes.diff, }" href="#" - @click.prevent="changeMode($options.viewerTypes.diff);" + @click.prevent="changeMode($options.viewerTypes.diff)" > <strong class="dropdown-menu-inner-title">{{ __('Reviewing') }}</strong> <span class="dropdown-menu-inner-content"> diff --git a/app/assets/javascripts/ide/components/file_finder/index.vue b/app/assets/javascripts/ide/components/file_finder/index.vue deleted file mode 100644 index bb391912572..00000000000 --- a/app/assets/javascripts/ide/components/file_finder/index.vue +++ /dev/null @@ -1,227 +0,0 @@ -<script> -import { mapActions, mapGetters, mapState } from 'vuex'; -import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import VirtualList from 'vue-virtual-scroll-list'; -import Item from './item.vue'; -import router from '../../ide_router'; -import { - MAX_FILE_FINDER_RESULTS, - FILE_FINDER_ROW_HEIGHT, - FILE_FINDER_EMPTY_ROW_HEIGHT, -} from '../../constants'; -import { - UP_KEY_CODE, - DOWN_KEY_CODE, - ENTER_KEY_CODE, - ESC_KEY_CODE, -} from '../../../lib/utils/keycodes'; - -export default { - components: { - Item, - VirtualList, - }, - data() { - return { - focusedIndex: 0, - searchText: '', - mouseOver: false, - cancelMouseOver: false, - }; - }, - computed: { - ...mapGetters(['allBlobs']), - ...mapState(['fileFindVisible', 'loading']), - filteredBlobs() { - const searchText = this.searchText.trim(); - - if (searchText === '') { - return this.allBlobs.slice(0, MAX_FILE_FINDER_RESULTS); - } - - return fuzzaldrinPlus.filter(this.allBlobs, searchText, { - key: 'path', - maxResults: MAX_FILE_FINDER_RESULTS, - }); - }, - filteredBlobsLength() { - return this.filteredBlobs.length; - }, - listShowCount() { - return this.filteredBlobsLength ? Math.min(this.filteredBlobsLength, 5) : 1; - }, - listHeight() { - return this.filteredBlobsLength ? FILE_FINDER_ROW_HEIGHT : FILE_FINDER_EMPTY_ROW_HEIGHT; - }, - showClearInputButton() { - return this.searchText.trim() !== ''; - }, - }, - watch: { - fileFindVisible() { - this.$nextTick(() => { - if (!this.fileFindVisible) { - this.searchText = ''; - } else { - this.focusedIndex = 0; - - if (this.$refs.searchInput) { - this.$refs.searchInput.focus(); - } - } - }); - }, - searchText() { - this.focusedIndex = 0; - }, - focusedIndex() { - if (!this.mouseOver) { - this.$nextTick(() => { - const el = this.$refs.virtualScrollList.$el; - const scrollTop = this.focusedIndex * FILE_FINDER_ROW_HEIGHT; - const bottom = this.listShowCount * FILE_FINDER_ROW_HEIGHT; - - if (this.focusedIndex === 0) { - // if index is the first index, scroll straight to start - el.scrollTop = 0; - } else if (this.focusedIndex === this.filteredBlobsLength - 1) { - // if index is the last index, scroll to the end - el.scrollTop = this.filteredBlobsLength * FILE_FINDER_ROW_HEIGHT; - } else if (scrollTop >= bottom + el.scrollTop) { - // if element is off the bottom of the scroll list, scroll down one item - el.scrollTop = scrollTop - bottom + FILE_FINDER_ROW_HEIGHT; - } else if (scrollTop < el.scrollTop) { - // if element is off the top of the scroll list, scroll up one item - el.scrollTop = scrollTop; - } - }); - } - }, - }, - methods: { - ...mapActions(['toggleFileFinder']), - clearSearchInput() { - this.searchText = ''; - - this.$nextTick(() => { - this.$refs.searchInput.focus(); - }); - }, - onKeydown(e) { - switch (e.keyCode) { - case UP_KEY_CODE: - e.preventDefault(); - this.mouseOver = false; - this.cancelMouseOver = true; - if (this.focusedIndex > 0) { - this.focusedIndex -= 1; - } else { - this.focusedIndex = this.filteredBlobsLength - 1; - } - break; - case DOWN_KEY_CODE: - e.preventDefault(); - this.mouseOver = false; - this.cancelMouseOver = true; - if (this.focusedIndex < this.filteredBlobsLength - 1) { - this.focusedIndex += 1; - } else { - this.focusedIndex = 0; - } - break; - default: - break; - } - }, - onKeyup(e) { - switch (e.keyCode) { - case ENTER_KEY_CODE: - this.openFile(this.filteredBlobs[this.focusedIndex]); - break; - case ESC_KEY_CODE: - this.toggleFileFinder(false); - break; - default: - break; - } - }, - openFile(file) { - this.toggleFileFinder(false); - router.push(`/project${file.url}`); - }, - onMouseOver(index) { - if (!this.cancelMouseOver) { - this.mouseOver = true; - this.focusedIndex = index; - } - }, - onMouseMove(index) { - this.cancelMouseOver = false; - this.onMouseOver(index); - }, - }, -}; -</script> - -<template> - <div class="ide-file-finder-overlay" @mousedown.self="toggleFileFinder(false);"> - <div class="dropdown-menu diff-file-changes ide-file-finder show"> - <div class="dropdown-input"> - <input - ref="searchInput" - v-model="searchText" - :placeholder="__('Search files')" - type="search" - class="dropdown-input-field" - autocomplete="off" - @keydown="onKeydown($event);" - @keyup="onKeyup($event);" - /> - <i - :class="{ - hidden: showClearInputButton, - }" - aria-hidden="true" - class="fa fa-search dropdown-input-search" - ></i> - <i - :aria-label="__('Clear search input')" - :class="{ - show: showClearInputButton, - }" - role="button" - class="fa fa-times dropdown-input-clear" - @click="clearSearchInput" - ></i> - </div> - <div> - <virtual-list ref="virtualScrollList" :size="listHeight" :remain="listShowCount" wtag="ul"> - <template v-if="filteredBlobsLength"> - <li v-for="(file, index) in filteredBlobs" :key="file.key"> - <item - :file="file" - :search-text="searchText" - :focused="index === focusedIndex" - :index="index" - class="disable-hover" - @click="openFile" - @mouseover="onMouseOver" - @mousemove="onMouseMove" - /> - </li> - </template> - <li v-else class="dropdown-menu-empty-item"> - <div class="append-right-default prepend-left-default prepend-top-8 append-bottom-8"> - <template v-if="loading"> - {{ __('Loading...') }} - </template> - <template v-else> - {{ __('No files found.') }} - </template> - </div> - </li> - </virtual-list> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/ide/components/file_finder/item.vue b/app/assets/javascripts/ide/components/file_finder/item.vue deleted file mode 100644 index 83e80d50aff..00000000000 --- a/app/assets/javascripts/ide/components/file_finder/item.vue +++ /dev/null @@ -1,104 +0,0 @@ -<script> -import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import FileIcon from '../../../vue_shared/components/file_icon.vue'; -import ChangedFileIcon from '../../../vue_shared/components/changed_file_icon.vue'; - -const MAX_PATH_LENGTH = 60; - -export default { - components: { - ChangedFileIcon, - FileIcon, - }, - props: { - file: { - type: Object, - required: true, - }, - focused: { - type: Boolean, - required: true, - }, - searchText: { - type: String, - required: true, - }, - index: { - type: Number, - required: true, - }, - }, - computed: { - pathWithEllipsis() { - const { path } = this.file; - - return path.length < MAX_PATH_LENGTH - ? path - : `...${path.substr(path.length - MAX_PATH_LENGTH)}`; - }, - nameSearchTextOccurences() { - return fuzzaldrinPlus.match(this.file.name, this.searchText); - }, - pathSearchTextOccurences() { - return fuzzaldrinPlus.match(this.pathWithEllipsis, this.searchText); - }, - }, - methods: { - clickRow() { - this.$emit('click', this.file); - }, - mouseOverRow() { - this.$emit('mouseover', this.index); - }, - mouseMove() { - this.$emit('mousemove', this.index); - }, - }, -}; -</script> - -<template> - <button - :class="{ - 'is-focused': focused, - }" - type="button" - class="diff-changed-file" - @click.prevent="clickRow" - @mouseover="mouseOverRow" - @mousemove="mouseMove" - > - <file-icon - :file-name="file.name" - :size="16" - css-classes="diff-file-changed-icon append-right-8" - /> - <span class="diff-changed-file-content append-right-8"> - <strong class="diff-changed-file-name"> - <span - v-for="(char, charIndex) in file.name.split('')" - :key="charIndex + char" - :class="{ - highlighted: nameSearchTextOccurences.indexOf(charIndex) >= 0, - }" - v-text="char" - > - </span> - </strong> - <span class="diff-changed-file-path prepend-top-5"> - <span - v-for="(char, charIndex) in pathWithEllipsis.split('')" - :key="charIndex + char" - :class="{ - highlighted: pathSearchTextOccurences.indexOf(charIndex) >= 0, - }" - v-text="char" - > - </span> - </span> - </span> - <span v-if="file.changed || file.tempFile" class="diff-changed-stats"> - <changed-file-icon :file="file" /> - </span> - </button> -</template> diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue index 414ea9c7d4d..343e0cca672 100644 --- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue +++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue @@ -91,7 +91,7 @@ export default { <gl-loading-icon v-if="showLoading" :size="2" /> <ul v-else> <li v-for="(item, index) in outputData" :key="index"> - <button type="button" @click="clickItem(item);">{{ item.name }}</button> + <button type="button" @click="clickItem(item)">{{ item.name }}</button> </li> </ul> </div> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index caec8779cac..9894ebb0624 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,20 +1,17 @@ <script> import Vue from 'vue'; -import Mousetrap from 'mousetrap'; import { mapActions, mapState, mapGetters } from 'vuex'; import { __ } from '~/locale'; +import FindFile from '~/vue_shared/components/file_finder/index.vue'; import NewModal from './new_dropdown/modal.vue'; import IdeSidebar from './ide_side_bar.vue'; import RepoTabs from './repo_tabs.vue'; import IdeStatusBar from './ide_status_bar.vue'; import RepoEditor from './repo_editor.vue'; -import FindFile from './file_finder/index.vue'; import RightPane from './panes/right.vue'; import ErrorMessage from './error_message.vue'; import CommitEditorHeader from './commit_sidebar/editor_header.vue'; -const originalStopCallback = Mousetrap.stopCallback; - export default { components: { NewModal, @@ -42,21 +39,18 @@ export default { 'emptyStateSvgPath', 'currentProjectId', 'errorMessage', + 'loading', + ]), + ...mapGetters([ + 'activeFile', + 'hasChanges', + 'someUncommittedChanges', + 'isCommitModeActive', + 'allBlobs', ]), - ...mapGetters(['activeFile', 'hasChanges', 'someUncommittedChanges', 'isCommitModeActive']), }, mounted() { window.onbeforeunload = e => this.onBeforeUnload(e); - - Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => { - if (e.preventDefault) { - e.preventDefault(); - } - - this.toggleFileFinder(!this.fileFindVisible); - }); - - Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo); }, methods: { ...mapActions(['toggleFileFinder']), @@ -70,17 +64,8 @@ export default { }); return returnValue; }, - mousetrapStopCallback(e, el, combo) { - if ( - (combo === 't' && el.classList.contains('dropdown-input-field')) || - el.classList.contains('inputarea') - ) { - return true; - } else if (combo === 'command+p' || combo === 'ctrl+p') { - return false; - } - - return originalStopCallback(e, el, combo); + openFile(file) { + this.$router.push(`/project${file.url}`); }, }, }; @@ -90,7 +75,14 @@ export default { <article class="ide position-relative d-flex flex-column align-items-stretch"> <error-message v-if="errorMessage" :message="errorMessage" /> <div class="ide-view flex-grow d-flex"> - <find-file v-show="fileFindVisible" /> + <find-file + v-show="fileFindVisible" + :files="allBlobs" + :visible="fileFindVisible" + :loading="loading" + @toggle="toggleFileFinder" + @click="openFile" + /> <ide-sidebar /> <div class="multi-file-edit-pane"> <template v-if="activeFile"> diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index e2e0acc22b1..ce577ae85b0 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -84,7 +84,7 @@ export default { <button type="button" class="p-0 border-0 h-50" - @click="openRightPane($options.rightSidebarViews.pipelines);" + @click="openRightPane($options.rightSidebarViews.pipelines)" > <ci-icon v-tooltip @@ -107,16 +107,23 @@ export default { class="commit-sha" >{{ lastCommit.short_id }}</a > - by {{ lastCommit.author_name }} + by + <user-avatar-image + css-classes="ide-status-avatar" + :size="18" + :img-src="latestPipeline && latestPipeline.commit.author_gravatar_url" + :img-alt="lastCommit.author_name" + :tooltip-text="lastCommit.author_name" + /> + {{ lastCommit.author_name }} <time v-tooltip :datetime="lastCommit.committed_date" :title="tooltipTitle(lastCommit.committed_date)" data-placement="top" data-container="body" + >{{ lastCommitFormatedAge }}</time > - {{ lastCommitFormatedAge }} - </time> </div> <div v-if="file" class="ide-status-file">{{ file.name }}</div> <div v-if="file" class="ide-status-file">{{ file.eol }}</div> diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue index 9fc21adae7c..f93496132a4 100644 --- a/app/assets/javascripts/ide/components/ide_tree.vue +++ b/app/assets/javascripts/ide/components/ide_tree.vue @@ -43,7 +43,7 @@ export default { :show-label="false" class="d-flex border-0 p-0 mr-3 qa-new-file" icon="doc-new" - @click="openNewEntryModal({ type: 'blob' });" + @click="openNewEntryModal({ type: 'blob' })" /> <upload :show-label="false" @@ -56,7 +56,7 @@ export default { :show-label="false" class="d-flex border-0 p-0" icon="folder-new" - @click="openNewEntryModal({ type: 'tree' });" + @click="openNewEntryModal({ type: 'tree' })" /> </div> </template> diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue index e8fe5fc696d..7710bfb49ec 100644 --- a/app/assets/javascripts/ide/components/jobs/detail.vue +++ b/app/assets/javascripts/ide/components/jobs/detail.vue @@ -75,7 +75,7 @@ export default { <template> <div class="ide-pipeline build-page d-flex flex-column flex-fill"> <header class="ide-job-header d-flex align-items-center"> - <button class="btn btn-default btn-sm d-flex" @click="setDetailJob(null);"> + <button class="btn btn-default btn-sm d-flex" @click="setDetailJob(null)"> <icon name="chevron-left" /> {{ __('View jobs') }} </button> </header> diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue index ac2b0eddfb4..2d55ffb3c65 100644 --- a/app/assets/javascripts/ide/components/merge_requests/list.vue +++ b/app/assets/javascripts/ide/components/merge_requests/list.vue @@ -84,7 +84,7 @@ export default { :placeholder="__('Search merge requests')" @focus="onSearchFocus" @input="searchMergeRequests" - @removeToken="setSearchType(null);" + @removeToken="setSearchType(null)" /> <icon :size="18" name="search" class="input-icon" /> </div> @@ -102,7 +102,7 @@ export default { <button type="button" class="btn-link d-flex align-items-center" - @click.stop="setSearchType(searchType);" + @click.stop="setSearchType(searchType)" > <span class="d-flex append-right-default ide-search-list-current-icon"> <icon :size="18" name="search" /> diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index a50d729036f..593a9162a06 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -1,7 +1,6 @@ <script> import { mapActions } from 'vuex'; import icon from '~/vue_shared/components/icon.vue'; -import newModal from './modal.vue'; import upload from './upload.vue'; import ItemButton from './button.vue'; import { modalTypes } from '../../constants'; @@ -9,7 +8,6 @@ import { modalTypes } from '../../constants'; export default { components: { icon, - newModal, upload, ItemButton, }, @@ -73,7 +71,7 @@ export default { :aria-label="__('Create new file or directory')" type="button" class="rounded border-0 d-flex ide-entry-dropdown-toggle" - @click.stop="openDropdown();" + @click.stop="openDropdown()" > <icon name="ellipsis_v" /> <icon name="arrow-down" /> </button> @@ -85,7 +83,7 @@ export default { class="d-flex" icon="doc-new" icon-classes="mr-2" - @click="createNewItem('blob');" + @click="createNewItem('blob')" /> </li> <li><upload :path="path" @create="createTempEntry" /></li> @@ -95,7 +93,7 @@ export default { class="d-flex" icon="folder-new" icon-classes="mr-2" - @click="createNewItem($options.modalTypes.tree);" + @click="createNewItem($options.modalTypes.tree)" /> </li> <li class="divider"></li> @@ -106,7 +104,7 @@ export default { class="d-flex" icon="pencil" icon-classes="mr-2" - @click="createNewItem($options.modalTypes.rename);" + @click="createNewItem($options.modalTypes.rename)" /> </li> <li> @@ -115,7 +113,7 @@ export default { class="d-flex" icon="remove" icon-classes="mr-2" - @click="deleteEntry(path);" + @click="deleteEntry(path)" /> </li> </ul> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index 63cbf41b89b..ba6bbdfef4b 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -1,6 +1,7 @@ <script> import $ from 'jquery'; -import { __ } from '~/locale'; +import flash from '~/flash'; +import { __, sprintf, s__ } from '~/locale'; import { mapActions, mapState, mapGetters } from 'vuex'; import GlModal from '~/vue_shared/components/gl_modal.vue'; import { modalTypes } from '../../constants'; @@ -15,15 +16,17 @@ export default { }; }, computed: { - ...mapState(['entryModal']), + ...mapState(['entries', 'entryModal']), ...mapGetters('fileTemplates', ['templateTypes']), entryName: { get() { + const entryPath = this.entryModal.entry.path; + if (this.entryModal.type === modalTypes.rename) { - return this.name || this.entryModal.entry.name; + return this.name || entryPath; } - return this.name || (this.entryModal.path !== '' ? `${this.entryModal.path}/` : ''); + return this.name || (entryPath ? `${entryPath}/` : ''); }, set(val) { this.name = val; @@ -51,18 +54,51 @@ export default { return __('Create file'); }, - isCreatingNew() { - return this.entryModal.type !== modalTypes.rename; + isCreatingNewFile() { + return this.entryModal.type === 'blob'; + }, + placeholder() { + return this.isCreatingNewFile ? 'dir/file_name' : 'dir/'; }, }, methods: { ...mapActions(['createTempEntry', 'renameEntry']), submitForm() { if (this.entryModal.type === modalTypes.rename) { - this.renameEntry({ - path: this.entryModal.entry.path, - name: this.entryName, - }); + if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) { + flash( + sprintf(s__('The name %{entryName} is already taken in this directory.'), { + entryName: this.entryName, + }), + 'alert', + document, + null, + false, + true, + ); + } else { + let parentPath = this.entryName.split('/'); + const entryName = parentPath.pop(); + parentPath = parentPath.join('/'); + + const createPromise = + parentPath && !this.entries[parentPath] + ? this.createTempEntry({ name: parentPath, type: 'tree' }) + : Promise.resolve(); + + createPromise + .then(() => + this.renameEntry({ + path: this.entryModal.entry.path, + name: entryName, + entryPath: null, + parentPath, + }), + ) + .catch(() => + flash(__('Error creating a new path'), 'alert', document, null, false, true), + ); + } } else { this.createTempEntry({ name: this.name, @@ -79,7 +115,14 @@ export default { $('#ide-new-entry').modal('toggle'); }, focusInput() { + const name = this.entries[this.entryName] ? this.entries[this.entryName].name : null; + const inputValue = this.$refs.fieldName.value; + this.$refs.fieldName.focus(); + + if (name) { + this.$refs.fieldName.setSelectionRange(inputValue.indexOf(name), inputValue.length); + } }, closedModal() { this.name = ''; @@ -107,14 +150,17 @@ export default { v-model="entryName" type="text" class="form-control qa-full-file-path" - placeholder="/dir/file_name" + :placeholder="placeholder" /> - <ul v-if="isCreatingNew" class="prepend-top-default list-inline qa-template-list"> + <ul + v-if="isCreatingNewFile" + class="file-templates prepend-top-default list-inline qa-template-list" + > <li v-for="(template, index) in templateTypes" :key="index" class="list-inline-item"> <button type="button" class="btn btn-missing p-1 pr-2 pl-2" - @click="createFromTemplate(template);" + @click="createFromTemplate(template)" > {{ template.name }} </button> diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index 7a57ccf2dd3..2e6bd85feec 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -122,7 +122,7 @@ export default { data-placement="left" class="ide-sidebar-link is-right" type="button" - @click="clickTab($event, tab);" + @click="clickTab($event, tab)" > <icon :size="16" :name="tab.icon" /> </button> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index c13d3ec094b..94a9e87369c 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -219,7 +219,7 @@ export default { <a href="javascript:void(0);" role="button" - @click.prevent="setFileViewMode({ file, viewMode: 'editor' });" + @click.prevent="setFileViewMode({ file, viewMode: 'editor' })" > <template v-if="viewer === $options.viewerTypes.edit"> {{ __('Edit') }} @@ -233,7 +233,7 @@ export default { <a href="javascript:void(0);" role="button" - @click.prevent="setFileViewMode({ file, viewMode: 'preview' });" + @click.prevent="setFileViewMode({ file, viewMode: 'preview' })" > {{ file.previewMode.previewTitle }} </a> diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index 4b87b83db8a..f6aa2295844 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -74,7 +74,7 @@ export default { active: tab.active, disabled: tab.pending, }" - @click="clickFile(tab);" + @click="clickFile(tab)" @mouseover="mouseOverTab" @mouseout="mouseOutTab" > @@ -88,7 +88,7 @@ export default { :disabled="tab.pending" type="button" class="multi-file-tab-close" - @click.stop.prevent="closeFile(tab);" + @click.stop.prevent="closeFile(tab)" > <icon v-if="!showChangedIcon" :size="12" name="close" /> <changed-file-icon v-else :file="tab" /> diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue index a89de56ab5c..7277fcb7617 100644 --- a/app/assets/javascripts/ide/components/resizable_panel.vue +++ b/app/assets/javascripts/ide/components/resizable_panel.vue @@ -78,8 +78,8 @@ export default { :min-size="minSize" :max-size="$options.maxSize" :side="side === 'right' ? 'left' : 'right'" - @resize-start="setResizingStatus(true);" - @resize-end="setResizingStatus(false);" + @resize-start="setResizingStatus(true)" + @resize-end="setResizingStatus(false)" /> </div> </template> diff --git a/app/assets/javascripts/ide/components/shared/tokened_input.vue b/app/assets/javascripts/ide/components/shared/tokened_input.vue index f58e08c2cc9..de3e71dad92 100644 --- a/app/assets/javascripts/ide/components/shared/tokened_input.vue +++ b/app/assets/javascripts/ide/components/shared/tokened_input.vue @@ -76,8 +76,8 @@ export default { <button class="selectable btn-blank" type="button" - @click.stop="removeToken(token);" - @keyup.delete="removeToken(token);" + @click.stop="removeToken(token)" + @keyup.delete="removeToken(token)" > <div class="value-container rounded"> <div class="value">{{ token.label }}</div> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 09245ed0296..7c560c89695 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -1,8 +1,3 @@ -// Fuzzy file finder -export const MAX_FILE_FINDER_RESULTS = 40; -export const FILE_FINDER_ROW_HEIGHT = 55; -export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33; - export const MAX_WINDOW_HEIGHT_COMPACT = 750; // Commit message textarea @@ -29,6 +24,22 @@ export const diffModes = { mode_changed: 'mode_changed', }; +export const diffViewerModes = Object.freeze({ + not_diffable: 'not_diffable', + no_preview: 'no_preview', + added: 'added', + deleted: 'deleted', + renamed: 'renamed', + mode_changed: 'mode_changed', + text: 'text', + image: 'image', +}); + +export const diffViewerErrors = Object.freeze({ + too_large: 'too_large', + stored_externally: 'server_side_but_stored_externally', +}); + export const rightSidebarViews = { pipelines: { name: 'pipelines-list', keepAlive: true }, jobsDetail: { name: 'jobs-detail', keepAlive: false }, diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index 3f6101e58f4..229ef168926 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -73,7 +73,7 @@ router.beforeEach((to, from, next) => { projectId: to.params.project, }) .then(() => { - const basePath = to.params[0] || ''; + const basePath = to.params.pathMatch || ''; const projectId = `${to.params.namespace}/${to.params.project}`; const branchId = to.params.branchid; const mergeRequestId = to.params.mrid; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 6351948f750..cdfebd19fa4 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -6,17 +6,25 @@ import ide from './components/ide.vue'; import store from './stores'; import router from './ide_router'; import { parseBoolean } from '../lib/utils/common_utils'; +import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; Vue.use(Translate); /** + * Function that receives the default store and returns an extended one. + * @callback extendStoreCallback + * @param {Vuex.Store} store + * @param {Element} el + */ + +/** * Initialize the IDE on the given element. * * @param {Element} el - The element that will contain the IDE. * @param {Object} options - Extra options for the IDE (Used by EE). * @param {Component} options.rootComponent - * Component that overrides the root component. - * @param {(store:Vuex.Store, el:Element) => Vuex.Store} options.extendStore - + * @param {extendStoreCallback} options.extendStore - * Function that receives the default store and returns an extended one. */ export function initIde(el, options = {}) { @@ -53,16 +61,6 @@ export function initIde(el, options = {}) { }); } -// tell webpack to load assets from origin so that web workers don't break -export function resetServiceWorkersPublicPath() { - // __webpack_public_path__ is a global variable that can be used to adjust - // the webpack publicPath setting at runtime. - // see: https://webpack.js.org/guides/public-path/ - const relativeRootPath = (gon && gon.relative_url_root) || ''; - const webpackAssetPath = `${relativeRootPath}/assets/webpack/`; - __webpack_public_path__ = webpackAssetPath; // eslint-disable-line camelcase -} - /** * Start the IDE. * diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index 60bddb34977..a15f04075d9 100644 --- a/app/assets/javascripts/ide/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -13,12 +13,12 @@ export default class Model { (this.originalModel = monacoEditor.createModel( head ? head.content : this.file.raw, undefined, - new Uri(false, false, `original/${this.path}`), + new Uri('gitlab', false, `original/${this.path}`), )), (this.model = monacoEditor.createModel( this.content, undefined, - new Uri(false, false, this.path), + new Uri('gitlab', false, this.path), )), ); if (this.file.mrChange) { @@ -26,7 +26,7 @@ export default class Model { (this.baseModel = monacoEditor.createModel( this.file.baseRaw, undefined, - new Uri(false, false, `target/${this.path}`), + new Uri('gitlab', false, `target/${this.path}`), )), ); } diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js new file mode 100644 index 00000000000..5dfba8fe531 --- /dev/null +++ b/app/assets/javascripts/ide/lib/files.js @@ -0,0 +1,113 @@ +import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; +import { decorateData, sortTree } from '../stores/utils'; + +export const splitParent = path => { + const idx = path.lastIndexOf('/'); + + return { + parent: idx >= 0 ? path.substring(0, idx) : null, + name: idx >= 0 ? path.substring(idx + 1) : path, + }; +}; + +/** + * Create file objects from a list of file paths. + */ +export const decorateFiles = ({ + data, + projectId, + branchId, + tempFile = false, + content = '', + base64 = false, +}) => { + const treeList = []; + const entries = {}; + + // These mutable variable references end up being exported and used by `createTempEntry` + let file; + let parentPath; + + const insertParent = path => { + if (!path) { + return null; + } else if (entries[path]) { + return entries[path]; + } + + const { parent, name } = splitParent(path); + const parentFolder = parent && insertParent(parent); + parentPath = parentFolder && parentFolder.path; + + const tree = decorateData({ + projectId, + branchId, + id: path, + name, + path, + url: `/${projectId}/tree/${branchId}/-/${path}/`, + type: 'tree', + parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`, + tempFile, + changed: tempFile, + opened: tempFile, + parentPath, + }); + + Object.assign(entries, { + [path]: tree, + }); + + if (parentFolder) { + parentFolder.tree.push(tree); + } else { + treeList.push(tree); + } + + return tree; + }; + + data.forEach(path => { + const { parent, name } = splitParent(path); + + const fileFolder = parent && insertParent(parent); + + if (name) { + parentPath = fileFolder && fileFolder.path; + + file = decorateData({ + projectId, + branchId, + id: path, + name, + path, + url: `/${projectId}/blob/${branchId}/-/${path}`, + type: 'blob', + parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`, + tempFile, + changed: tempFile, + content, + base64, + previewMode: viewerInformationForPath(name), + parentPath, + }); + + Object.assign(entries, { + [path]: file, + }); + + if (fileFolder) { + fileFolder.tree.push(file); + } else { + treeList.push(file); + } + } + }); + + return { + entries, + treeList: sortTree(treeList), + file, + parentPath, + }; +}; diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 13449592e62..ba33b6826d6 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -40,6 +40,9 @@ export default { getProjectData(namespace, project) { return Api.project(`${namespace}/${project}`); }, + getProjectMergeRequests(projectId, params = {}) { + return Api.projectMergeRequests(projectId, params); + }, getProjectMergeRequestData(projectId, mergeRequestId, params = {}) { return Api.projectMergeRequest(projectId, mergeRequestId, params); }, diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index e10a132ab4b..7b660bda081 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import { visitUrl } from '~/lib/utils/url_utility'; import flash from '~/flash'; import * as types from './mutation_types'; -import FilesDecoratorWorker from './workers/files_decorator_worker'; +import { decorateFiles } from '../lib/files'; import { stageKeys } from '../constants'; export const redirectToUrl = (_, url) => visitUrl(url); @@ -56,7 +56,6 @@ export const createTempEntry = ( { name, type, content = '', base64 = false }, ) => new Promise(resolve => { - const worker = new FilesDecoratorWorker(); const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; if (state.entries[name]) { @@ -74,31 +73,7 @@ export const createTempEntry = ( return null; } - worker.addEventListener('message', ({ data }) => { - const { file, parentPath } = data; - - worker.terminate(); - - commit(types.CREATE_TMP_ENTRY, { - data, - projectId: state.currentProjectId, - branchId: state.currentBranchId, - }); - - if (type === 'blob') { - commit(types.TOGGLE_FILE_OPEN, file.path); - commit(types.ADD_FILE_TO_CHANGED, file.path); - dispatch('setFileActive', file.path); - } - - if (parentPath && !state.entries[parentPath].opened) { - commit(types.TOGGLE_TREE_OPEN, parentPath); - } - - resolve(file); - }); - - worker.postMessage({ + const data = decorateFiles({ data: [fullName], projectId: state.currentProjectId, branchId: state.currentBranchId, @@ -107,6 +82,25 @@ export const createTempEntry = ( base64, content, }); + const { file, parentPath } = data; + + commit(types.CREATE_TMP_ENTRY, { + data, + projectId: state.currentProjectId, + branchId: state.currentBranchId, + }); + + if (type === 'blob') { + commit(types.TOGGLE_FILE_OPEN, file.path); + commit(types.ADD_FILE_TO_CHANGED, file.path); + dispatch('setFileActive', file.path); + } + + if (parentPath && !state.entries[parentPath].opened) { + commit(types.TOGGLE_TREE_OPEN, parentPath); + } + + resolve(file); return null; }); @@ -215,15 +209,27 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => { export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES); -export const renameEntry = ({ dispatch, commit, state }, { path, name, entryPath = null }) => { +export const renameEntry = ( + { dispatch, commit, state }, + { path, name, entryPath = null, parentPath }, +) => { const entry = state.entries[entryPath || path]; - commit(types.RENAME_ENTRY, { path, name, entryPath }); + commit(types.RENAME_ENTRY, { path, name, entryPath, parentPath }); if (entry.type === 'tree') { - state.entries[entryPath || path].tree.forEach(f => - dispatch('renameEntry', { path, name, entryPath: f.path }), - ); + const slashedParentPath = parentPath ? `${parentPath}/` : ''; + const targetEntry = entryPath ? entryPath.split('/').pop() : name; + const newParentPath = `${slashedParentPath}${targetEntry}`; + + state.entries[entryPath || path].tree.forEach(f => { + dispatch('renameEntry', { + path, + name, + entryPath: f.path, + parentPath: newParentPath, + }); + }); } if (!entryPath && !entry.tempFile) { diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 18c24369996..362ced248a1 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -4,6 +4,38 @@ import service from '../../services'; import * as types from '../mutation_types'; import { activityBarViews } from '../../constants'; +export const getMergeRequestsForBranch = ({ commit }, { projectId, branchId } = {}) => + service + .getProjectMergeRequests(`${projectId}`, { + source_branch: branchId, + order_by: 'created_at', + per_page: 1, + }) + .then(({ data }) => { + if (data.length > 0) { + const currentMR = data[0]; + + commit(types.SET_MERGE_REQUEST, { + projectPath: projectId, + mergeRequestId: currentMR.iid, + mergeRequest: currentMR, + }); + + commit(types.SET_CURRENT_MERGE_REQUEST, `${currentMR.iid}`); + } + }) + .catch(e => { + flash( + __(`Error fetching merge requests for ${branchId}`), + 'alert', + document, + null, + false, + true, + ); + throw e; + }); + export const getMergeRequestData = ( { commit, dispatch, state }, { projectId, mergeRequestId, targetProjectId = null, force = false } = {}, diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index b65f631c99c..06ed5c0b572 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -136,17 +136,24 @@ export const openBranch = ({ dispatch, state }, { projectId, branchId, basePath return dispatch('getFiles', { projectId, branchId, - }).then(() => { - if (basePath) { - const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath; - const treeEntryKey = Object.keys(state.entries).find( - key => key === path && !state.entries[key].pending, - ); - const treeEntry = state.entries[treeEntryKey]; + }) + .then(() => { + if (basePath) { + const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath; + const treeEntryKey = Object.keys(state.entries).find( + key => key === path && !state.entries[key].pending, + ); + const treeEntry = state.entries[treeEntryKey]; - if (treeEntry) { - dispatch('handleTreeEntryAction', treeEntry); + if (treeEntry) { + dispatch('handleTreeEntryAction', treeEntry); + } } - } - }); + }) + .then(() => { + dispatch('getMergeRequestsForBranch', { + projectId, + branchId, + }); + }); }; diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index de5f6050074..3d83e4a9ba5 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -1,7 +1,8 @@ +import _ from 'underscore'; import { __ } from '../../../locale'; import service from '../../services'; import * as types from '../mutation_types'; -import FilesDecoratorWorker from '../workers/files_decorator_worker'; +import { decorateFiles } from '../../lib/files'; export const toggleTreeOpen = ({ commit }, path) => { commit(types.TOGGLE_TREE_OPEN, path); @@ -32,6 +33,19 @@ export const handleTreeEntryAction = ({ commit, dispatch }, row) => { dispatch('showTreeEntry', row.path); }; +export const setDirectoryData = ({ state, commit }, { projectId, branchId, treeList }) => { + const selectedTree = state.trees[`${projectId}/${branchId}`]; + + commit(types.SET_DIRECTORY_DATA, { + treePath: `${projectId}/${branchId}`, + data: treeList, + }); + commit(types.TOGGLE_LOADING, { + entry: selectedTree, + forceValue: false, + }); +}; + export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = {}) => new Promise((resolve, reject) => { if ( @@ -45,31 +59,19 @@ export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = service .getFiles(selectedProject.web_url, branchId) .then(({ data }) => { - const worker = new FilesDecoratorWorker(); - worker.addEventListener('message', e => { - const { entries, treeList } = e.data; - const selectedTree = state.trees[`${projectId}/${branchId}`]; - - commit(types.SET_ENTRIES, entries); - commit(types.SET_DIRECTORY_DATA, { - treePath: `${projectId}/${branchId}`, - data: treeList, - }); - commit(types.TOGGLE_LOADING, { - entry: selectedTree, - forceValue: false, - }); - - worker.terminate(); - - resolve(); - }); - - worker.postMessage({ + const { entries, treeList } = decorateFiles({ data, projectId, branchId, }); + + commit(types.SET_ENTRIES, entries); + + // Defer setting the directory data because this triggers some intense rendering. + // The entries is all we need to load the file editor. + _.defer(() => dispatch('setDirectoryData', { projectId, branchId, treeList })); + + resolve(); }) .catch(e => { if (e.response.status === 404) { diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index d97a950a8b2..24c2f71ae2b 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -202,7 +202,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo dispatch( 'setErrorMessage', { - text: __('An error accured whilst committing your changes.'), + text: __('An error occurred whilst committing your changes.'), action: () => dispatch('commitChanges').then(() => dispatch('setErrorMessage', null, { root: true }), diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index 78cdfda74f0..9b9f4b21f1c 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -206,19 +206,17 @@ export default { } } }, - [types.RENAME_ENTRY](state, { path, name, entryPath = null }) { + [types.RENAME_ENTRY](state, { path, name, entryPath = null, parentPath }) { const oldEntry = state.entries[entryPath || path]; - const nameRegex = - !entryPath && oldEntry.type === 'blob' - ? new RegExp(`${oldEntry.name}$`) - : new RegExp(`^${path}`); - const newPath = oldEntry.path.replace(nameRegex, name); - const parentPath = oldEntry.parentPath ? oldEntry.parentPath.replace(nameRegex, name) : ''; + const slashedParentPath = parentPath ? `${parentPath}/` : ''; + const newPath = entryPath + ? `${slashedParentPath}${oldEntry.name}` + : `${slashedParentPath}${name}`; state.entries[newPath] = { ...oldEntry, id: newPath, - key: `${name}-${oldEntry.type}-${oldEntry.id}`, + key: `${newPath}-${oldEntry.type}-${oldEntry.id}`, path: newPath, name: entryPath ? oldEntry.name : name, tempFile: true, @@ -228,6 +226,7 @@ export default { parentPath, raw: '', }; + oldEntry.moved = true; oldEntry.movedPath = newPath; @@ -256,6 +255,7 @@ export default { Vue.delete(state.entries, oldEntry.path); } }, + ...projectMutations, ...mergeRequestMutation, ...fileMutations, diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 0ede76fd1e0..0b2a18e9c8a 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -75,8 +75,7 @@ export const decorateData = entity => { parentPath = '', } = entity; - return { - ...dataStructure(), + return Object.assign(dataStructure(), { id, projectId, branchId, @@ -97,7 +96,7 @@ export const decorateData = entity => { file_lock, html, parentPath, - }; + }); }; export const findEntry = (tree, type, name, prop = 'name') => diff --git a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js deleted file mode 100644 index fa35c215880..00000000000 --- a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js +++ /dev/null @@ -1,100 +0,0 @@ -import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; -import { decorateData, sortTree } from '../utils'; - -// eslint-disable-next-line no-restricted-globals -self.addEventListener('message', e => { - const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data; - - const treeList = []; - let file; - let parentPath; - const entries = data.reduce((acc, path) => { - const pathSplit = path.split('/'); - const blobName = pathSplit.pop().trim(); - - if (pathSplit.length > 0) { - pathSplit.reduce((pathAcc, folderName) => { - const parentFolder = acc[pathAcc[pathAcc.length - 1]]; - const folderPath = `${parentFolder ? `${parentFolder.path}/` : ''}${folderName}`; - const foundEntry = acc[folderPath]; - - if (!foundEntry) { - parentPath = parentFolder ? parentFolder.path : null; - - const tree = decorateData({ - projectId, - branchId, - id: folderPath, - name: folderName, - path: folderPath, - url: `/${projectId}/tree/${branchId}/-/${folderPath}/`, - type: 'tree', - parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`, - tempFile, - changed: tempFile, - opened: tempFile, - parentPath, - }); - - Object.assign(acc, { - [folderPath]: tree, - }); - - if (parentFolder) { - parentFolder.tree.push(tree); - } else { - treeList.push(tree); - } - - pathAcc.push(tree.path); - } else { - pathAcc.push(foundEntry.path); - } - - return pathAcc; - }, []); - } - - if (blobName !== '') { - const fileFolder = acc[pathSplit.join('/')]; - parentPath = fileFolder ? fileFolder.path : null; - - file = decorateData({ - projectId, - branchId, - id: path, - name: blobName, - path, - url: `/${projectId}/blob/${branchId}/-/${path}`, - type: 'blob', - parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`, - tempFile, - changed: tempFile, - content, - base64, - previewMode: viewerInformationForPath(blobName), - parentPath, - }); - - Object.assign(acc, { - [path]: file, - }); - - if (fileFolder) { - fileFolder.tree.push(file); - } else { - treeList.push(file); - } - } - - return acc; - }, {}); - - // eslint-disable-next-line no-restricted-globals - self.postMessage({ - entries, - treeList: sortTree(treeList), - file, - parentPath, - }); -}); diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_projects/components/import_projects_table.vue new file mode 100644 index 00000000000..777f8fa6691 --- /dev/null +++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue @@ -0,0 +1,101 @@ +<script> +import { mapActions, mapState, mapGetters } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import { __, sprintf } from '~/locale'; +import ImportedProjectTableRow from './imported_project_table_row.vue'; +import ProviderRepoTableRow from './provider_repo_table_row.vue'; +import eventHub from '../event_hub'; + +export default { + name: 'ImportProjectsTable', + components: { + ImportedProjectTableRow, + ProviderRepoTableRow, + LoadingButton, + GlLoadingIcon, + }, + props: { + providerTitle: { + type: String, + required: true, + }, + }, + + computed: { + ...mapState(['importedProjects', 'providerRepos', 'isLoadingRepos']), + ...mapGetters(['isImportingAnyRepo', 'hasProviderRepos', 'hasImportedProjects']), + + emptyStateText() { + return sprintf(__('No %{providerTitle} repositories available to import'), { + providerTitle: this.providerTitle, + }); + }, + + fromHeaderText() { + return sprintf(__('From %{providerTitle}'), { providerTitle: this.providerTitle }); + }, + }, + + mounted() { + return this.fetchRepos(); + }, + + beforeDestroy() { + this.stopJobsPolling(); + this.clearJobsEtagPoll(); + }, + + methods: { + ...mapActions(['fetchRepos', 'fetchJobs', 'stopJobsPolling', 'clearJobsEtagPoll']), + + importAll() { + eventHub.$emit('importAll'); + }, + }, +}; +</script> + +<template> + <div> + <div class="d-flex justify-content-between align-items-end flex-wrap mb-3"> + <p class="light text-nowrap mt-2 my-sm-0"> + {{ s__('ImportProjects|Select the projects you want to import') }} + </p> + <loading-button + container-class="btn btn-success js-import-all" + :loading="isImportingAnyRepo" + :label="__('Import all repositories')" + :disabled="!hasProviderRepos" + type="button" + @click="importAll" + /> + </div> + <gl-loading-icon + v-if="isLoadingRepos" + class="js-loading-button-icon import-projects-loading-icon" + :size="4" + /> + <div v-else-if="hasProviderRepos || hasImportedProjects" class="table-responsive"> + <table class="table import-table"> + <thead> + <th class="import-jobs-from-col">{{ fromHeaderText }}</th> + <th class="import-jobs-to-col">{{ __('To GitLab') }}</th> + <th class="import-jobs-status-col">{{ __('Status') }}</th> + <th class="import-jobs-cta-col"></th> + </thead> + <tbody> + <imported-project-table-row + v-for="project in importedProjects" + :key="project.id" + :project="project" + /> + <provider-repo-table-row v-for="repo in providerRepos" :key="repo.id" :repo="repo" /> + </tbody> + </table> + </div> + <div v-else class="text-center"> + <strong>{{ emptyStateText }}</strong> + </div> + </div> +</template> diff --git a/app/assets/javascripts/import_projects/components/import_status.vue b/app/assets/javascripts/import_projects/components/import_status.vue new file mode 100644 index 00000000000..9e3347a657f --- /dev/null +++ b/app/assets/javascripts/import_projects/components/import_status.vue @@ -0,0 +1,47 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import STATUS_MAP from '../constants'; + +export default { + name: 'ImportStatus', + components: { + CiIcon, + GlLoadingIcon, + }, + props: { + status: { + type: String, + required: true, + }, + }, + + computed: { + mappedStatus() { + return STATUS_MAP[this.status]; + }, + + ciIconStatus() { + const { icon } = this.mappedStatus; + + return { + icon: `status_${icon}`, + group: icon, + }; + }, + }, +}; +</script> + +<template> + <div> + <gl-loading-icon + v-if="mappedStatus.loadingIcon" + :inline="true" + :class="mappedStatus.textClass" + class="align-middle mr-2" + /> + <ci-icon v-else css-classes="align-middle mr-2" :status="ciIconStatus" /> + <span :class="mappedStatus.textClass">{{ mappedStatus.text }}</span> + </div> +</template> diff --git a/app/assets/javascripts/import_projects/components/imported_project_table_row.vue b/app/assets/javascripts/import_projects/components/imported_project_table_row.vue new file mode 100644 index 00000000000..ab2bd87ee9f --- /dev/null +++ b/app/assets/javascripts/import_projects/components/imported_project_table_row.vue @@ -0,0 +1,55 @@ +<script> +import ImportStatus from './import_status.vue'; +import { STATUSES } from '../constants'; + +export default { + name: 'ImportedProjectTableRow', + components: { + ImportStatus, + }, + props: { + project: { + type: Object, + required: true, + }, + }, + + computed: { + displayFullPath() { + return this.project.fullPath.replace(/^\//, ''); + }, + + isFinished() { + return this.project.importStatus === STATUSES.FINISHED; + }, + }, +}; +</script> + +<template> + <tr class="js-imported-project import-row"> + <td> + <a + :href="project.providerLink" + rel="noreferrer noopener" + target="_blank" + class="js-provider-link" + > + {{ project.importSource }} + </a> + </td> + <td class="js-full-path">{{ displayFullPath }}</td> + <td><import-status :status="project.importStatus" /></td> + <td> + <a + v-if="isFinished" + class="btn btn-default js-go-to-project" + :href="project.fullPath" + rel="noreferrer noopener" + target="_blank" + > + {{ __('Go to project') }} + </a> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue new file mode 100644 index 00000000000..7cc29fa1b91 --- /dev/null +++ b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue @@ -0,0 +1,110 @@ +<script> +import { mapState, mapGetters, mapActions } from 'vuex'; +import Select2Select from '~/vue_shared/components/select2_select.vue'; +import { __ } from '~/locale'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import eventHub from '../event_hub'; +import { STATUSES } from '../constants'; +import ImportStatus from './import_status.vue'; + +export default { + name: 'ProviderRepoTableRow', + components: { + Select2Select, + LoadingButton, + ImportStatus, + }, + props: { + repo: { + type: Object, + required: true, + }, + }, + + data() { + return { + targetNamespace: this.$store.state.defaultTargetNamespace, + newName: this.repo.sanitizedName, + }; + }, + + computed: { + ...mapState(['namespaces', 'reposBeingImported', 'ciCdOnly']), + + ...mapGetters(['namespaceSelectOptions']), + + importButtonText() { + return this.ciCdOnly ? __('Connect') : __('Import'); + }, + + select2Options() { + return { + data: this.namespaceSelectOptions, + containerCssClass: + 'import-namespace-select js-namespace-select qa-project-namespace-select', + }; + }, + + isLoadingImport() { + return this.reposBeingImported.includes(this.repo.id); + }, + + status() { + return this.isLoadingImport ? STATUSES.SCHEDULING : STATUSES.NONE; + }, + }, + + created() { + eventHub.$on('importAll', () => this.importRepo()); + }, + + methods: { + ...mapActions(['fetchImport']), + + importRepo() { + return this.fetchImport({ + newName: this.newName, + targetNamespace: this.targetNamespace, + repo: this.repo, + }); + }, + }, +}; +</script> + +<template> + <tr class="qa-project-import-row js-provider-repo import-row"> + <td> + <a + :href="repo.providerLink" + rel="noreferrer noopener" + target="_blank" + class="js-provider-link" + > + {{ repo.fullName }} + </a> + </td> + <td class="d-flex flex-wrap flex-lg-nowrap"> + <select2-select v-model="targetNamespace" :options="select2Options" /> + <span class="px-2 import-slash-divider d-flex justify-content-center align-items-center" + >/</span + > + <input + v-model="newName" + type="text" + class="form-control import-project-name-input js-new-name qa-project-path-field" + /> + </td> + <td><import-status :status="status" /></td> + <td> + <button + v-if="!isLoadingImport" + type="button" + class="qa-import-button js-import-button btn btn-default" + @click="importRepo" + > + {{ importButtonText }} + </button> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/import_projects/constants.js b/app/assets/javascripts/import_projects/constants.js new file mode 100644 index 00000000000..ad33ca158d2 --- /dev/null +++ b/app/assets/javascripts/import_projects/constants.js @@ -0,0 +1,48 @@ +import { __ } from '../locale'; + +// The `scheduling` status is only present on the client-side, +// it is used as the status when we are requesting to start an import. + +export const STATUSES = { + FINISHED: 'finished', + FAILED: 'failed', + SCHEDULED: 'scheduled', + STARTED: 'started', + NONE: 'none', + SCHEDULING: 'scheduling', +}; + +const STATUS_MAP = { + [STATUSES.FINISHED]: { + icon: 'success', + text: __('Done'), + textClass: 'text-success', + }, + [STATUSES.FAILED]: { + icon: 'failed', + text: __('Failed'), + textClass: 'text-danger', + }, + [STATUSES.SCHEDULED]: { + icon: 'pending', + text: __('Scheduled'), + textClass: 'text-warning', + }, + [STATUSES.STARTED]: { + icon: 'running', + text: __('Running…'), + textClass: 'text-info', + }, + [STATUSES.NONE]: { + icon: 'created', + text: __('Not started'), + textClass: 'text-muted', + }, + [STATUSES.SCHEDULING]: { + loadingIcon: true, + text: __('Scheduling'), + textClass: 'text-warning', + }, +}; + +export default STATUS_MAP; diff --git a/app/assets/javascripts/monitoring/event_hub.js b/app/assets/javascripts/import_projects/event_hub.js index 0948c2e5352..0948c2e5352 100644 --- a/app/assets/javascripts/monitoring/event_hub.js +++ b/app/assets/javascripts/import_projects/event_hub.js diff --git a/app/assets/javascripts/import_projects/index.js b/app/assets/javascripts/import_projects/index.js new file mode 100644 index 00000000000..2d99d716609 --- /dev/null +++ b/app/assets/javascripts/import_projects/index.js @@ -0,0 +1,48 @@ +import Vue from 'vue'; +import { mapActions } from 'vuex'; +import Translate from '../vue_shared/translate'; +import ImportProjectsTable from './components/import_projects_table.vue'; +import { parseBoolean } from '../lib/utils/common_utils'; +import createStore from './store'; + +Vue.use(Translate); + +export default function mountImportProjectsTable(mountElement) { + if (!mountElement) return undefined; + + const { + reposPath, + provider, + providerTitle, + canSelectNamespace, + jobsPath, + importPath, + ciCdOnly, + } = mountElement.dataset; + + const store = createStore(); + return new Vue({ + el: mountElement, + store, + + created() { + this.setInitialData({ + reposPath, + provider, + jobsPath, + importPath, + defaultTargetNamespace: gon.current_username, + ciCdOnly: parseBoolean(ciCdOnly), + canSelectNamespace: parseBoolean(canSelectNamespace), + }); + }, + + methods: { + ...mapActions(['setInitialData']), + }, + + render(createElement) { + return createElement(ImportProjectsTable, { props: { providerTitle } }); + }, + }); +} diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js new file mode 100644 index 00000000000..c44500937cc --- /dev/null +++ b/app/assets/javascripts/import_projects/store/actions.js @@ -0,0 +1,106 @@ +import Visibility from 'visibilityjs'; +import * as types from './mutation_types'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import Poll from '~/lib/utils/poll'; +import createFlash from '~/flash'; +import { s__, sprintf } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; + +let eTagPoll; + +export const clearJobsEtagPoll = () => { + eTagPoll = null; +}; +export const stopJobsPolling = () => { + if (eTagPoll) eTagPoll.stop(); +}; +export const restartJobsPolling = () => { + if (eTagPoll) eTagPoll.restart(); +}; + +export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); + +export const requestRepos = ({ commit }, repos) => commit(types.REQUEST_REPOS, repos); +export const receiveReposSuccess = ({ commit }, repos) => + commit(types.RECEIVE_REPOS_SUCCESS, repos); +export const receiveReposError = ({ commit }) => commit(types.RECEIVE_REPOS_ERROR); +export const fetchRepos = ({ state, dispatch }) => { + dispatch('requestRepos'); + + return axios + .get(state.reposPath) + .then(({ data }) => + dispatch('receiveReposSuccess', convertObjectPropsToCamelCase(data, { deep: true })), + ) + .then(() => dispatch('fetchJobs')) + .catch(() => { + createFlash( + sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), { + provider: state.provider, + }), + ); + + dispatch('receiveReposError'); + }); +}; + +export const requestImport = ({ commit, state }, repoId) => { + if (!state.reposBeingImported.includes(repoId)) commit(types.REQUEST_IMPORT, repoId); +}; +export const receiveImportSuccess = ({ commit }, { importedProject, repoId }) => + commit(types.RECEIVE_IMPORT_SUCCESS, { importedProject, repoId }); +export const receiveImportError = ({ commit }, repoId) => + commit(types.RECEIVE_IMPORT_ERROR, repoId); +export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, repo }) => { + dispatch('requestImport', repo.id); + + return axios + .post(state.importPath, { + ci_cd_only: state.ciCdOnly, + new_name: newName, + repo_id: repo.id, + target_namespace: targetNamespace, + }) + .then(({ data }) => + dispatch('receiveImportSuccess', { + importedProject: convertObjectPropsToCamelCase(data, { deep: true }), + repoId: repo.id, + }), + ) + .catch(() => { + createFlash(s__('ImportProjects|Importing the project failed')); + + dispatch('receiveImportError', { repoId: repo.id }); + }); +}; + +export const receiveJobsSuccess = ({ commit }, updatedProjects) => + commit(types.RECEIVE_JOBS_SUCCESS, updatedProjects); +export const fetchJobs = ({ state, dispatch }) => { + if (eTagPoll) return; + + eTagPoll = new Poll({ + resource: { + fetchJobs: () => axios.get(state.jobsPath), + }, + method: 'fetchJobs', + successCallback: ({ data }) => + dispatch('receiveJobsSuccess', convertObjectPropsToCamelCase(data, { deep: true })), + errorCallback: () => createFlash(s__('ImportProjects|Updating the imported projects failed')), + }); + + if (!Visibility.hidden()) { + eTagPoll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + dispatch('restartJobsPolling'); + } else { + dispatch('stopJobsPolling'); + } + }); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_projects/store/getters.js new file mode 100644 index 00000000000..f03474a8404 --- /dev/null +++ b/app/assets/javascripts/import_projects/store/getters.js @@ -0,0 +1,20 @@ +export const namespaceSelectOptions = state => { + const serializedNamespaces = state.namespaces.map(({ fullPath }) => ({ + id: fullPath, + text: fullPath, + })); + + return [ + { text: 'Groups', children: serializedNamespaces }, + { + text: 'Users', + children: [{ id: state.defaultTargetNamespace, text: state.defaultTargetNamespace }], + }, + ]; +}; + +export const isImportingAnyRepo = state => state.reposBeingImported.length > 0; + +export const hasProviderRepos = state => state.providerRepos.length > 0; + +export const hasImportedProjects = state => state.importedProjects.length > 0; diff --git a/app/assets/javascripts/import_projects/store/index.js b/app/assets/javascripts/import_projects/store/index.js new file mode 100644 index 00000000000..f666e2ebf33 --- /dev/null +++ b/app/assets/javascripts/import_projects/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from './state'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + state: state(), + actions, + mutations, + getters, + }); diff --git a/app/assets/javascripts/import_projects/store/mutation_types.js b/app/assets/javascripts/import_projects/store/mutation_types.js new file mode 100644 index 00000000000..6ba3fd6f29e --- /dev/null +++ b/app/assets/javascripts/import_projects/store/mutation_types.js @@ -0,0 +1,11 @@ +export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; + +export const REQUEST_REPOS = 'REQUEST_REPOS'; +export const RECEIVE_REPOS_SUCCESS = 'RECEIVE_REPOS_SUCCESS'; +export const RECEIVE_REPOS_ERROR = 'RECEIVE_REPOS_ERROR'; + +export const REQUEST_IMPORT = 'REQUEST_IMPORT'; +export const RECEIVE_IMPORT_SUCCESS = 'RECEIVE_IMPORT_SUCCESS'; +export const RECEIVE_IMPORT_ERROR = 'RECEIVE_IMPORT_ERROR'; + +export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS'; diff --git a/app/assets/javascripts/import_projects/store/mutations.js b/app/assets/javascripts/import_projects/store/mutations.js new file mode 100644 index 00000000000..b88de0268e7 --- /dev/null +++ b/app/assets/javascripts/import_projects/store/mutations.js @@ -0,0 +1,55 @@ +import Vue from 'vue'; +import * as types from './mutation_types'; + +export default { + [types.SET_INITIAL_DATA](state, data) { + Object.assign(state, data); + }, + + [types.REQUEST_REPOS](state) { + state.isLoadingRepos = true; + }, + + [types.RECEIVE_REPOS_SUCCESS](state, { importedProjects, providerRepos, namespaces }) { + state.isLoadingRepos = false; + + state.importedProjects = importedProjects; + state.providerRepos = providerRepos; + state.namespaces = namespaces; + }, + + [types.RECEIVE_REPOS_ERROR](state) { + state.isLoadingRepos = false; + }, + + [types.REQUEST_IMPORT](state, repoId) { + state.reposBeingImported.push(repoId); + }, + + [types.RECEIVE_IMPORT_SUCCESS](state, { importedProject, repoId }) { + const existingRepoIndex = state.reposBeingImported.indexOf(repoId); + if (state.reposBeingImported.includes(repoId)) + state.reposBeingImported.splice(existingRepoIndex, 1); + + const providerRepoIndex = state.providerRepos.findIndex( + providerRepo => providerRepo.id === repoId, + ); + state.providerRepos.splice(providerRepoIndex, 1); + state.importedProjects.unshift(importedProject); + }, + + [types.RECEIVE_IMPORT_ERROR](state, repoId) { + const repoIndex = state.reposBeingImported.indexOf(repoId); + if (state.reposBeingImported.includes(repoId)) state.reposBeingImported.splice(repoIndex, 1); + }, + + [types.RECEIVE_JOBS_SUCCESS](state, updatedProjects) { + updatedProjects.forEach(updatedProject => { + const existingProject = state.importedProjects.find( + importedProject => importedProject.id === updatedProject.id, + ); + + Vue.set(existingProject, 'importStatus', updatedProject.importStatus); + }); + }, +}; diff --git a/app/assets/javascripts/import_projects/store/state.js b/app/assets/javascripts/import_projects/store/state.js new file mode 100644 index 00000000000..637fef6e53c --- /dev/null +++ b/app/assets/javascripts/import_projects/store/state.js @@ -0,0 +1,15 @@ +export default () => ({ + reposPath: '', + importPath: '', + jobsPath: '', + currentProjectId: '', + provider: '', + currentUsername: '', + importedProjects: [], + providerRepos: [], + namespaces: [], + reposBeingImported: [], + isLoadingRepos: false, + canSelectNamespace: false, + ciCdOnly: false, +}); diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js index 612c524ca1c..e0fb58ef195 100644 --- a/app/assets/javascripts/issuable/auto_width_dropdown_select.js +++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js @@ -11,10 +11,14 @@ class AutoWidthDropdownSelect { init() { const { dropdownClass } = this; - this.$selectElement.select2({ - dropdownCssClass: dropdownClass, - ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass), - }); + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + this.$selectElement.select2({ + dropdownCssClass: dropdownClass, + ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass), + }); + }) + .catch(() => {}); return this; } diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index f3d722409b0..48e7ed1318d 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -7,10 +7,14 @@ export default class IssuableContext { constructor(currentUser) { this.userSelect = new UsersSelect(currentUser); - $('select.select2').select2({ - width: 'resolve', - dropdownAutoWidth: true, - }); + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('select.select2').select2({ + width: 'resolve', + dropdownAutoWidth: true, + }); + }) + .catch(() => {}); $('.issuable-sidebar .inline-update').on('change', 'select', function onClickSelect() { return $(this).submit(); diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index c81a2230310..9336b71cfd7 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -44,6 +44,7 @@ export default class IssuableForm { parse: dateString => parsePikadayDate(dateString), toString: date => pikadayToString(date), onSelect: dateText => $issuableDueDate.val(calendar.toString(dateText)), + firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate($issuableDueDate.val())); } @@ -120,35 +121,39 @@ export default class IssuableForm { } initTargetBranchDropdown() { - this.$targetBranchSelect.select2({ - ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'), - ajax: { - url: this.$targetBranchSelect.data('endpoint'), - dataType: 'JSON', - quietMillis: 250, - data(search) { - return { - search, - }; - }, - results(data) { - return { - // `data` keys are translated so we can't just access them with a string based key - results: data[Object.keys(data)[0]].map(name => ({ - id: name, - text: name, - })), - }; - }, - }, - initSelection(el, callback) { - const val = el.val(); - - callback({ - id: val, - text: val, + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + this.$targetBranchSelect.select2({ + ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'), + ajax: { + url: this.$targetBranchSelect.data('endpoint'), + dataType: 'JSON', + quietMillis: 250, + data(search) { + return { + search, + }; + }, + results(data) { + return { + // `data` keys are translated so we can't just access them with a string based key + results: data[Object.keys(data)[0]].map(name => ({ + id: name, + text: name, + })), + }; + }, + }, + initSelection(el, callback) { + const val = el.val(); + + callback({ + id: val, + text: val, + }); + }, }); - }, - }); + }) + .catch(() => {}); } } diff --git a/app/assets/javascripts/issuable_suggestions/index.js b/app/assets/javascripts/issuable_suggestions/index.js index 2c80cf1797a..40916c9d27f 100644 --- a/app/assets/javascripts/issuable_suggestions/index.js +++ b/app/assets/javascripts/issuable_suggestions/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import defaultClient from '~/lib/graphql'; +import createDefaultClient from '~/lib/graphql'; import App from './components/app.vue'; Vue.use(VueApollo); @@ -10,7 +10,7 @@ export default function() { const issueTitle = document.getElementById('issue_title'); const { projectPath } = el.dataset; const apolloProvider = new VueApollo({ - defaultClient, + 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 e4e2eab2acd..bd757a76ee7 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -1,5 +1,7 @@ <script> import Visibility from 'visibilityjs'; +import { __, s__, sprintf } from '~/locale'; +import createFlash from '~/flash'; import { visitUrl } from '../../lib/utils/url_utility'; import Poll from '../../lib/utils/poll'; import eventHub from '../event_hub'; @@ -106,11 +108,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, projectPath: { type: String, required: true, @@ -129,6 +126,11 @@ export default { required: false, default: true, }, + lockVersion: { + type: Number, + required: false, + default: 0, + }, }, data() { const store = new Store({ @@ -140,6 +142,7 @@ export default { updatedByName: this.updatedByName, updatedByPath: this.updatedByPath, taskStatus: this.initialTaskStatus, + lock_version: this.lockVersion, }); return { @@ -160,6 +163,9 @@ export default { const titleChanged = this.initialTitleText !== this.store.formState.title; return descriptionChanged || titleChanged; }, + defaultErrorMessage() { + return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType }); + }, }, created() { this.service = new Service(this.endpoint); @@ -201,11 +207,22 @@ export default { methods: { handleBeforeUnloadEvent(e) { const event = e; - if (this.showForm && this.issueChanged) { - event.returnValue = 'Are you sure you want to lose your issue information?'; + if (this.showForm && this.issueChanged && !this.showRecaptcha) { + event.returnValue = __('Are you sure you want to lose your issue information?'); } return undefined; }, + updateStoreState() { + return this.service + .getData() + .then(res => res.data) + .then(data => { + this.store.updateState(data); + }) + .catch(() => { + createFlash(this.defaultErrorMessage); + }); + }, openForm() { if (!this.showForm) { @@ -213,6 +230,7 @@ export default { this.store.setFormState({ title: this.state.titleText, description: this.state.descriptionText, + lock_version: this.state.lock_version, lockedWarningVisible: false, updateLoading: false, }); @@ -231,20 +249,24 @@ export default { if (window.location.pathname !== data.web_url) { visitUrl(data.web_url); } - - return this.service.getData(); }) - .then(res => res.data) - .then(data => { - this.store.updateState(data); + .then(this.updateStoreState) + .then(() => { eventHub.$emit('close.form'); }) - .catch(error => { - if (error && error.name === 'SpamError') { + .catch((error = {}) => { + const { name, response = {} } = error; + + if (name === 'SpamError') { this.openRecaptcha(); } else { - eventHub.$emit('close.form'); - window.Flash(`Error updating ${this.issuableType}`); + let errMsg = this.defaultErrorMessage; + + if (response.data && response.data.errors) { + errMsg += `. ${response.data.errors.join(' ')}`; + } + + createFlash(errMsg); } }); }, @@ -268,8 +290,9 @@ export default { visitUrl(data.web_url); }) .catch(() => { - eventHub.$emit('close.form'); - window.Flash(`Error deleting ${this.issuableType}`); + createFlash( + sprintf(s__('Error deleting %{issuableType}'), { issuableType: this.issuableType }), + ); }); }, }, @@ -285,7 +308,6 @@ export default { :issuable-templates="issuableTemplates" :markdown-docs-path="markdownDocsPath" :markdown-preview-path="markdownPreviewPath" - :markdown-version="markdownVersion" :project-path="projectPath" :project-namespace="projectNamespace" :show-delete-button="showDeleteButton" @@ -313,6 +335,8 @@ export default { :task-status="state.taskStatus" :issuable-type="issuableType" :update-url="updateEndpoint" + :lock-version="state.lock_version" + @taskListUpdateFailed="updateStoreState" /> <edited-component v-if="hasUpdated" diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 5ca88d75063..58f14bac8c8 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -1,5 +1,7 @@ <script> import $ from 'jquery'; +import { s__, sprintf } from '~/locale'; +import createFlash from '~/flash'; import animateMixin from '../mixins/animate'; import TaskList from '../../task_list'; import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor'; @@ -35,6 +37,11 @@ export default { required: false, default: null, }, + lockVersion: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -67,8 +74,10 @@ export default { new TaskList({ dataType: this.issuableType, fieldName: 'description', + lockVersion: this.lockVersion, selector: '.detail-page-description', onSuccess: this.taskListUpdateSuccess.bind(this), + onError: this.taskListUpdateError.bind(this), }); } }, @@ -82,6 +91,21 @@ export default { } }, + taskListUpdateError() { + createFlash( + 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.', + ), + { + issueType: this.issuableType, + }, + ), + ); + + this.$emit('taskListUpdateFailed'); + }, + updateTaskStatusText() { const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/); const $issuableHeader = $('.issuable-meta'); diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 90258c0e044..299130e56ae 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -20,11 +20,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, canAttachFile: { type: Boolean, required: false, @@ -48,7 +43,6 @@ export default { <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :markdown-version="markdownVersion" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" > diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index 45b60bc3392..eade31f1d14 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -39,11 +39,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, projectPath: { type: String, required: true, @@ -101,7 +96,6 @@ export default { :form-state="formState" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :markdown-version="markdownVersion" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" /> diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js index 32044d6da25..3c17e73ccec 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -1,3 +1,5 @@ +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + export default class Store { constructor(initialState) { this.state = initialState; @@ -6,6 +8,7 @@ export default class Store { description: '', lockedWarningVisible: false, updateLoading: false, + lock_version: 0, }; } @@ -14,14 +17,10 @@ export default class Store { this.formState.lockedWarningVisible = true; } + Object.assign(this.state, convertObjectPropsToCamelCase(data)); this.state.titleHtml = data.title; - this.state.titleText = data.title_text; this.state.descriptionHtml = data.description; - this.state.descriptionText = data.description_text; - this.state.taskStatus = data.task_status; - this.state.updatedAt = data.updated_at; - this.state.updatedByName = data.updated_by_name; - this.state.updatedByPath = data.updated_by_path; + this.state.lock_version = data.lock_version; } stateShouldUpdate(data) { diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue index e0f55518eef..7076a79dd5d 100644 --- a/app/assets/javascripts/jobs/components/commit_block.vue +++ b/app/assets/javascripts/jobs/components/commit_block.vue @@ -45,7 +45,7 @@ export default { /> <span v-if="mergeRequest"> - {{ __('in') }} + in <gl-link :href="mergeRequest.path" class="js-link-commit link-commit" >!{{ mergeRequest.iid }}</gl-link > diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index 786ab16992d..dbadd224251 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -34,6 +34,7 @@ export default { StuckBlock, Sidebar, GlLoadingIcon, + SharedRunner: () => import('ee_component/jobs/components/shared_runner_limit_block.vue'), }, mixins: [delayedJobMixin], props: { @@ -80,11 +81,11 @@ export default { 'hasError', ]), ...mapGetters([ - 'headerActions', 'headerTime', 'shouldRenderCalloutMessage', 'shouldRenderTriggeredLabel', 'hasEnvironment', + 'shouldRenderSharedRunnerLimitWarning', 'hasTrace', 'emptyStateIllustration', 'isScrollingDown', @@ -202,7 +203,6 @@ export default { :item-id="job.id" :time="headerTime" :user="job.user" - :actions="headerActions" :has-sidebar-button="true" :should-render-triggered-label="shouldRenderTriggeredLabel" :item-name="__('Job')" @@ -223,6 +223,14 @@ export default { :runners-path="runnerSettingsUrl" /> + <shared-runner + v-if="shouldRenderSharedRunnerLimitWarning" + class="js-shared-runner-limit" + :quota-used="job.runners.quota.used" + :quota-limit="job.runners.quota.limit" + :runners-path="runnerHelpUrl" + /> + <environments-block v-if="hasEnvironment" class="js-job-environment" @@ -240,14 +248,19 @@ export default { <div v-if="job.archived" ref="sticky" - class="js-archived-job prepend-top-default archived-sticky sticky-top" + class="js-archived-job prepend-top-default archived-job" + :class="{ 'sticky-top border-bottom-0': hasTrace }" > <icon name="lock" class="align-text-bottom" /> {{ __('This job is archived. Only the complete pipeline can be retried.') }} </div> <!-- job log --> - <div v-if="hasTrace" class="build-trace-container"> + <div + v-if="hasTrace" + class="build-trace-container" + :class="{ 'prepend-top-default': !job.archived }" + > <log-top-bar :class="{ 'sidebar-expanded': isSidebarOpen, diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index ad3e7dabc79..1691ac62100 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -48,8 +48,7 @@ export default { return `${this.job.runner.description} (#${this.job.runner.id})`; }, retryButtonClass() { - let className = - 'js-retry-button float-right btn btn-retry d-none d-md-block d-lg-block d-xl-block'; + let className = 'js-retry-button btn btn-retry'; className += this.job.status && this.job.recoverable ? ' btn-primary' : ' btn-inverted-secondary'; return className; @@ -110,24 +109,27 @@ export default { <aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix"> <div class="sidebar-container"> <div class="blocks-container"> - <div class="block d-flex align-items-center"> - <h4 class="flex-grow-1 prepend-top-8 m-0">{{ job.name }}</h4> - <gl-link - v-if="job.retry_path" - :class="retryButtonClass" - :href="job.retry_path" - data-method="post" - rel="nofollow" - >{{ __('Retry') }}</gl-link - > - <gl-link - v-if="job.terminal_path" - :href="job.terminal_path" - class="js-terminal-link pull-right btn btn-primary btn-inverted visible-md-block visible-lg-block" - target="_blank" - > - {{ __('Debug') }} <icon name="external-link" /> - </gl-link> + <div class="block d-flex flex-nowrap align-items-center"> + <h4 class="my-0 mr-2 text-break-word">{{ job.name }}</h4> + <div class="flex-grow-1 flex-shrink-0 text-right"> + <gl-link + v-if="job.retry_path" + :class="retryButtonClass" + :href="job.retry_path" + data-method="post" + rel="nofollow" + >{{ __('Retry') }}</gl-link + > + <gl-link + v-if="job.cancel_path" + :href="job.cancel_path" + class="js-cancel-job btn btn-default" + data-method="post" + rel="nofollow" + >{{ __('Cancel') }}</gl-link + > + </div> + <gl-button :aria-label="__('Toggle Sidebar')" type="button" @@ -137,22 +139,24 @@ export default { <i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"></i> </gl-button> </div> - <div v-if="job.retry_path || job.new_issue_path" class="block retry-link"> + + <div v-if="job.terminal_path || job.new_issue_path" class="block retry-link"> <gl-link v-if="job.new_issue_path" :href="job.new_issue_path" - class="js-new-issue btn btn-success btn-inverted" + class="js-new-issue btn btn-success btn-inverted float-left mr-2" >{{ __('New issue') }}</gl-link > <gl-link - v-if="job.retry_path" - :href="job.retry_path" - class="js-retry-job btn btn-inverted-secondary" - data-method="post" - rel="nofollow" - >{{ __('Retry') }}</gl-link + v-if="job.terminal_path" + :href="job.terminal_path" + class="js-terminal-link btn btn-primary btn-inverted visible-md-block visible-lg-block float-left" + target="_blank" > + {{ __('Debug') }} <icon name="external-link" :size="14" /> + </gl-link> </div> + <div :class="{ block: renderBlock }"> <detail-row v-if="job.duration" @@ -193,16 +197,6 @@ export default { tag }}</span> </p> - - <div v-if="job.cancel_path" class="btn-group prepend-top-5" role="group"> - <gl-link - :href="job.cancel_path" - class="js-cancel-job btn btn-sm btn-default" - data-method="post" - rel="nofollow" - >{{ __('Cancel') }}</gl-link - > - </div> </div> <artifacts-block v-if="hasArtifact" :artifact="job.artifact" /> diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index 7f79e92067f..c5076d65ff9 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -39,7 +39,9 @@ export default { <ci-icon :status="pipeline.details.status" class="vertical-align-middle" /> <span class="font-weight-bold">{{ __('Pipeline') }}</span> - <a :href="pipeline.path" class="js-pipeline-path link-commit">#{{ pipeline.id }}</a> + <a :href="pipeline.path" class="js-pipeline-path link-commit qa-pipeline-path" + >#{{ pipeline.id }}</a + > <template v-if="hasRef"> {{ __('from') }} <a :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name }}</a> @@ -55,7 +57,7 @@ export default { <ul class="dropdown-menu"> <li v-for="stage in stages" :key="stage.name"> - <button type="button" class="js-stage-item stage-item" @click="onStageClick(stage);"> + <button type="button" class="js-stage-item stage-item" @click="onStageClick(stage)"> {{ stage.name }} </button> </li> diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js index 35e92b0b5d9..73c1cbc3a99 100644 --- a/app/assets/javascripts/jobs/store/getters.js +++ b/app/assets/javascripts/jobs/store/getters.js @@ -1,22 +1,6 @@ import _ from 'underscore'; -import { __ } from '~/locale'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; -export const headerActions = state => { - if (state.job.new_issue_path) { - return [ - { - label: __('New issue'), - path: state.job.new_issue_path, - cssClass: - 'js-new-issue btn btn-success btn-inverted d-none d-md-block d-lg-block d-xl-block', - type: 'link', - }, - ]; - } - return []; -}; - export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at); export const shouldRenderCalloutMessage = state => @@ -44,6 +28,17 @@ export const emptyStateIllustration = state => export const emptyStateAction = state => (state.job && state.job.status && state.job.status.action) || null; +/** + * Shared runners limit is only rendered when + * used quota is bigger or equal than the limit + * + * @returns {Boolean} + */ +export const shouldRenderSharedRunnerLimitWarning = state => + !_.isEmpty(state.job.runners) && + !_.isEmpty(state.job.runners.quota) && + state.job.runners.quota.used >= state.job.runners.quota.limit; + export const isScrollingDown = state => isScrolledToBottom() && !state.isTraceComplete; export const hasRunnersForProject = state => diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js index 062501d1d04..f134a54dd53 100644 --- a/app/assets/javascripts/label_manager.js +++ b/app/assets/javascripts/label_manager.js @@ -70,7 +70,18 @@ export default class LabelManager { const $detachedLabel = $label.detach(); this.toggleLabelPriorityBadge($detachedLabel, action); - $detachedLabel.appendTo($target); + + const $labelEls = $target.find('li.label-list-item'); + + /* + * If there is a label element in the target, we'd want to + * append the new label just right next to it. + */ + if ($labelEls.length) { + $labelEls.last().after($detachedLabel); + } else { + $detachedLabel.appendTo($target); + } if ($from.find('li').length) { $from.find('.empty-message').removeClass('hidden'); diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index f7a611fbca0..cca4927c115 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -199,8 +199,8 @@ export default class LabelsSelect { .catch(() => flash(__('Error fetching labels.'))); }, renderRow: function(label, instance) { - var $a, - $li, + var linkEl, + listItemEl, color, colorEl, indeterminate, @@ -209,12 +209,11 @@ export default class LabelsSelect { spacing, i, marked, - dropdownName, dropdownValue; - $li = $('<li>'); - $a = $('<a href="#">'); + selectedClass = []; removesAll = label.id <= 0 || label.id == null; + if ($dropdown.hasClass('js-filter-bulk-update')) { indeterminate = $dropdown.data('indeterminate') || []; marked = $dropdown.data('marked') || []; @@ -233,7 +232,6 @@ export default class LabelsSelect { } } else { if (this.id(label)) { - dropdownName = $dropdown.data('fieldName'); dropdownValue = this.id(label) .toString() .replace(/'/g, "\\'"); @@ -241,7 +239,7 @@ export default class LabelsSelect { if ( $form.find( "input[type='hidden'][name='" + - dropdownName + + this.fieldName + "'][value='" + dropdownValue + "']", @@ -251,24 +249,34 @@ export default class LabelsSelect { } } - if ($dropdown.hasClass('js-multiselect') && removesAll) { + if (this.multiSelect && removesAll) { selectedClass.push('dropdown-clear-active'); } } + if (label.color) { colorEl = "<span class='dropdown-label-box' style='background: " + label.color + "'></span>"; } else { colorEl = ''; } + + linkEl = document.createElement('a'); + linkEl.href = '#'; + // We need to identify which items are actually labels if (label.id) { selectedClass.push('label-item'); - $a.attr('data-label-id', label.id); + linkEl.dataset.labelId = label.id; } - $a.addClass(selectedClass.join(' ')).html(`${colorEl} ${_.escape(label.title)}`); - // Return generated html - return $li.html($a).prop('outerHTML'); + + linkEl.className = selectedClass.join(' '); + linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`; + + listItemEl = document.createElement('li'); + listItemEl.appendChild(linkEl); + + return listItemEl; }, search: { fields: ['title'], diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index b8c3c237eb3..4314e5e1afb 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -11,48 +11,53 @@ function hideEndFade($scrollingTabs) { }); } +function initDeferred() { + $(document).trigger('init.scrolling-tabs'); +} + export default function initLayoutNav() { const contextualSidebar = new ContextualSidebar(); contextualSidebar.bindEvents(); initFlyOutNav(); - $(document) - .on('init.scrolling-tabs', () => { - const $scrollingTabs = $('.scrolling-tabs').not('.is-initialized'); - $scrollingTabs.addClass('is-initialized'); + // We need to init it on DomContentLoaded as others could also call it + $(document).on('init.scrolling-tabs', () => { + const $scrollingTabs = $('.scrolling-tabs').not('.is-initialized'); + $scrollingTabs.addClass('is-initialized'); - $(window) - .on('resize.nav', () => { - hideEndFade($scrollingTabs); - }) - .trigger('resize.nav'); + $(window) + .on('resize.nav', () => { + hideEndFade($scrollingTabs); + }) + .trigger('resize.nav'); - $scrollingTabs.on('scroll', function tabsScrollEvent() { - const $this = $(this); - const currentPosition = $this.scrollLeft(); - const maxPosition = $this.prop('scrollWidth') - $this.outerWidth(); + $scrollingTabs.on('scroll', function tabsScrollEvent() { + const $this = $(this); + const currentPosition = $this.scrollLeft(); + const maxPosition = $this.prop('scrollWidth') - $this.outerWidth(); - $this.siblings('.fade-left').toggleClass('scrolling', currentPosition > 0); - $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1); - }); + $this.siblings('.fade-left').toggleClass('scrolling', currentPosition > 0); + $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1); + }); - $scrollingTabs.each(function scrollTabsEachLoop() { - const $this = $(this); - const scrollingTabWidth = $this.width(); - const $active = $this.find('.active'); - const activeWidth = $active.width(); + $scrollingTabs.each(function scrollTabsEachLoop() { + const $this = $(this); + const scrollingTabWidth = $this.width(); + const $active = $this.find('.active'); + const activeWidth = $active.width(); - if ($active.length) { - const offset = $active.offset().left + activeWidth; + if ($active.length) { + const offset = $active.offset().left + activeWidth; - if (offset > scrollingTabWidth - 30) { - const scrollLeft = offset - scrollingTabWidth / 2 - activeWidth / 2; + if (offset > scrollingTabWidth - 30) { + const scrollLeft = offset - scrollingTabWidth / 2 - activeWidth / 2; - $this.scrollLeft(scrollLeft); - } + $this.scrollLeft(scrollLeft); } - }); - }) - .trigger('init.scrolling-tabs'); + } + }); + }); + + requestIdleCallback(initDeferred); } diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js index ee01a73a6e8..66f25b622e0 100644 --- a/app/assets/javascripts/lazy_loader.js +++ b/app/assets/javascripts/lazy_loader.js @@ -163,6 +163,7 @@ export default class LazyLoader { img.removeAttribute('data-src'); img.classList.remove('lazy'); img.classList.add('js-lazy-loaded'); + img.classList.add('qa-js-lazy-loaded'); } } } diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 20a0f142d9e..64e4e899f44 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -1,9 +1,11 @@ import ApolloClient from 'apollo-boost'; import csrf from '~/lib/utils/csrf'; -export default new ApolloClient({ - uri: `${gon.relative_url_root}/api/graphql`, - headers: { - [csrf.headerKey]: csrf.token, - }, -}); +export default (clientState = {}) => + new ApolloClient({ + uri: `${gon.relative_url_root}/api/graphql`, + headers: { + [csrf.headerKey]: csrf.token, + }, + clientState, + }); diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js new file mode 100644 index 00000000000..0f78756aac8 --- /dev/null +++ b/app/assets/javascripts/lib/utils/chart_utils.js @@ -0,0 +1,83 @@ +const commonTooltips = () => ({ + mode: 'x', + intersect: false, +}); + +const adjustedFontScale = () => ({ + fontSize: 8, +}); + +const yAxesConfig = (shouldAdjustFontSize = false) => ({ + yAxes: [ + { + ticks: { + beginAtZero: true, + ...(shouldAdjustFontSize ? adjustedFontScale() : {}), + }, + }, + ], +}); + +const xAxesConfig = (shouldAdjustFontSize = false) => ({ + xAxes: [ + { + ticks: { + ...(shouldAdjustFontSize ? adjustedFontScale() : {}), + }, + }, + ], +}); + +const commonChartOptions = () => ({ + responsive: true, + maintainAspectRatio: false, + legend: false, +}); + +export const barChartOptions = shouldAdjustFontSize => ({ + ...commonChartOptions(), + scales: { + ...yAxesConfig(shouldAdjustFontSize), + ...xAxesConfig(shouldAdjustFontSize), + }, + tooltips: { + ...commonTooltips(), + displayColors: false, + callbacks: { + title() { + return ''; + }, + label({ xLabel, yLabel }) { + return `${xLabel}: ${yLabel}`; + }, + }, + }, +}); + +export const pieChartOptions = commonChartOptions; + +export const lineChartOptions = ({ width, numberOfPoints, shouldAdjustFontSize }) => ({ + ...commonChartOptions(), + scales: { + ...yAxesConfig(shouldAdjustFontSize), + ...xAxesConfig(shouldAdjustFontSize), + }, + elements: { + point: { + hitRadius: width / (numberOfPoints * 2), + }, + }, + tooltips: { + ...commonTooltips(), + caretSize: 0, + multiKeyBackground: 'rgba(0,0,0,0)', + callbacks: { + labelColor({ datasetIndex }, { config }) { + return { + backgroundColor: config.data.datasets[datasetIndex].backgroundColor, + borderColor: 'rgba(0,0,0,0)', + }; + }, + }, + }, +}); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 9e22cdc04e9..1af6b63efc9 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -1,3 +1,7 @@ +/** + * @module common-utils + */ + import $ from 'jquery'; import axios from './axios_utils'; import { getLocationHash } from './url_utility'; @@ -118,14 +122,15 @@ export const handleLocationHash = () => { // Check if element scrolled into viewport from above or below // Courtesy http://stackoverflow.com/a/7557433/414749 -export const isInViewport = el => { +export const isInViewport = (el, offset = {}) => { const rect = el.getBoundingClientRect(); + const { top, left } = offset; return ( - rect.top >= 0 && - rect.left >= 0 && + rect.top >= (top || 0) && + rect.left >= (left || 0) && rect.bottom <= window.innerHeight && - rect.right <= window.innerWidth + parseInt(rect.right, 10) <= window.innerWidth ); }; @@ -216,6 +221,22 @@ export const scrollToElement = element => { }; /** + * Returns a function that can only be invoked once between + * each browser screen repaint. + * @param {Function} fn + */ +export const debounceByAnimationFrame = fn => { + let requestId; + + return function debounced(...args) { + if (requestId) { + window.cancelAnimationFrame(requestId); + } + requestId = window.requestAnimationFrame(() => fn.apply(this, args)); + }; +}; + +/** this will take in the `name` of the param you want to parse in the url if the name does not exist this function will return `null` otherwise it will return the value of the param key provided @@ -425,34 +446,26 @@ export const historyPushState = newUrl => { }; /** - * Returns true for a String "true" and false otherwise. - * This is the opposite of Boolean(...).toString() + * Returns true for a String value of "true" and false otherwise. + * This is the opposite of Boolean(...).toString(). + * `parseBoolean` is idempotent. * * @param {String} value * @returns {Boolean} */ -export const parseBoolean = value => value === 'true'; +export const parseBoolean = value => (value && value.toString()) === 'true'; /** - * Converts permission provided as strings to booleans. - * - * @param {String} string - * @returns {Boolean} + * @callback backOffCallback + * @param {Function} next + * @param {Function} stop */ -export const convertPermissionToBoolean = permission => { - if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.warn('convertPermissionToBoolean is deprecated! Please use parseBoolean instead.'); - } - - return parseBoolean(permission); -}; /** * Back Off exponential algorithm * backOff :: (Function<next, stop>, Number) -> Promise<Any, Error> * - * @param {Function<next, stop>} fn function to be called + * @param {backOffCallback} fn function to be called * @param {Number} timeout * @return {Promise<Any, Error>} * @example @@ -602,10 +615,18 @@ export const spriteIcon = (icon, className = '') => { /** * This method takes in object with snake_case property names - * and returns new object with camelCase property names + * and returns a new object with camelCase property names * * Reasoning for this method is to ensure consistent property * naming conventions across JS code. + * + * This method also supports additional params in `options` object + * + * @param {Object} obj - Object to be converted. + * @param {Object} options - Object containing additional options. + * @param {boolean} options.deep - FLag to allow deep object converting + * @param {Array[]} dropKeys - List of properties to discard while building new object + * @param {Array[]} ignoreKeyNames - List of properties to leave intact (as snake_case) while building new object */ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => { if (obj === null) { @@ -613,12 +634,26 @@ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => { } const initial = Array.isArray(obj) ? [] : {}; + const { deep = false, dropKeys = [], ignoreKeyNames = [] } = options; return Object.keys(obj).reduce((acc, prop) => { const result = acc; const val = obj[prop]; - if (options.deep && (isObject(val) || Array.isArray(val))) { + // Drop properties from new object if + // there are any mentioned in options + if (dropKeys.indexOf(prop) > -1) { + return acc; + } + + // Skip converting properties in new object + // if there are any mentioned in options + if (ignoreKeyNames.indexOf(prop) > -1) { + result[prop] = obj[prop]; + return acc; + } + + if (deep && (isObject(val) || Array.isArray(val))) { result[convertToCamelCase(prop)] = convertObjectPropsToCamelCase(val, options); } else { result[convertToCamelCase(prop)] = obj[prop]; @@ -673,6 +708,14 @@ export const NavigationType = { TYPE_RESERVED: 255, }; +/** + * Returns the value of `gon.ee` + * Used to check if it's the EE codebase or the CE one. + * + * @returns Boolean + */ +export const isEE = () => window.gon && window.gon.ee; + window.gl = window.gl || {}; window.gl.utils = { ...(window.gl.utils || {}), diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 59007d5950e..d3fe8f77bd4 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -8,6 +8,14 @@ import { languageCode, s__ } from '../../locale'; window.timeago = timeago; /** + * This method allows you to create new Date instance from existing + * date instance without keeping the reference. + * + * @param {Date} date + */ +export const newDate = date => (date instanceof Date ? new Date(date.getTime()) : new Date()); + +/** * Returns i18n month names array. * If `abbreviated` is provided, returns abbreviated * name. @@ -79,44 +87,67 @@ let timeagoInstance; */ export const getTimeago = () => { if (!timeagoInstance) { - const localeRemaining = (number, index) => - [ - [s__('Timeago|just now'), s__('Timeago|right now')], - [s__('Timeago|%s seconds ago'), s__('Timeago|%s seconds remaining')], - [s__('Timeago|1 minute ago'), s__('Timeago|1 minute remaining')], - [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')], - [s__('Timeago|1 hour ago'), s__('Timeago|1 hour remaining')], - [s__('Timeago|%s hours ago'), s__('Timeago|%s hours remaining')], - [s__('Timeago|1 day ago'), s__('Timeago|1 day remaining')], - [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')], - [s__('Timeago|1 week ago'), s__('Timeago|1 week remaining')], - [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')], - [s__('Timeago|1 month ago'), s__('Timeago|1 month remaining')], - [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')], - [s__('Timeago|1 year ago'), s__('Timeago|1 year remaining')], - [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')], - ][index]; - - const locale = (number, index) => - [ - [s__('Timeago|just now'), s__('Timeago|right now')], - [s__('Timeago|%s seconds ago'), s__('Timeago|in %s seconds')], - [s__('Timeago|1 minute ago'), s__('Timeago|in 1 minute')], - [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')], - [s__('Timeago|1 hour ago'), s__('Timeago|in 1 hour')], - [s__('Timeago|%s hours ago'), s__('Timeago|in %s hours')], - [s__('Timeago|1 day ago'), s__('Timeago|in 1 day')], - [s__('Timeago|%s days ago'), s__('Timeago|in %s days')], - [s__('Timeago|1 week ago'), s__('Timeago|in 1 week')], - [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')], - [s__('Timeago|1 month ago'), s__('Timeago|in 1 month')], - [s__('Timeago|%s months ago'), s__('Timeago|in %s months')], - [s__('Timeago|1 year ago'), s__('Timeago|in 1 year')], - [s__('Timeago|%s years ago'), s__('Timeago|in %s years')], - ][index]; - - timeago.register(timeagoLanguageCode, locale); - timeago.register(`${timeagoLanguageCode}-remaining`, localeRemaining); + const memoizedLocaleRemaining = () => { + const cache = []; + + const timeAgoLocaleRemaining = [ + () => [s__('Timeago|just now'), s__('Timeago|right now')], + () => [s__('Timeago|%s seconds ago'), s__('Timeago|%s seconds remaining')], + () => [s__('Timeago|1 minute ago'), s__('Timeago|1 minute remaining')], + () => [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')], + () => [s__('Timeago|1 hour ago'), s__('Timeago|1 hour remaining')], + () => [s__('Timeago|%s hours ago'), s__('Timeago|%s hours remaining')], + () => [s__('Timeago|1 day ago'), s__('Timeago|1 day remaining')], + () => [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')], + () => [s__('Timeago|1 week ago'), s__('Timeago|1 week remaining')], + () => [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')], + () => [s__('Timeago|1 month ago'), s__('Timeago|1 month remaining')], + () => [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')], + () => [s__('Timeago|1 year ago'), s__('Timeago|1 year remaining')], + () => [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')], + ]; + + return (number, index) => { + if (cache[index]) { + return cache[index]; + } + cache[index] = timeAgoLocaleRemaining[index] && timeAgoLocaleRemaining[index](); + return cache[index]; + }; + }; + + const memoizedLocale = () => { + const cache = []; + + const timeAgoLocale = [ + () => [s__('Timeago|just now'), s__('Timeago|right now')], + () => [s__('Timeago|%s seconds ago'), s__('Timeago|in %s seconds')], + () => [s__('Timeago|1 minute ago'), s__('Timeago|in 1 minute')], + () => [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')], + () => [s__('Timeago|1 hour ago'), s__('Timeago|in 1 hour')], + () => [s__('Timeago|%s hours ago'), s__('Timeago|in %s hours')], + () => [s__('Timeago|1 day ago'), s__('Timeago|in 1 day')], + () => [s__('Timeago|%s days ago'), s__('Timeago|in %s days')], + () => [s__('Timeago|1 week ago'), s__('Timeago|in 1 week')], + () => [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')], + () => [s__('Timeago|1 month ago'), s__('Timeago|in 1 month')], + () => [s__('Timeago|%s months ago'), s__('Timeago|in %s months')], + () => [s__('Timeago|1 year ago'), s__('Timeago|in 1 year')], + () => [s__('Timeago|%s years ago'), s__('Timeago|in %s years')], + ]; + + return (number, index) => { + if (cache[index]) { + return cache[index]; + } + cache[index] = timeAgoLocale[index] && timeAgoLocale[index](); + return cache[index]; + }; + }; + + timeago.register(timeagoLanguageCode, memoizedLocale()); + timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining()); + timeagoInstance = timeago(); } @@ -124,35 +155,28 @@ export const getTimeago = () => { }; /** - * For the given element, renders a timeago instance. - * @param {jQuery} $els - */ -export const renderTimeago = $els => { - const timeagoEls = $els || document.querySelectorAll('.js-timeago-render'); - - // timeago.js sets timeouts internally for each timeago value to be updated in real time - getTimeago().render(timeagoEls, timeagoLanguageCode); -}; - -/** * For the given elements, sets a tooltip with a formatted date. - * @param {jQuery} + * @param {JQuery} $timeagoEls * @param {Boolean} setTimeago */ export const localTimeAgo = ($timeagoEls, setTimeago = true) => { - $timeagoEls.each((i, el) => { - if (setTimeago) { + getTimeago().render($timeagoEls, timeagoLanguageCode); + + if (!setTimeago) { + return; + } + + function addTimeAgoTooltip() { + $timeagoEls.each((i, el) => { // Recreate with custom template $(el).tooltip({ template: '<div class="tooltip local-timeago" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>', }); - } - - el.classList.add('js-timeago-render'); - }); + }); + } - renderTimeago($timeagoEls); + requestIdleCallback(addTimeAgoTooltip); }; /** @@ -321,23 +345,35 @@ export const getSundays = date => { /** * Returns list of Dates representing a timeframe of months from startDate and length + * This method also supports going back in time when `length` is negative number * - * @param {Date} startDate + * @param {Date} initialStartDate * @param {Number} length */ -export const getTimeframeWindowFrom = (startDate, length) => { - if (!(startDate instanceof Date) || !length) { +export const getTimeframeWindowFrom = (initialStartDate, length) => { + if (!(initialStartDate instanceof Date) || !length) { return []; } + const startDate = newDate(initialStartDate); + const moveMonthBy = length > 0 ? 1 : -1; + + startDate.setDate(1); + startDate.setHours(0, 0, 0, 0); + // Iterate and set date for the size of length // and push date reference to timeframe list - const timeframe = new Array(length) - .fill() - .map((val, i) => new Date(startDate.getFullYear(), startDate.getMonth() + i, 1)); + const timeframe = new Array(Math.abs(length)).fill().map(() => { + const currentMonth = startDate.getTime(); + startDate.setMonth(startDate.getMonth() + moveMonthBy); + return new Date(currentMonth); + }); // Change date of last timeframe item to last date of the month - timeframe[length - 1].setDate(totalDaysInMonth(timeframe[length - 1])); + // when length is positive + if (length > 0) { + timeframe[timeframe.length - 1].setDate(totalDaysInMonth(timeframe[timeframe.length - 1])); + } return timeframe; }; diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js index b41ffb44971..82ee83e4348 100644 --- a/app/assets/javascripts/lib/utils/file_upload.js +++ b/app/assets/javascripts/lib/utils/file_upload.js @@ -1,6 +1,9 @@ export default (buttonSelector, fileSelector) => { const btn = document.querySelector(buttonSelector); const fileInput = document.querySelector(fileSelector); + + if (!btn || !fileInput) return; + const form = btn.closest('form'); btn.addEventListener('click', () => { diff --git a/app/assets/javascripts/lib/utils/grammar.js b/app/assets/javascripts/lib/utils/grammar.js new file mode 100644 index 00000000000..18f9e2ed846 --- /dev/null +++ b/app/assets/javascripts/lib/utils/grammar.js @@ -0,0 +1,40 @@ +import { sprintf, s__ } from '~/locale'; + +/** + * Combines each given item into a noun series sentence fragment. It does this + * in a way that supports i18n by giving context and punctuation to the locale + * functions. + * + * **Examples:** + * + * - `["A", "B"] => "A and B"` + * - `["A", "B", "C"] => "A, B, and C"` + * + * **Why only nouns?** + * + * Some languages need a bit more context to translate other series. + * + * @param {String[]} items + */ +export const toNounSeriesText = items => { + if (items.length === 0) { + return ''; + } else if (items.length === 1) { + return items[0]; + } else if (items.length === 2) { + return sprintf(s__('nounSeries|%{firstItem} and %{lastItem}'), { + firstItem: items[0], + lastItem: items[1], + }); + } + + return items.reduce((item, nextItem, idx) => + idx === items.length - 1 + ? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem }) + : sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }), + ); +}; + +export default { + toNounSeriesText, +}; diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index 14c02218990..37ad1676f7a 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -16,6 +16,7 @@ const httpStatusCodes = { IM_USED: 226, MULTIPLE_CHOICES: 300, BAD_REQUEST: 400, + UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, UNPROCESSABLE_ENTITY: 422, diff --git a/app/assets/javascripts/lib/utils/icon_utils.js b/app/assets/javascripts/lib/utils/icon_utils.js new file mode 100644 index 00000000000..7b8dd9bbef7 --- /dev/null +++ b/app/assets/javascripts/lib/utils/icon_utils.js @@ -0,0 +1,18 @@ +/* eslint-disable import/prefer-default-export */ + +import axios from '~/lib/utils/axios_utils'; + +/** + * Retrieve SVG icon path content from gitlab/svg sprite icons + * @param {String} name + */ +export const getSvgIconPathContent = name => + axios + .get(gon.sprite_icons) + .then(({ data: svgs }) => + new DOMParser() + .parseFromString(svgs, 'text/xml') + .querySelector(`#${name} path`) + .getAttribute('d'), + ) + .catch(() => null); diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index 2ccc51c35f7..19c4de6083d 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -80,3 +80,22 @@ export function numberToHumanSize(size) { } return `${bytesToGiB(size).toFixed(2)} GiB`; } + +/** + * A simple method that returns the value of a + b + * It seems unessesary, but when combined with a reducer it + * adds up all the values in an array. + * + * e.g. `[1, 2, 3, 4, 5].reduce(sum) // => 15` + * + * @param {Float} a + * @param {Float} b + * @example + * // return 15 + * [1, 2, 3, 4, 5].reduce(sum); + * + * // returns 6 + * Object.values([{a: 1, b: 2, c: 3].reduce(sum); + * @returns {Float} The summed value + */ +export const sum = (a = 0, b = 0) => a + b; diff --git a/app/assets/javascripts/lib/utils/poll.js b/app/assets/javascripts/lib/utils/poll.js index 198711cf427..a900ff34bf5 100644 --- a/app/assets/javascripts/lib/utils/poll.js +++ b/app/assets/javascripts/lib/utils/poll.js @@ -63,6 +63,10 @@ export default class Poll { const headers = normalizeHeaders(response.headers); const pollInterval = parseInt(headers[this.intervalHeader], 10); if (pollInterval > 0 && successCodes.indexOf(response.status) !== -1 && this.canPoll) { + if (this.timeoutID) { + clearTimeout(this.timeoutID); + } + this.timeoutID = setTimeout(() => { this.makeRequest(); }, pollInterval); @@ -101,15 +105,25 @@ export default class Poll { } /** - * Restarts polling after it has been stoped + * Enables polling after it has been stopped */ - restart(options) { - // update data + enable(options) { if (options && options.data) { this.options.data = options.data; } this.canPoll = true; + + if (options && options.response) { + this.checkConditions(options.response); + } + } + + /** + * Restarts polling after it has been stopped and makes a request + */ + restart(options) { + this.enable(options); this.makeRequest(); } } diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 1254ec798a6..84a617acb42 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -8,6 +8,10 @@ function selectedText(text, textarea) { return text.substring(textarea.selectionStart, textarea.selectionEnd); } +function addBlockTags(blockTag, selected) { + return `${blockTag}\n${selected}\n${blockTag}`; +} + function lineBefore(text, textarea) { var split; split = text @@ -24,19 +28,45 @@ function lineAfter(text, textarea) { .split('\n')[0]; } +function editorBlockTagText(text, blockTag, selected, editor) { + const lines = text.split('\n'); + const selectionRange = editor.getSelectionRange(); + const shouldRemoveBlock = + lines[selectionRange.start.row - 1] === blockTag && + lines[selectionRange.end.row + 1] === blockTag; + + if (shouldRemoveBlock) { + if (blockTag !== null) { + // ace is globally defined + // eslint-disable-next-line no-undef + const { Range } = ace.require('ace/range'); + const lastLine = lines[selectionRange.end.row + 1]; + const rangeWithBlockTags = new Range( + lines[selectionRange.start.row - 1], + 0, + selectionRange.end.row + 1, + lastLine.length, + ); + editor.getSelection().setSelectionRange(rangeWithBlockTags); + } + return selected; + } + return addBlockTags(blockTag, selected); +} + function blockTagText(text, textArea, blockTag, selected) { - const before = lineBefore(text, textArea); - const after = lineAfter(text, textArea); - if (before === blockTag && after === blockTag) { + const shouldRemoveBlock = + lineBefore(text, textArea) === blockTag && lineAfter(text, textArea) === blockTag; + + if (shouldRemoveBlock) { // To remove the block tag we have to select the line before & after if (blockTag != null) { textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1); textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1); } return selected; - } else { - return blockTag + '\n' + selected + '\n' + blockTag; } + return addBlockTags(blockTag, selected); } function moveCursor({ @@ -46,33 +76,48 @@ function moveCursor({ positionBetweenTags, removedLastNewLine, select, + editor, + editorSelectionStart, + editorSelectionEnd, }) { var pos; - if (!textArea.setSelectionRange) { + if (textArea && !textArea.setSelectionRange) { return; } if (select && select.length > 0) { - // calculate the part of the text to be selected - const startPosition = textArea.selectionStart - (tag.length - tag.indexOf(select)); - const endPosition = startPosition + select.length; - return textArea.setSelectionRange(startPosition, endPosition); - } - if (textArea.selectionStart === textArea.selectionEnd) { - if (positionBetweenTags) { - pos = textArea.selectionStart - tag.length; - } else { - pos = textArea.selectionStart; + if (textArea) { + // calculate the part of the text to be selected + const startPosition = textArea.selectionStart - (tag.length - tag.indexOf(select)); + const endPosition = startPosition + select.length; + return textArea.setSelectionRange(startPosition, endPosition); + } else if (editor) { + editor.navigateLeft(tag.length - tag.indexOf(select)); + editor.getSelection().selectAWord(); + return; } + } + if (textArea) { + if (textArea.selectionStart === textArea.selectionEnd) { + if (positionBetweenTags) { + pos = textArea.selectionStart - tag.length; + } else { + pos = textArea.selectionStart; + } - if (removedLastNewLine) { - pos -= 1; - } + if (removedLastNewLine) { + pos -= 1; + } - if (cursorOffset) { - pos -= cursorOffset; - } + if (cursorOffset) { + pos -= cursorOffset; + } - return textArea.setSelectionRange(pos, pos); + return textArea.setSelectionRange(pos, pos); + } + } else if (editor && editorSelectionStart.row === editorSelectionEnd.row) { + if (positionBetweenTags) { + editor.navigateLeft(tag.length); + } } } @@ -85,6 +130,7 @@ export function insertMarkdownText({ selected = '', wrap, select, + editor, }) { var textToInsert, selectedSplit, @@ -92,11 +138,20 @@ export function insertMarkdownText({ removedLastNewLine, removedFirstNewLine, currentLineEmpty, - lastNewLine; + lastNewLine, + editorSelectionStart, + editorSelectionEnd; removedLastNewLine = false; removedFirstNewLine = false; currentLineEmpty = false; + if (editor) { + const selectionRange = editor.getSelectionRange(); + + editorSelectionStart = selectionRange.start; + editorSelectionEnd = selectionRange.end; + } + // check for link pattern and selected text is an URL // if so fill in the url part instead of the text part of the pattern. if (tag === LINK_TAG_PATTERN) { @@ -119,14 +174,27 @@ export function insertMarkdownText({ } // Remove the last newline - if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) { - removedLastNewLine = true; - selected = selected.replace(/\n$/, ''); + if (textArea) { + if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) { + removedLastNewLine = true; + selected = selected.replace(/\n$/, ''); + } + } else if (editor) { + if (editorSelectionStart.row !== editorSelectionEnd.row) { + removedLastNewLine = true; + selected = selected.replace(/\n$/, ''); + } } selectedSplit = selected.split('\n'); - if (!wrap) { + if (editor && !wrap) { + lastNewLine = editor.getValue().split('\n')[editorSelectionStart.row]; + + if (/^\s*$/.test(lastNewLine)) { + currentLineEmpty = true; + } + } else if (textArea && !wrap) { lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n'); // Check whether the current line is empty or consists only of spaces(=handle as empty) @@ -135,13 +203,19 @@ export function insertMarkdownText({ } } - startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : ''; + const isBeginning = + (textArea && textArea.selectionStart === 0) || + (editor && editorSelectionStart.column === 0 && editorSelectionStart.row === 0); + + startChar = !wrap && !currentLineEmpty && !isBeginning ? '\n' : ''; const textPlaceholder = '{text}'; if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) { if (blockTag != null && blockTag !== '') { - textToInsert = blockTagText(text, textArea, blockTag, selected); + textToInsert = editor + ? editorBlockTagText(text, blockTag, selected, editor) + : blockTagText(text, textArea, blockTag, selected); } else { textToInsert = selectedSplit .map(function(val) { @@ -170,7 +244,11 @@ export function insertMarkdownText({ textToInsert += '\n'; } - insertText(textArea, textToInsert); + if (editor) { + editor.insert(textToInsert); + } else { + insertText(textArea, textToInsert); + } return moveCursor({ textArea, tag: tag.replace(textPlaceholder, selected), @@ -178,6 +256,9 @@ export function insertMarkdownText({ positionBetweenTags: wrap && selected.length === 0, removedLastNewLine, select, + editor, + editorSelectionStart, + editorSelectionEnd, }); } @@ -217,6 +298,25 @@ export function addMarkdownListeners(form) { }); } +export function addEditorMarkdownListeners(editor) { + $('.js-md') + .off('click') + .on('click', function(e) { + const { mdTag, mdBlock, mdPrepend, mdSelect } = $(e.currentTarget).data(); + + insertMarkdownText({ + tag: mdTag, + blockTag: mdBlock, + wrap: !mdPrepend, + select: mdSelect, + selected: editor.getSelectedText(), + text: editor.getValue(), + editor, + }); + editor.focus(); + }); +} + export function removeMarkdownListeners(form) { return $('.js-md', form).off('click'); } diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 7cc7cd6d20e..c49b1bb5a2f 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -72,6 +72,29 @@ export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3 */ export const truncateSha = sha => sha.substr(0, 8); +const ELLIPSIS_CHAR = '…'; +export const truncatePathMiddleToLength = (text, maxWidth) => { + let returnText = text; + let ellipsisCount = 0; + + while (returnText.length >= maxWidth) { + const textSplit = returnText.split('/').filter(s => s !== ELLIPSIS_CHAR); + const middleIndex = Math.floor(textSplit.length / 2); + + returnText = textSplit + .slice(0, middleIndex) + .concat( + new Array(ellipsisCount + 1).fill().map(() => ELLIPSIS_CHAR), + textSplit.slice(middleIndex + 1), + ) + .join('/'); + + ellipsisCount += 1; + } + + return returnText; +}; + /** * Capitalizes first character * diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 9850f7ce782..4ba84589705 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -42,22 +42,35 @@ export function mergeUrlParams(params, url) { return `${urlparts[1]}?${query}${urlparts[3]}`; } -export function removeParamQueryString(url, param) { - const decodedUrl = decodeURIComponent(url); - const urlVariables = decodedUrl.split('&'); - - return urlVariables.filter(variable => variable.indexOf(param) === -1).join('&'); -} - -export function removeParams(params, source = window.location.href) { - const url = document.createElement('a'); - url.href = source; +/** + * Removes specified query params from the url by returning a new url string that no longer + * includes the param/value pair. If no url is provided, `window.location.href` is used as + * the default value. + * + * @param {string[]} params - the query param names to remove + * @param {string} [url=windowLocation().href] - url from which the query param will be removed + * @returns {string} A copy of the original url but without the query param + */ +export function removeParams(params, url = window.location.href) { + const [rootAndQuery, fragment] = url.split('#'); + const [root, query] = rootAndQuery.split('?'); + + if (!query) { + return url; + } - params.forEach(param => { - url.search = removeParamQueryString(url.search, param); - }); + const encodedParams = params.map(param => encodeURIComponent(param)); + const updatedQuery = query + .split('&') + .filter(paramPair => { + const [foundParam] = paramPair.split('='); + return encodedParams.indexOf(foundParam) < 0; + }) + .join('&'); - return url.href; + const writableQuery = updatedQuery.length > 0 ? `?${updatedQuery}` : ''; + const writableFragment = fragment ? `#${fragment}` : ''; + return `${root}${writableQuery}${writableFragment}`; } export function getLocationHash(url = window.location.href) { @@ -66,6 +79,20 @@ export function getLocationHash(url = window.location.href) { return hashIndex === -1 ? null : url.substring(hashIndex + 1); } +/** + * Apply the fragment to the given url by returning a new url string that includes + * the fragment. If the given url already contains a fragment, the original fragment + * will be removed. + * + * @param {string} url - url to which the fragment will be applied + * @param {string} fragment - fragment to append + */ +export const setUrlFragment = (url, fragment) => { + const [rootUrl] = url.split('#'); + const encodedFragment = encodeURIComponent(fragment.replace(/^#/, '')); + return `${rootUrl}#${encodedFragment}`; +}; + export function visitUrl(url, external = false) { if (external) { // Simulate `target="blank" rel="noopener noreferrer"` diff --git a/app/assets/javascripts/lib/utils/webpack.js b/app/assets/javascripts/lib/utils/webpack.js new file mode 100644 index 00000000000..308ad9784e4 --- /dev/null +++ b/app/assets/javascripts/lib/utils/webpack.js @@ -0,0 +1,10 @@ +// tell webpack to load assets from origin so that web workers don't break +// eslint-disable-next-line import/prefer-default-export +export function resetServiceWorkersPublicPath() { + // __webpack_public_path__ is a global variable that can be used to adjust + // the webpack publicPath setting at runtime. + // see: https://webpack.js.org/guides/public-path/ + const relativeRootPath = (gon && gon.relative_url_root) || ''; + const webpackAssetPath = `${relativeRootPath}/assets/webpack/`; + __webpack_public_path__ = webpackAssetPath; // eslint-disable-line camelcase +} diff --git a/app/assets/javascripts/locale/sprintf.js b/app/assets/javascripts/locale/sprintf.js index 5246c49842e..68b64a3a16a 100644 --- a/app/assets/javascripts/locale/sprintf.js +++ b/app/assets/javascripts/locale/sprintf.js @@ -4,8 +4,8 @@ import _ from 'underscore'; Very limited implementation of sprintf supporting only named parameters. @param input (translated) text with parameters (e.g. '%{num_users} users use us') - @param parameters object mapping parameter names to values (e.g. { num_users: 5 }) - @param escapeParameters whether parameter values should be escaped (see http://underscorejs.org/#escape) + @param {Object} parameters object mapping parameter names to values (e.g. { num_users: 5 }) + @param {Boolean} escapeParameters whether parameter values should be escaped (see http://underscorejs.org/#escape) @returns {String} the text with parameters replaces (e.g. '5 users use us') @see https://ruby-doc.org/core-2.3.3/Kernel.html#method-i-sprintf diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index c866e8d180a..1b722c0505a 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -66,15 +66,11 @@ gl.lazyLoader = new LazyLoader({ observerNode: '#content-body', }); -document.addEventListener('DOMContentLoaded', () => { +// Put all initialisations here that can also wait after everything is rendered and ready +function deferredInitialisation() { const $body = $('body'); - const $document = $(document); - const $window = $(window); - const $sidebarGutterToggle = $('.js-sidebar-toggle'); - let bootstrapBreakpoint = bp.getBreakpointSize(); initBreadcrumbs(); - initLayoutNav(); initImporterStatus(); initTodoToggle(); initLogoAnimation(); @@ -82,35 +78,6 @@ document.addEventListener('DOMContentLoaded', () => { initUserPopovers(); if (document.querySelector('.search')) initSearchAutocomplete(); - if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' }); - - // Set the default path for all cookies to GitLab's root directory - Cookies.defaults.path = gon.relative_url_root || '/'; - - // `hashchange` is not triggered when link target is already in window.location - $body.on('click', 'a[href^="#"]', function clickHashLinkCallback() { - const href = this.getAttribute('href'); - if (href.substr(1) === getLocationHash()) { - setTimeout(handleLocationHash, 1); - } - }); - - if (bootstrapBreakpoint === 'xs') { - const $rightSidebar = $('aside.right-sidebar, .layout-page'); - - $rightSidebar.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); - } - - // prevent default action for disabled buttons - $('.btn').click(function clickDisabledButtonCallback(e) { - if ($(this).hasClass('disabled')) { - e.preventDefault(); - e.stopImmediatePropagation(); - return false; - } - - return true; - }); addSelectOnFocusBehaviour('.js-select-on-focus'); @@ -132,27 +99,30 @@ document.addEventListener('DOMContentLoaded', () => { }); // Initialize select2 selects - $('select.select2').select2({ - width: 'resolve', - dropdownAutoWidth: true, - }); - - // Close select2 on escape - $('.js-select2').on('select2-close', () => { - setTimeout(() => { - $('.select2-container-active').removeClass('select2-container-active'); - $(':focus').blur(); - }, 1); - }); + if ($('select.select2').length) { + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('select.select2').select2({ + width: 'resolve', + dropdownAutoWidth: true, + }); + + // Close select2 on escape + $('.js-select2').on('select2-close', () => { + setTimeout(() => { + $('.select2-container-active').removeClass('select2-container-active'); + $(':focus').blur(); + }, 1); + }); + }) + .catch(() => {}); + } // Initialize tooltips $body.tooltip({ selector: '.has-tooltip, [data-toggle="tooltip"]', trigger: 'hover', boundary: 'viewport', - placement(tip, el) { - return $(el).data('placement') || 'bottom'; - }, }); // Initialize popovers @@ -164,6 +134,50 @@ document.addEventListener('DOMContentLoaded', () => { viewport: '.layout-page', }); + loadAwardsHandler(); +} + +document.addEventListener('DOMContentLoaded', () => { + const $body = $('body'); + const $document = $(document); + const $window = $(window); + const $sidebarGutterToggle = $('.js-sidebar-toggle'); + let bootstrapBreakpoint = bp.getBreakpointSize(); + + if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' }); + + initLayoutNav(); + + // Set the default path for all cookies to GitLab's root directory + Cookies.defaults.path = gon.relative_url_root || '/'; + + // `hashchange` is not triggered when link target is already in window.location + $body.on('click', 'a[href^="#"]', function clickHashLinkCallback() { + const href = this.getAttribute('href'); + if (href.substr(1) === getLocationHash()) { + setTimeout(handleLocationHash, 1); + } + }); + + if (bootstrapBreakpoint === 'xs') { + const $rightSidebar = $('aside.right-sidebar, .layout-page'); + + $rightSidebar.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); + } + + // prevent default action for disabled buttons + $('.btn').click(function clickDisabledButtonCallback(e) { + if ($(this).hasClass('disabled')) { + e.preventDefault(); + e.stopImmediatePropagation(); + return false; + } + + return true; + }); + + localTimeAgo($('abbr.timeago, .js-timeago'), true); + // Form submitter $('.trigger-submit').on('change', function triggerSubmitCallback() { $(this) @@ -171,8 +185,6 @@ document.addEventListener('DOMContentLoaded', () => { .submit(); }); - localTimeAgo($('abbr.timeago, .js-timeago'), true); - // Disable form buttons while a form is submitting $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function ajaxCompleteCallback(e) { const $buttons = $('[type="submit"], .js-disable-on-submit', this); @@ -195,6 +207,10 @@ document.addEventListener('DOMContentLoaded', () => { } }); + $('.navbar-toggler').on('click', () => { + $('.header-content').toggleClass('menu-expanded'); + }); + // Commit show suppressed diff $document.on('click', '.diff-content .js-show-suppressed-diff', function showDiffCallback() { const $container = $(this).parent(); @@ -202,10 +218,6 @@ document.addEventListener('DOMContentLoaded', () => { $container.remove(); }); - $('.navbar-toggler').on('click', () => { - $('.header-content').toggleClass('menu-expanded'); - }); - // Show/hide comments on diff $body.on('click', '.js-toggle-diff-comments', function toggleDiffCommentsCallback(e) { const $this = $(this); @@ -250,8 +262,6 @@ document.addEventListener('DOMContentLoaded', () => { $window.on('resize.app', fitSidebarForSize); - loadAwardsHandler(); - $('form.filter-form').on('submit', function filterFormSubmitCallback(event) { const link = document.createElement('a'); link.href = this.action; @@ -274,4 +284,6 @@ document.addEventListener('DOMContentLoaded', () => { // initialize field errors $('.gl-show-field-errors').each((i, form) => new GlFieldErrors(form)); + + requestIdleCallback(deferredInitialisation); }); diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js index 0beedcacf33..0dabb28ea66 100644 --- a/app/assets/javascripts/member_expiration_date.js +++ b/app/assets/javascripts/member_expiration_date.js @@ -33,6 +33,7 @@ export default function memberExpirationDate(selector = '.js-access-expiration-d toggleClearInput.call($input); }, + firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate($input.val())); diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index bd263c75a3d..af2697444f2 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -16,25 +16,33 @@ export default class Members { gl.utils.disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change'); } + dropdownClicked(options) { + this.formSubmit(null, options.$el); + } + + // eslint-disable-next-line class-methods-use-this + dropdownToggleLabel(selected, $el) { + return $el.text(); + } + + // eslint-disable-next-line class-methods-use-this + dropdownIsSelectable(selected, $el) { + return !$el.hasClass('is-active'); + } + initGLDropdown() { $('.js-member-permissions-dropdown').each((i, btn) => { const $btn = $(btn); $btn.glDropdown({ selectable: true, - isSelectable(selected, $el) { - return !$el.hasClass('is-active'); - }, + isSelectable: (selected, $el) => this.dropdownIsSelectable(selected, $el), fieldName: $btn.data('fieldName'), id(selected, $el) { return $el.data('id'); }, - toggleLabel(selected, $el) { - return $el.text(); - }, - clicked: options => { - this.formSubmit(null, options.$el); - }, + toggleLabel: (selected, $el) => this.dropdownToggleLabel(selected, $el, $btn), + clicked: options => this.dropdownClicked(options), }); }); } @@ -55,6 +63,7 @@ export default class Members { $toggle.enable(); $dateInput.enable(); } + // eslint-disable-next-line class-methods-use-this getMemberListItems($el) { const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('elId')}`); diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 0deae478deb..3b42a154af8 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import { __ } from '~/locale'; +import createFlash from '~/flash'; import TaskList from './task_list'; import MergeRequestTabs from './merge_request_tabs'; import IssuablesHelper from './helpers/issuables_helper'; @@ -35,10 +36,18 @@ function MergeRequest(opts) { dataType: 'merge_request', fieldName: 'description', selector: '.detail-page-description', + lockVersion: this.$el.data('lockVersion'), onSuccess: result => { document.querySelector('#task_status').innerText = result.task_status; document.querySelector('#task_status_short').innerText = result.task_status_short; }, + onError: () => { + createFlash( + __( + 'Someone edited this merge request at the same time you did. Please refresh the page to see changes.', + ), + ); + }, }); } } diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index b0dc5697018..2f15da42271 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -428,7 +428,7 @@ export default class MergeRequestTabs { } diffViewType() { - return $('.inline-parallel-buttons button.active').data('viewType'); + return $('.js-diff-view-buttons button.active').data('viewType'); } isDiffAction(action) { diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index 12224e36ba2..41783d311ef 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -1,18 +1,25 @@ <script> -import { GlAreaChart } from '@gitlab/ui'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; +import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; +import Icon from '~/vue_shared/components/icon.vue'; +import { chartHeight, graphTypes, lineTypes } from '../../constants'; + +let debouncedResize; export default { components: { GlAreaChart, + Icon, }, + inheritAttrs: false, props: { graphData: { type: Object, required: true, validator(data) { return ( - data.queries && Array.isArray(data.queries) && data.queries.filter(query => { if (Array.isArray(query.result)) { @@ -25,19 +32,63 @@ export default { ); }, }, + containerWidth: { + type: Number, + required: true, + }, + deploymentData: { + type: Array, + required: false, + default: () => [], + }, + alertData: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + tooltip: { + title: '', + content: [], + isDeployment: false, + sha: '', + }, + width: 0, + height: chartHeight, + svgs: {}, + primaryColor: null, + }; }, computed: { chartData() { - return this.graphData.queries.reduce((accumulator, query) => { - const xLabel = `${query.unit}`; - accumulator[xLabel] = {}; - query.result.forEach(res => - res.values.forEach(v => { - accumulator[xLabel][v.time.toISOString()] = v.value; - }), - ); - return accumulator; - }, {}); + return this.graphData.queries.map(query => { + const { appearance } = query; + const lineType = + appearance && appearance.line && appearance.line.type + ? appearance.line.type + : lineTypes.default; + const lineColor = lineType === lineTypes.threshold ? this.primaryColor : undefined; + + return { + name: this.formatLegendLabel(query), + data: this.concatenateResults(query.result), + lineStyle: { + type: lineType, + color: lineColor, + }, + itemStyle: { + color: lineColor, + }, + areaStyle: { + opacity: + appearance && appearance.area && typeof appearance.area.opacity === 'number' + ? appearance.area.opacity + : undefined, + }, + }; + }); }, chartOptions() { return { @@ -45,53 +96,172 @@ export default { name: 'Time', type: 'time', axisLabel: { - formatter: date => dateFormat(date, 'h:MMtt'), + formatter: date => dateFormat(date, 'h:MM TT'), }, - nameTextStyle: { - padding: [18, 0, 0, 0], + axisPointer: { + snap: true, }, }, yAxis: { - name: this.graphData.y_label, + name: this.yAxisLabel, axisLabel: { formatter: value => value.toFixed(3), }, - nameTextStyle: { - padding: [0, 0, 36, 0], - }, }, - legend: { - formatter: this.xAxisLabel, + series: this.scatterSeries, + dataZoom: this.dataZoomConfig, + }; + }, + dataZoomConfig() { + const handleIcon = this.svgs['scroll-handle']; + + return handleIcon ? { handleIcon } : {}; + }, + earliestDatapoint() { + return this.chartData.reduce((acc, series) => { + const [[timestamp]] = series.data.sort(([a], [b]) => { + if (a < b) { + return -1; + } + return a > b ? 1 : 0; + }); + + return timestamp < acc || acc === null ? timestamp : acc; + }, null); + }, + recentDeployments() { + return this.deploymentData.reduce((acc, deployment) => { + if (deployment.created_at >= this.earliestDatapoint) { + acc.push({ + id: deployment.id, + createdAt: deployment.created_at, + sha: deployment.sha, + commitUrl: `${this.projectPath}/commit/${deployment.sha}`, + tag: deployment.tag, + tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null, + ref: deployment.ref.name, + showDeploymentFlag: false, + }); + } + + return acc; + }, []); + }, + scatterSeries() { + return { + type: graphTypes.deploymentData, + data: this.recentDeployments.map(deployment => [deployment.createdAt, 0]), + symbol: this.svgs.rocket, + symbolSize: 14, + itemStyle: { + color: this.primaryColor, }, }; }, - xAxisLabel() { - return this.graphData.queries.map(query => query.label).join(', '); + yAxisLabel() { + return `${this.graphData.y_label}`; }, }, + watch: { + containerWidth: 'onResize', + }, + beforeDestroy() { + window.removeEventListener('resize', debouncedResize); + }, + created() { + debouncedResize = debounceByAnimationFrame(this.onResize); + window.addEventListener('resize', debouncedResize); + this.setSvg('rocket'); + this.setSvg('scroll-handle'); + }, methods: { + concatenateResults(results) { + return results.reduce((acc, result) => acc.concat(result.values), []); + }, + formatLegendLabel(query) { + return `${query.label}`; + }, formatTooltipText(params) { - const [date, value] = params; - return [dateFormat(date, 'dd mmm yyyy, h:MMtt'), value.toFixed(3)]; + this.tooltip.title = dateFormat(params.value, 'dd mmm yyyy, h:MMTT'); + this.tooltip.content = []; + params.seriesData.forEach(seriesData => { + if (seriesData.componentSubType === graphTypes.deploymentData) { + this.tooltip.isDeployment = true; + const [deploy] = this.recentDeployments.filter( + deployment => deployment.createdAt === seriesData.value[0], + ); + this.tooltip.sha = deploy.sha.substring(0, 8); + } else { + const { seriesName } = seriesData; + // seriesData.value contains the chart's [x, y] value pair + // seriesData.value[1] is threfore the chart y value + const value = seriesData.value[1].toFixed(3); + + this.tooltip.content.push({ + name: seriesName, + value, + }); + } + }); + }, + setSvg(name) { + getSvgIconPathContent(name) + .then(path => { + if (path) { + this.$set(this.svgs, name, `path://${path}`); + } + }) + .catch(() => {}); + }, + onChartUpdated(chart) { + [this.primaryColor] = chart.getOption().color; }, - onCreated(chart) { - this.$emit('created', chart); + onResize() { + const { width } = this.$refs.areaChart.$el.getBoundingClientRect(); + this.width = width; }, }, }; </script> <template> - <div class="prometheus-graph"> + <div class="prometheus-graph col-12 col-lg-6"> <div class="prometheus-graph-header"> - <h5 class="prometheus-graph-title">{{ graphData.title }}</h5> - <div class="prometheus-graph-widgets"><slot></slot></div> + <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> + <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div> </div> <gl-area-chart + ref="areaChart" + v-bind="$attrs" :data="chartData" :option="chartOptions" :format-tooltip-text="formatTooltipText" - @created="onCreated" - /> + :thresholds="alertData" + :width="width" + :height="height" + @updated="onChartUpdated" + > + <template slot="tooltipTitle"> + <div v-if="tooltip.isDeployment"> + {{ __('Deployed') }} + </div> + {{ tooltip.title }} + </template> + <template slot="tooltipContent"> + <div v-if="tooltip.isDeployment" class="d-flex align-items-center"> + <icon name="commit" class="mr-2" /> + {{ tooltip.sha }} + </div> + <template v-else> + <div + v-for="(content, key) in tooltip.content" + :key="key" + class="d-flex justify-content-between" + > + {{ content.name }} {{ content.value }} + </div> + </template> + </template> + </gl-area-chart> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 2d9c5050c9b..895a57785bc 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,20 +1,19 @@ <script> -import _ from 'underscore'; import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import Flash from '../../flash'; import MonitoringService from '../services/monitoring_service'; import MonitorAreaChart from './charts/area.vue'; import GraphGroup from './graph_group.vue'; -import Graph from './graph.vue'; import EmptyState from './empty_state.vue'; import MonitoringStore from '../stores/monitoring_store'; -import eventHub from '../event_hub'; + +const sidebarAnimationDuration = 150; +let sidebarMutationObserver; export default { components: { MonitorAreaChart, - Graph, GraphGroup, EmptyState, Icon, @@ -25,21 +24,11 @@ export default { required: false, default: true, }, - showLegend: { - type: Boolean, - required: false, - default: true, - }, showPanels: { type: Boolean, required: false, default: true, }, - forceSmallGraph: { - type: Boolean, - required: false, - default: false, - }, documentationPath: { type: String, required: true, @@ -99,51 +88,38 @@ export default { store: new MonitoringStore(), state: 'gettingStarted', showEmptyState: true, - hoverData: {}, elWidth: 0, }; }, - computed: { - graphComponent() { - return gon.features && gon.features.areaChart ? MonitorAreaChart : Graph; - }, - forceRedraw() { - return this.elWidth; - }, - }, created() { this.service = new MonitoringService({ metricsEndpoint: this.metricsEndpoint, deploymentEndpoint: this.deploymentEndpoint, environmentsEndpoint: this.environmentsEndpoint, }); - this.mutationObserverConfig = { - attributes: true, - childList: false, - subtree: false, - }; - eventHub.$on('hoverChanged', this.hoverChanged); }, beforeDestroy() { - eventHub.$off('hoverChanged', this.hoverChanged); - window.removeEventListener('resize', this.resizeThrottled, false); - this.sidebarMutationObserver.disconnect(); + if (sidebarMutationObserver) { + sidebarMutationObserver.disconnect(); + } }, mounted() { - this.resizeThrottled = _.debounce(this.resize, 100); if (!this.hasMetrics) { this.state = 'gettingStarted'; } else { this.getGraphsData(); - window.addEventListener('resize', this.resizeThrottled, false); - - const sidebarEl = document.querySelector('.nav-sidebar'); - // The sidebar listener - this.sidebarMutationObserver = new MutationObserver(this.resizeThrottled); - this.sidebarMutationObserver.observe(sidebarEl, this.mutationObserverConfig); + sidebarMutationObserver = new MutationObserver(this.onSidebarMutation); + sidebarMutationObserver.observe(document.querySelector('.layout-page'), { + attributes: true, + childList: false, + subtree: false, + }); } }, methods: { + getGraphAlerts(graphId) { + return this.alertData ? this.alertData[graphId] || {} : {}; + }, getGraphsData() { this.state = 'loading'; Promise.all([ @@ -165,42 +141,40 @@ export default { this.showEmptyState = false; }) - .then(this.resize) .catch(() => { this.state = 'unableToConnect'; }); }, - resize() { - this.elWidth = this.$el.clientWidth; - }, - hoverChanged(data) { - this.hoverData = data; + onSidebarMutation() { + setTimeout(() => { + this.elWidth = this.$el.clientWidth; + }, sidebarAnimationDuration); }, }, }; </script> <template> - <div v-if="!showEmptyState" :key="forceRedraw" class="prometheus-graphs prepend-top-default"> + <div v-if="!showEmptyState" class="prometheus-graphs prepend-top-default"> <div class="environments d-flex align-items-center"> {{ s__('Metrics|Environment') }} <div class="dropdown prepend-left-10"> <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button"> - <span> {{ currentEnvironmentName }} </span> <icon name="chevron-down" /> + <span>{{ currentEnvironmentName }}</span> + <icon name="chevron-down" /> </button> <div v-if="store.environmentsData.length > 0" class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up" > <ul> - <li v-for="environment in store.environmentsData" :key="environment.latest.id"> + <li v-for="environment in store.environmentsData" :key="environment.id"> <a - :href="environment.latest.metrics_path" - :class="{ 'is-active': environment.latest.name == currentEnvironmentName }" + :href="environment.metrics_path" + :class="{ 'is-active': environment.name == currentEnvironmentName }" class="dropdown-item" + >{{ environment.name }}</a > - {{ environment.latest.name }} - </a> </li> </ul> </div> @@ -212,21 +186,15 @@ export default { :name="groupData.group" :show-panels="showPanels" > - <component - :is="graphComponent" + <monitor-area-chart v-for="(graphData, graphIndex) in groupData.metrics" :key="graphIndex" :graph-data="graphData" - :hover-data="hoverData" :deployment-data="store.deploymentData" - :project-path="projectPath" - :tags-path="tagsPath" - :show-legend="showLegend" - :small-graph="forceSmallGraph" - > - <!-- EE content --> - {{ null }} - </component> + :alert-data="getGraphAlerts(graphData.id)" + :container-width="elWidth" + group-id="monitor-area-chart" + /> </graph-group> </div> <empty-state diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue deleted file mode 100644 index 64a1df80a8e..00000000000 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ /dev/null @@ -1,329 +0,0 @@ -<script> -import { scaleLinear, scaleTime } from 'd3-scale'; -import { axisLeft, axisBottom } from 'd3-axis'; -import _ from 'underscore'; -import { max, extent } from 'd3-array'; -import { select } from 'd3-selection'; -import GraphAxis from './graph/axis.vue'; -import GraphLegend from './graph/legend.vue'; -import GraphFlag from './graph/flag.vue'; -import GraphDeployment from './graph/deployment.vue'; -import GraphPath from './graph/path.vue'; -import MonitoringMixin from '../mixins/monitoring_mixins'; -import eventHub from '../event_hub'; -import measurements from '../utils/measurements'; -import { bisectDate, timeScaleFormat } from '../utils/date_time_formatters'; -import createTimeSeries from '../utils/multiple_time_series'; -import bp from '../../breakpoints'; - -const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select }; - -export default { - components: { - GraphAxis, - GraphFlag, - GraphDeployment, - GraphPath, - GraphLegend, - }, - mixins: [MonitoringMixin], - props: { - graphData: { - type: Object, - required: true, - }, - deploymentData: { - type: Array, - required: true, - }, - hoverData: { - type: Object, - required: false, - default: () => ({}), - }, - projectPath: { - type: String, - required: true, - }, - tagsPath: { - type: String, - required: true, - }, - showLegend: { - type: Boolean, - required: false, - default: true, - }, - smallGraph: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - baseGraphHeight: 450, - baseGraphWidth: 600, - graphHeight: 450, - graphWidth: 600, - graphHeightOffset: 120, - margin: {}, - unitOfDisplay: '', - yAxisLabel: '', - legendTitle: '', - reducedDeploymentData: [], - measurements: measurements.large, - currentData: { - time: new Date(), - value: 0, - }, - currentXCoordinate: 0, - currentCoordinates: {}, - showFlag: false, - showFlagContent: false, - timeSeries: [], - graphDrawData: {}, - realPixelRatio: 1, - seriesUnderMouse: [], - }; - }, - computed: { - outerViewBox() { - return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`; - }, - innerViewBox() { - return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`; - }, - axisTransform() { - return `translate(70, ${this.graphHeight - 100})`; - }, - paddingBottomRootSvg() { - return { - paddingBottom: `${Math.ceil(this.baseGraphHeight * 100) / this.baseGraphWidth || 0}%`, - }; - }, - deploymentFlagData() { - return this.reducedDeploymentData.find(deployment => deployment.showDeploymentFlag); - }, - shouldRenderData() { - return this.graphData.queries.filter(s => s.result.length > 0).length > 0; - }, - }, - watch: { - hoverData() { - this.positionFlag(); - }, - }, - mounted() { - this.draw(); - }, - methods: { - showDot(path) { - return this.showFlagContent && this.seriesUnderMouse.includes(path); - }, - draw() { - const breakpointSize = bp.getBreakpointSize(); - const svgWidth = this.$refs.baseSvg.getBoundingClientRect().width; - - this.margin = measurements.large.margin; - - if (this.smallGraph || breakpointSize === 'xs' || breakpointSize === 'sm') { - this.graphHeight = 300; - this.margin = measurements.small.margin; - this.measurements = measurements.small; - } - - this.yAxisLabel = this.graphData.y_label || 'Values'; - this.graphWidth = svgWidth - this.margin.left - this.margin.right; - this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom; - this.baseGraphHeight = this.graphHeight - 50; - this.baseGraphWidth = this.graphWidth; - - // pixel offsets inside the svg and outside are not 1:1 - this.realPixelRatio = svgWidth / this.baseGraphWidth; - - // set the legends on the axes - const [query] = this.graphData.queries; - this.legendTitle = query ? query.label : 'Average'; - this.unitOfDisplay = query ? query.unit : ''; - - if (this.shouldRenderData) { - this.renderAxesPaths(); - this.formatDeployments(); - } - }, - handleMouseOverGraph(e) { - let point = this.$refs.graphData.createSVGPoint(); - point.x = e.clientX; - point.y = e.clientY; - point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse()); - point.x += 7; - - this.seriesUnderMouse = this.timeSeries.filter(series => { - const mouseX = series.timeSeriesScaleX.invert(point.x); - let minDistance = Infinity; - - const closestTickMark = Object.keys(this.allXAxisValues).reduce((closest, x) => { - const distance = Math.abs(Number(new Date(x)) - Number(mouseX)); - if (distance < minDistance) { - minDistance = distance; - return x; - } - return closest; - }); - - return series.values.find(v => v.time.toString() === closestTickMark); - }); - - const firstTimeSeries = this.seriesUnderMouse[0]; - const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x); - const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1); - const d0 = firstTimeSeries.values[overlayIndex - 1]; - const d1 = firstTimeSeries.values[overlayIndex]; - if (d0 === undefined || d1 === undefined) return; - const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay; - const hoveredDataIndex = evalTime ? overlayIndex : overlayIndex - 1; - const hoveredDate = firstTimeSeries.values[hoveredDataIndex].time; - const currentDeployXPos = this.mouseOverDeployInfo(point.x); - - eventHub.$emit('hoverChanged', { - hoveredDate, - currentDeployXPos, - }); - }, - renderAxesPaths() { - ({ timeSeries: this.timeSeries, graphDrawData: this.graphDrawData } = createTimeSeries( - this.graphData.queries, - this.graphWidth, - this.graphHeight, - this.graphHeightOffset, - )); - - if (_.findWhere(this.timeSeries, { renderCanary: true })) { - this.timeSeries = this.timeSeries.map(series => ({ ...series, renderCanary: true })); - } - - const axisXScale = d3.scaleTime().range([0, this.graphWidth - 70]); - const axisYScale = d3.scaleLinear().range([this.graphHeight - this.graphHeightOffset, 0]); - - const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []); - axisXScale.domain(d3.extent(allValues, d => d.time)); - axisYScale.domain([0, d3.max(allValues.map(d => d.value))]); - - this.allXAxisValues = this.timeSeries.reduce((obj, series) => { - const seriesKeys = {}; - series.values.forEach(v => { - seriesKeys[v.time] = true; - }); - return { - ...obj, - ...seriesKeys, - }; - }, {}); - - const xAxis = d3 - .axisBottom() - .scale(axisXScale) - .ticks(this.graphWidth / 120) - .tickFormat(timeScaleFormat); - - const yAxis = d3 - .axisLeft() - .scale(axisYScale) - .ticks(measurements.yTicks); - - d3.select(this.$refs.baseSvg) - .select('.x-axis') - .call(xAxis); - - const width = this.graphWidth; - d3.select(this.$refs.baseSvg) - .select('.y-axis') - .call(yAxis) - .selectAll('.tick') - .each(function createTickLines(d, i) { - if (i > 0) { - d3.select(this) - .select('line') - .attr('x2', width) - .attr('class', 'axis-tick'); - } // Avoid adding the class to the first tick, to prevent coloring - }); // This will select all of the ticks once they're rendered - }, - }, -}; -</script> - -<template> - <div - class="prometheus-graph" - @mouseover="showFlagContent = true;" - @mouseleave="showFlagContent = false;" - > - <div class="prometheus-graph-header"> - <h5 class="prometheus-graph-title">{{ graphData.title }}</h5> - <div class="prometheus-graph-widgets"><slot></slot></div> - </div> - <div :style="paddingBottomRootSvg" class="prometheus-svg-container"> - <svg ref="baseSvg" :viewBox="outerViewBox"> - <g :transform="axisTransform" class="x-axis" /> - <g class="y-axis" transform="translate(70, 20)" /> - <graph-axis - :graph-width="graphWidth" - :graph-height="graphHeight" - :margin="margin" - :measurements="measurements" - :y-axis-label="yAxisLabel" - :unit-of-display="unitOfDisplay" - /> - <svg v-if="shouldRenderData" ref="graphData" :viewBox="innerViewBox" class="graph-data"> - <slot name="additionalSvgContent" :graphDrawData="graphDrawData" /> - <graph-path - v-for="(path, index) in timeSeries" - :key="index" - :generated-line-path="path.linePath" - :generated-area-path="path.areaPath" - :line-style="path.lineStyle" - :line-color="path.lineColor" - :area-color="path.areaColor" - :current-coordinates="currentCoordinates[path.metricTag]" - :show-dot="showDot(path)" - /> - <graph-deployment - :deployment-data="reducedDeploymentData" - :graph-height="graphHeight" - :graph-height-offset="graphHeightOffset" - /> - <rect - ref="graphOverlay" - :width="graphWidth - 70" - :height="graphHeight - 100" - class="prometheus-graph-overlay" - transform="translate(-5, 20)" - @mousemove="handleMouseOverGraph($event);" - /> - </svg> - <svg v-else :viewBox="innerViewBox" class="js-no-data-to-display"> - <text x="50%" y="50%" alignment-baseline="middle" text-anchor="middle"> - {{ s__('Metrics|No data to display') }} - </text> - </svg> - </svg> - <graph-flag - v-if="shouldRenderData" - :real-pixel-ratio="realPixelRatio" - :current-x-coordinate="currentXCoordinate" - :current-data="currentData" - :graph-height="graphHeight" - :graph-height-offset="graphHeightOffset" - :show-flag-content="showFlagContent" - :time-series="seriesUnderMouse" - :unit-of-display="unitOfDisplay" - :legend-title="legendTitle" - :deployment-flag-data="deploymentFlagData" - :current-coordinates="currentCoordinates" - /> - </div> - <graph-legend v-if="showLegend" :legend-title="legendTitle" :time-series="timeSeries" /> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/axis.vue b/app/assets/javascripts/monitoring/components/graph/axis.vue deleted file mode 100644 index 8f046857a20..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/axis.vue +++ /dev/null @@ -1,118 +0,0 @@ -<script> -import { convertToSentenceCase } from '~/lib/utils/text_utility'; -import { s__ } from '~/locale'; - -export default { - props: { - graphWidth: { - type: Number, - required: true, - }, - graphHeight: { - type: Number, - required: true, - }, - margin: { - type: Object, - required: true, - }, - measurements: { - type: Object, - required: true, - }, - yAxisLabel: { - type: String, - required: true, - }, - unitOfDisplay: { - type: String, - required: true, - }, - }, - data() { - return { - yLabelWidth: 0, - yLabelHeight: 0, - }; - }, - computed: { - textTransform() { - const yCoordinate = - (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 || 0; - - return `translate(15, ${yCoordinate}) rotate(-90)`; - }, - - rectTransform() { - const yCoordinate = - (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 + - this.yLabelWidth / 2 || 0; - - return `translate(0, ${yCoordinate}) rotate(-90)`; - }, - - xPosition() { - return (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 - this.margin.right || 0; - }, - - yPosition() { - return this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset || 0; - }, - - yAxisLabelSentenceCase() { - return `${convertToSentenceCase(this.yAxisLabel)} (${this.unitOfDisplay})`; - }, - - timeString() { - return s__('PrometheusDashboard|Time'); - }, - }, - mounted() { - this.$nextTick(() => { - const bbox = this.$refs.ylabel.getBBox(); - this.yLabelWidth = bbox.width + 10; // Added some padding - this.yLabelHeight = bbox.height + 5; - }); - }, -}; -</script> -<template> - <g class="axis-label-container"> - <line - :y1="yPosition" - :x2="graphWidth + 20" - :y2="yPosition" - class="label-x-axis-line" - stroke="#000000" - stroke-width="1" - x1="10" - /> - <line - :x2="10" - :y2="yPosition" - class="label-y-axis-line" - stroke="#000000" - stroke-width="1" - x1="10" - y1="0" - /> - <rect - :transform="rectTransform" - :width="yLabelWidth" - :height="yLabelHeight" - class="rect-axis-text" - /> - <text - ref="ylabel" - :transform="textTransform" - class="label-axis-text y-label-text" - text-anchor="middle" - > - {{ yAxisLabelSentenceCase }} - </text> - <rect :x="xPosition + 60" :y="graphHeight - 80" class="rect-axis-text" width="35" height="50" /> - <text :x="xPosition + 60" :y="yPosition" class="label-axis-text x-label-text" dy=".35em"> - {{ timeString }} - </text> - </g> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/deployment.vue b/app/assets/javascripts/monitoring/components/graph/deployment.vue deleted file mode 100644 index bee9784692c..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/deployment.vue +++ /dev/null @@ -1,48 +0,0 @@ -<script> -export default { - props: { - deploymentData: { - type: Array, - required: true, - }, - graphHeight: { - type: Number, - required: true, - }, - graphHeightOffset: { - type: Number, - required: true, - }, - }, - computed: { - calculatedHeight() { - return this.graphHeight - this.graphHeightOffset; - }, - }, - methods: { - transformDeploymentGroup(deployment) { - return `translate(${Math.floor(deployment.xPos) - 5}, 20)`; - }, - }, -}; -</script> -<template> - <g class="deploy-info"> - <g - v-for="(deployment, index) in deploymentData" - :key="index" - :transform="transformDeploymentGroup(deployment)" - > - <rect :height="calculatedHeight" x="0" y="0" width="3" fill="url(#shadow-gradient)" /> - <line :y2="calculatedHeight" class="deployment-line" x1="0" y1="0" x2="0" stroke="#000" /> - </g> - <svg height="0" width="0"> - <defs> - <linearGradient id="shadow-gradient"> - <stop offset="0%" stop-color="#000" stop-opacity="0.4" /> - <stop offset="100%" stop-color="#000" stop-opacity="0" /> - </linearGradient> - </defs> - </svg> - </g> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue deleted file mode 100644 index 9d6d1caef80..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/flag.vue +++ /dev/null @@ -1,151 +0,0 @@ -<script> -import { dateFormat, timeFormat } from '../../utils/date_time_formatters'; -import { formatRelevantDigits } from '../../../lib/utils/number_utils'; -import Icon from '../../../vue_shared/components/icon.vue'; -import TrackLine from './track_line.vue'; - -export default { - components: { - Icon, - TrackLine, - }, - props: { - currentXCoordinate: { - type: Number, - required: true, - }, - currentData: { - type: Object, - required: true, - }, - deploymentFlagData: { - type: Object, - required: false, - default: null, - }, - graphHeight: { - type: Number, - required: true, - }, - graphHeightOffset: { - type: Number, - required: true, - }, - realPixelRatio: { - type: Number, - required: true, - }, - showFlagContent: { - type: Boolean, - required: true, - }, - timeSeries: { - type: Array, - required: true, - }, - unitOfDisplay: { - type: String, - required: true, - }, - legendTitle: { - type: String, - required: true, - }, - currentCoordinates: { - type: Object, - required: true, - }, - }, - computed: { - formatTime() { - return this.deploymentFlagData - ? timeFormat(this.deploymentFlagData.time) - : timeFormat(this.currentData.time); - }, - formatDate() { - return this.deploymentFlagData - ? dateFormat(this.deploymentFlagData.time) - : dateFormat(this.currentData.time); - }, - cursorStyle() { - const xCoordinate = this.deploymentFlagData - ? this.deploymentFlagData.xPos - : this.currentXCoordinate; - - const offsetTop = 20 * this.realPixelRatio; - const offsetLeft = (70 + xCoordinate) * this.realPixelRatio; - const height = (this.graphHeight - this.graphHeightOffset) * this.realPixelRatio; - - return { - top: `${offsetTop}px`, - left: `${offsetLeft}px`, - height: `${height}px`, - }; - }, - flagOrientation() { - if (this.currentXCoordinate * this.realPixelRatio > 120) { - return 'left'; - } - return 'right'; - }, - }, - methods: { - seriesMetricValue(seriesIndex, series) { - const indexFromCoordinates = this.currentCoordinates[series.metricTag] - ? this.currentCoordinates[series.metricTag].currentDataIndex - : 0; - const index = this.deploymentFlagData - ? this.deploymentFlagData.seriesIndex - : indexFromCoordinates; - const value = series.values[index] && series.values[index].value; - if (Number.isNaN(value)) { - return '-'; - } - return `${formatRelevantDigits(value)}${this.unitOfDisplay}`; - }, - seriesMetricLabel(index, series) { - if (this.timeSeries.length < 2) { - return this.legendTitle; - } - if (series.metricTag) { - return series.metricTag; - } - return `series ${index + 1}`; - }, - }, -}; -</script> - -<template> - <div :style="cursorStyle" class="prometheus-graph-cursor"> - <div v-if="showFlagContent" :class="flagOrientation" class="prometheus-graph-flag popover"> - <div class="arrow-shadow"></div> - <div class="arrow"></div> - <div class="popover-title"> - <h5 v-if="deploymentFlagData">Deployed</h5> - {{ formatDate }} <strong>{{ formatTime }}</strong> - </div> - <div v-if="deploymentFlagData" class="popover-content deploy-meta-content"> - <div> - <icon :size="12" name="commit" /> - <a :href="deploymentFlagData.commitUrl"> {{ deploymentFlagData.sha.slice(0, 8) }} </a> - </div> - <div v-if="deploymentFlagData.tag"> - <icon :size="12" name="label" /> - <a :href="deploymentFlagData.tagUrl"> {{ deploymentFlagData.ref }} </a> - </div> - </div> - <div class="popover-content"> - <table class="prometheus-table"> - <tr v-for="(series, index) in timeSeries" :key="index"> - <track-line :track="series" /> - <td>{{ series.track }} {{ seriesMetricLabel(index, series) }}</td> - <td> - <strong>{{ seriesMetricValue(index, series) }}</strong> - </td> - </tr> - </table> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue deleted file mode 100644 index b5211c306a3..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/legend.vue +++ /dev/null @@ -1,62 +0,0 @@ -<script> -import TrackLine from './track_line.vue'; -import TrackInfo from './track_info.vue'; - -export default { - components: { - TrackLine, - TrackInfo, - }, - props: { - legendTitle: { - type: String, - required: true, - }, - timeSeries: { - type: Array, - required: true, - }, - }, - methods: { - isStable(track) { - return { - 'prometheus-table-row-highlight': track.trackName !== 'Canary' && track.renderCanary, - }; - }, - }, -}; -</script> -<template> - <div class="prometheus-graph-legends prepend-left-10"> - <table class="prometheus-table"> - <tr - v-for="(series, index) in timeSeries" - v-if="series.shouldRenderLegend" - :key="index" - :class="isStable(series)" - > - <td> - <strong v-if="series.renderCanary">{{ series.trackName }}</strong> - </td> - <track-line :track="series" /> - <td v-if="timeSeries.length > 1" class="legend-metric-title"> - <track-info v-if="series.metricTag" :track="series" /> - <track-info v-else :track="series"> - <strong>{{ legendTitle }}</strong> series {{ index + 1 }} - </track-info> - </td> - <td v-else> - <track-info :track="series"> - <strong>{{ legendTitle }}</strong> - </track-info> - </td> - <template v-for="(track, trackIndex) in series.tracksLegend"> - <track-line :key="`track-line-${trackIndex}`" :track="track" /> - <td :key="`track-info-${trackIndex}`"> - <track-info :track="track" class="legend-metric-title" /> - </td> - </template> - </tr> - </table> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/path.vue b/app/assets/javascripts/monitoring/components/graph/path.vue deleted file mode 100644 index f2c237ec391..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/path.vue +++ /dev/null @@ -1,65 +0,0 @@ -<script> -export default { - props: { - generatedLinePath: { - type: String, - required: true, - }, - generatedAreaPath: { - type: String, - required: true, - }, - lineStyle: { - type: String, - required: false, - default: '', - }, - lineColor: { - type: String, - required: true, - }, - areaColor: { - type: String, - required: true, - }, - currentCoordinates: { - type: Object, - required: false, - default: () => ({ currentX: 0, currentY: 0 }), - }, - showDot: { - type: Boolean, - required: true, - }, - }, - computed: { - strokeDashArray() { - if (this.lineStyle === 'dashed') return '3, 1'; - if (this.lineStyle === 'dotted') return '1, 1'; - return null; - }, - }, -}; -</script> -<template> - <g transform="translate(-5, 20)"> - <circle - v-if="showDot" - :cx="currentCoordinates.currentX" - :cy="currentCoordinates.currentY" - :fill="lineColor" - :stroke="lineColor" - class="circle-path" - r="3" - /> - <path :d="generatedAreaPath" :fill="areaColor" class="metric-area" /> - <path - :d="generatedLinePath" - :stroke="lineColor" - :stroke-dasharray="strokeDashArray" - class="metric-line" - fill="none" - stroke-width="1" - /> - </g> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/track_info.vue b/app/assets/javascripts/monitoring/components/graph/track_info.vue deleted file mode 100644 index 3464067834f..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/track_info.vue +++ /dev/null @@ -1,28 +0,0 @@ -<script> -import { formatRelevantDigits } from '~/lib/utils/number_utils'; - -export default { - name: 'TrackInfo', - props: { - track: { - type: Object, - required: true, - }, - }, - computed: { - summaryMetrics() { - return `Avg: ${formatRelevantDigits(this.track.average)} · Max: ${formatRelevantDigits( - this.track.max, - )}`; - }, - }, -}; -</script> -<template> - <span> - <slot> - <strong> {{ track.metricTag }} </strong> - </slot> - {{ summaryMetrics }} - </span> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/track_line.vue b/app/assets/javascripts/monitoring/components/graph/track_line.vue deleted file mode 100644 index d2ed1ba113e..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/track_line.vue +++ /dev/null @@ -1,33 +0,0 @@ -<script> -export default { - name: 'TrackLine', - props: { - track: { - type: Object, - required: true, - }, - }, - computed: { - stylizedLine() { - if (this.track.lineStyle === 'dashed') return '6, 3'; - if (this.track.lineStyle === 'dotted') return '3, 3'; - return null; - }, - }, -}; -</script> -<template> - <td> - <svg width="16" height="8"> - <line - :stroke-dasharray="stylizedLine" - :stroke="track.lineColor" - :x1="0" - :x2="16" - :y1="4" - :y2="4" - stroke-width="4" - /> - </svg> - </td> -</template> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js new file mode 100644 index 00000000000..869173b6572 --- /dev/null +++ b/app/assets/javascripts/monitoring/constants.js @@ -0,0 +1,10 @@ +export const chartHeight = 300; + +export const graphTypes = { + deploymentData: 'scatter', +}; + +export const lineTypes = { + default: 'solid', + threshold: 'dashed', +}; diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js deleted file mode 100644 index 87c3d969de4..00000000000 --- a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js +++ /dev/null @@ -1,86 +0,0 @@ -import { bisectDate } from '../utils/date_time_formatters'; - -const mixins = { - methods: { - mouseOverDeployInfo(mouseXPos) { - if (!this.reducedDeploymentData) return false; - - let dataFound = false; - this.reducedDeploymentData = this.reducedDeploymentData.map(d => { - const deployment = d; - if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) { - dataFound = d.xPos + 1; - - deployment.showDeploymentFlag = true; - } else { - deployment.showDeploymentFlag = false; - } - return deployment; - }); - - return dataFound; - }, - - formatDeployments() { - this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => { - const time = new Date(deployment.created_at); - const xPos = Math.floor(this.timeSeries[0].timeSeriesScaleX(time)); - - time.setSeconds(this.timeSeries[0].values[0].time.getSeconds()); - - if (xPos >= 0) { - const seriesIndex = bisectDate(this.timeSeries[0].values, time, 1); - - deploymentDataArray.push({ - id: deployment.id, - time, - sha: deployment.sha, - commitUrl: `${this.projectPath}/commit/${deployment.sha}`, - tag: deployment.tag, - tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null, - ref: deployment.ref.name, - xPos, - seriesIndex, - showDeploymentFlag: false, - }); - } - - return deploymentDataArray; - }, []); - }, - - positionFlag() { - const timeSeries = this.seriesUnderMouse[0]; - if (!timeSeries) { - return; - } - const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate); - - this.currentData = timeSeries.values[hoveredDataIndex]; - this.currentXCoordinate = Math.floor(timeSeries.timeSeriesScaleX(this.currentData.time)); - - this.currentCoordinates = {}; - - this.seriesUnderMouse.forEach(series => { - const currentDataIndex = bisectDate(series.values, this.hoverData.hoveredDate); - const currentData = series.values[currentDataIndex]; - const currentX = Math.floor(series.timeSeriesScaleX(currentData.time)); - const currentY = Math.floor(series.timeSeriesScaleY(currentData.value)); - - this.currentCoordinates[series.metricTag] = { - currentX, - currentY, - currentDataIndex, - }; - }); - - if (this.hoverData.currentDeployXPos) { - this.showFlag = false; - } else { - this.showFlag = true; - } - }, - }, -}; - -export default mixins; diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js index 8692c873a41..70635059bd9 100644 --- a/app/assets/javascripts/monitoring/stores/monitoring_store.js +++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js @@ -13,7 +13,7 @@ function checkQueryEmptyData(query) { result: query.result.filter(timeSeries => { const newTimeSeries = timeSeries; const hasValue = series => - !Number.isNaN(series.value) && (series.value !== null || series.value !== undefined); + !Number.isNaN(series[1]) && (series[1] !== null || series[1] !== undefined); const hasNonNullValue = timeSeries.values.find(hasValue); newTimeSeries.values = hasNonNullValue ? newTimeSeries.values : []; @@ -33,10 +33,10 @@ function normalizeMetrics(metrics) { ...query, result: query.result.map(result => ({ ...result, - values: result.values.map(([timestamp, value]) => ({ - time: new Date(timestamp * 1000), - value: Number(value), - })), + values: result.values.map(([timestamp, value]) => [ + new Date(timestamp * 1000).toISOString(), + Number(value), + ]), })), })); @@ -66,9 +66,7 @@ export default class MonitoringStore { } storeEnvironmentsData(environmentsData = []) { - this.environmentsData = environmentsData.filter( - environment => !!environment.latest.last_deployment, - ); + this.environmentsData = environmentsData.filter(environment => !!environment.last_deployment); } getMetricsCount() { diff --git a/app/assets/javascripts/monitoring/utils/date_time_formatters.js b/app/assets/javascripts/monitoring/utils/date_time_formatters.js deleted file mode 100644 index d88c13609dc..00000000000 --- a/app/assets/javascripts/monitoring/utils/date_time_formatters.js +++ /dev/null @@ -1,42 +0,0 @@ -import { timeFormat as time } from 'd3-time-format'; -import { timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear } from 'd3-time'; -import { bisector } from 'd3-array'; - -const d3 = { - time, - bisector, - timeSecond, - timeMinute, - timeHour, - timeDay, - timeWeek, - timeMonth, - timeYear, -}; - -export const dateFormat = d3.time('%d %b %Y, '); -export const timeFormat = d3.time('%-I:%M%p'); -export const dateFormatWithName = d3.time('%a, %b %-d'); -export const bisectDate = d3.bisector(d => d.time).left; - -export function timeScaleFormat(date) { - let formatFunction; - if (d3.timeSecond(date) < date) { - formatFunction = d3.time('.%L'); - } else if (d3.timeMinute(date) < date) { - formatFunction = d3.time(':%S'); - } else if (d3.timeHour(date) < date) { - formatFunction = d3.time('%-I:%M'); - } else if (d3.timeDay(date) < date) { - formatFunction = d3.time('%-I %p'); - } else if (d3.timeWeek(date) < date) { - formatFunction = d3.time('%a %d'); - } else if (d3.timeMonth(date) < date) { - formatFunction = d3.time('%b %d'); - } else if (d3.timeYear(date) < date) { - formatFunction = d3.time('%B'); - } else { - formatFunction = d3.time('%Y'); - } - return formatFunction(date); -} diff --git a/app/assets/javascripts/monitoring/utils/measurements.js b/app/assets/javascripts/monitoring/utils/measurements.js deleted file mode 100644 index 7c771f43eee..00000000000 --- a/app/assets/javascripts/monitoring/utils/measurements.js +++ /dev/null @@ -1,44 +0,0 @@ -export default { - small: { - // Covers both xs and sm screen sizes - margin: { - top: 40, - right: 40, - bottom: 50, - left: 40, - }, - legends: { - width: 15, - height: 3, - offsetX: 20, - offsetY: 32, - }, - backgroundLegend: { - width: 30, - height: 50, - }, - axisLabelLineOffset: -20, - }, - large: { - // This covers both md and lg screen sizes - margin: { - top: 80, - right: 80, - bottom: 100, - left: 80, - }, - legends: { - width: 15, - height: 3, - offsetX: 20, - offsetY: 34, - }, - backgroundLegend: { - width: 30, - height: 150, - }, - axisLabelLineOffset: 20, - }, - xTicks: 8, - yTicks: 3, -}; diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js deleted file mode 100644 index 50ba14dfb2e..00000000000 --- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js +++ /dev/null @@ -1,223 +0,0 @@ -import _ from 'underscore'; -import { scaleLinear, scaleTime } from 'd3-scale'; -import { line, area, curveLinear } from 'd3-shape'; -import { extent, max, sum } from 'd3-array'; -import { timeMinute, timeSecond } from 'd3-time'; -import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; - -const d3 = { - scaleLinear, - scaleTime, - line, - area, - curveLinear, - extent, - max, - timeMinute, - timeSecond, - sum, -}; - -const defaultColorPalette = { - blue: ['#1f78d1', '#8fbce8'], - orange: ['#fc9403', '#feca81'], - red: ['#db3b21', '#ed9d90'], - green: ['#1aaa55', '#8dd5aa'], - purple: ['#6666c4', '#d1d1f0'], -}; - -const defaultColorOrder = ['blue', 'orange', 'red', 'green', 'purple']; - -const defaultStyleOrder = ['solid', 'dashed', 'dotted']; - -function queryTimeSeries(query, graphDrawData, lineStyle) { - let usedColors = []; - let renderCanary = false; - const timeSeriesParsed = []; - - function pickColor(name) { - let pick; - if (name && defaultColorPalette[name]) { - pick = name; - } else { - const unusedColors = _.difference(defaultColorOrder, usedColors); - if (unusedColors.length > 0) { - [pick] = unusedColors; - } else { - usedColors = []; - [pick] = defaultColorOrder; - } - } - usedColors.push(pick); - return defaultColorPalette[pick]; - } - - function findByDate(series, time) { - const val = series.find(v => Math.abs(d3.timeSecond.count(time, v.time)) < 60); - if (val) { - return val.value; - } - return NaN; - } - - // The timeseries data may have gaps in it - // but we need a regularly-spaced set of time/value pairs - // this gives us a complete range of one minute intervals - // offset the same amount as the original data - const [minX, maxX] = graphDrawData.xDom; - const offset = d3.timeMinute(minX) - Number(minX); - const datesWithoutGaps = d3.timeSecond - .every(60) - .range(d3.timeMinute.offset(minX, -1), maxX) - .map(d => d - offset); - - query.result.forEach((timeSeries, timeSeriesNumber) => { - let metricTag = ''; - let lineColor = ''; - let areaColor = ''; - let shouldRenderLegend = true; - const timeSeriesValues = timeSeries.values.map(d => d.value); - const maximumValue = d3.max(timeSeriesValues); - const accum = d3.sum(timeSeriesValues); - const trackName = capitalizeFirstCharacter(query.track ? query.track : 'Stable'); - - if (trackName === 'Canary') { - renderCanary = true; - } - - const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]]; - const seriesCustomizationData = - query.series != null && _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel }); - - if (seriesCustomizationData) { - metricTag = seriesCustomizationData.value || timeSeriesMetricLabel; - [lineColor, areaColor] = pickColor(seriesCustomizationData.color); - if (timeSeriesParsed.length > 0) { - shouldRenderLegend = false; - } else { - shouldRenderLegend = true; - } - } else { - metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`; - [lineColor, areaColor] = pickColor(); - if (timeSeriesParsed.length > 1) { - shouldRenderLegend = false; - } - } - - const values = datesWithoutGaps.map(time => ({ - time, - value: findByDate(timeSeries.values, time), - })); - - timeSeriesParsed.push({ - linePath: graphDrawData.lineFunction(values), - areaPath: graphDrawData.areaBelowLine(values), - timeSeriesScaleX: graphDrawData.timeSeriesScaleX, - timeSeriesScaleY: graphDrawData.timeSeriesScaleY, - values: timeSeries.values, - max: maximumValue, - average: accum / timeSeries.values.length, - lineStyle, - lineColor, - areaColor, - metricTag, - trackName, - shouldRenderLegend, - renderCanary, - }); - - if (!shouldRenderLegend) { - if (!timeSeriesParsed[0].tracksLegend) { - timeSeriesParsed[0].tracksLegend = []; - } - timeSeriesParsed[0].tracksLegend.push({ - max: maximumValue, - average: accum / timeSeries.values.length, - lineStyle, - lineColor, - metricTag, - }); - } - }); - - return timeSeriesParsed; -} - -function xyDomain(queries) { - const allValues = queries.reduce( - (allQueryResults, query) => - allQueryResults.concat( - query.result.reduce((allResults, result) => allResults.concat(result.values), []), - ), - [], - ); - - const xDom = d3.extent(allValues, d => d.time); - const yDom = [0, d3.max(allValues.map(d => d.value))]; - - return { - xDom, - yDom, - }; -} - -export function generateGraphDrawData(queries, graphWidth, graphHeight, graphHeightOffset) { - const { xDom, yDom } = xyDomain(queries); - - const timeSeriesScaleX = d3.scaleTime().range([0, graphWidth - 70]); - const timeSeriesScaleY = d3.scaleLinear().range([graphHeight - graphHeightOffset, 0]); - - timeSeriesScaleX.domain(xDom); - timeSeriesScaleX.ticks(d3.timeMinute, 60); - timeSeriesScaleY.domain(yDom); - - const defined = d => !Number.isNaN(d.value) && d.value != null; - - const lineFunction = d3 - .line() - .defined(defined) - .curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate - .x(d => timeSeriesScaleX(d.time)) - .y(d => timeSeriesScaleY(d.value)); - - const areaBelowLine = d3 - .area() - .defined(defined) - .curve(d3.curveLinear) - .x(d => timeSeriesScaleX(d.time)) - .y0(graphHeight - graphHeightOffset) - .y1(d => timeSeriesScaleY(d.value)); - - const areaAboveLine = d3 - .area() - .defined(defined) - .curve(d3.curveLinear) - .x(d => timeSeriesScaleX(d.time)) - .y0(0) - .y1(d => timeSeriesScaleY(d.value)); - - return { - lineFunction, - areaBelowLine, - areaAboveLine, - xDom, - yDom, - timeSeriesScaleX, - timeSeriesScaleY, - }; -} - -export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) { - const graphDrawData = generateGraphDrawData(queries, graphWidth, graphHeight, graphHeightOffset); - - const timeSeries = queries.reduce((series, query, index) => { - const lineStyle = defaultStyleOrder[index % defaultStyleOrder.length]; - return series.concat(queryTimeSeries(query, graphDrawData, lineStyle)); - }, []); - - return { - timeSeries, - graphDrawData, - }; -} diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index e4d72eb8318..8eccba07c38 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -1,82 +1,22 @@ -import $ from 'jquery'; import Vue from 'vue'; -import { mapActions, mapState, mapGetters } from 'vuex'; +import store from 'ee_else_ce/mr_notes/stores'; +import initNotesApp from './init_notes'; import initDiffsApp from '../diffs'; -import notesApp from '../notes/components/notes_app.vue'; import discussionCounter from '../notes/components/discussion_counter.vue'; import initDiscussionFilters from '../notes/discussion_filters'; -import store from './stores'; import MergeRequest from '../merge_request'; +import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; export default function initMrNotes() { + resetServiceWorkersPublicPath(); + const mrShowNode = document.querySelector('.merge-request'); // eslint-disable-next-line no-new new MergeRequest({ action: mrShowNode.dataset.mrAction, }); - // eslint-disable-next-line no-new - new Vue({ - el: '#js-vue-mr-discussions', - name: 'MergeRequestDiscussions', - components: { - notesApp, - }, - store, - data() { - const notesDataset = document.getElementById('js-vue-mr-discussions').dataset; - const noteableData = JSON.parse(notesDataset.noteableData); - noteableData.noteableType = notesDataset.noteableType; - noteableData.targetType = notesDataset.targetType; - - return { - noteableData, - currentUserData: JSON.parse(notesDataset.currentUserData), - notesData: JSON.parse(notesDataset.notesData), - helpPagePath: notesDataset.helpPagePath, - }; - }, - computed: { - ...mapGetters(['discussionTabCounter']), - ...mapState({ - activeTab: state => state.page.activeTab, - }), - }, - watch: { - discussionTabCounter() { - this.updateDiscussionTabCounter(); - }, - }, - created() { - this.setActiveTab(window.mrTabs.getCurrentAction()); - }, - mounted() { - this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'); - $(document).on('visibilitychange', this.updateDiscussionTabCounter); - window.mrTabs.eventHub.$on('MergeRequestTabChange', this.setActiveTab); - }, - beforeDestroy() { - $(document).off('visibilitychange', this.updateDiscussionTabCounter); - window.mrTabs.eventHub.$off('MergeRequestTabChange', this.setActiveTab); - }, - methods: { - ...mapActions(['setActiveTab']), - updateDiscussionTabCounter() { - this.notesCountBadge.text(this.discussionTabCounter); - }, - }, - render(createElement) { - return createElement('notes-app', { - props: { - noteableData: this.noteableData, - notesData: this.notesData, - userData: this.currentUserData, - shouldShow: this.activeTab === 'show', - helpPagePath: this.helpPagePath, - }, - }); - }, - }); + initNotesApp(); // eslint-disable-next-line no-new new Vue({ diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js new file mode 100644 index 00000000000..842a209a545 --- /dev/null +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -0,0 +1,70 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import store from 'ee_else_ce/mr_notes/stores'; +import notesApp from '../notes/components/notes_app.vue'; + +export default () => { + // eslint-disable-next-line no-new + new Vue({ + el: '#js-vue-mr-discussions', + name: 'MergeRequestDiscussions', + components: { + notesApp, + }, + store, + data() { + const notesDataset = document.getElementById('js-vue-mr-discussions').dataset; + const noteableData = JSON.parse(notesDataset.noteableData); + noteableData.noteableType = notesDataset.noteableType; + noteableData.targetType = notesDataset.targetType; + + return { + noteableData, + currentUserData: JSON.parse(notesDataset.currentUserData), + notesData: JSON.parse(notesDataset.notesData), + helpPagePath: notesDataset.helpPagePath, + }; + }, + computed: { + ...mapGetters(['discussionTabCounter']), + ...mapState({ + activeTab: state => state.page.activeTab, + }), + }, + watch: { + discussionTabCounter() { + this.updateDiscussionTabCounter(); + }, + }, + created() { + this.setActiveTab(window.mrTabs.getCurrentAction()); + }, + mounted() { + this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'); + $(document).on('visibilitychange', this.updateDiscussionTabCounter); + window.mrTabs.eventHub.$on('MergeRequestTabChange', this.setActiveTab); + }, + beforeDestroy() { + $(document).off('visibilitychange', this.updateDiscussionTabCounter); + window.mrTabs.eventHub.$off('MergeRequestTabChange', this.setActiveTab); + }, + methods: { + ...mapActions(['setActiveTab']), + updateDiscussionTabCounter() { + this.notesCountBadge.text(this.discussionTabCounter); + }, + }, + render(createElement) { + return createElement('notes-app', { + props: { + noteableData: this.noteableData, + notesData: this.notesData, + userData: this.currentUserData, + shouldShow: this.activeTab === 'show', + helpPagePath: this.helpPagePath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/notebook/cells/code.vue b/app/assets/javascripts/notebook/cells/code.vue index bd6736152f5..eefc801ed7a 100644 --- a/app/assets/javascripts/notebook/cells/code.vue +++ b/app/assets/javascripts/notebook/cells/code.vue @@ -1,11 +1,12 @@ <script> -import CodeCell from './code/index.vue'; +import CodeOutput from './code/index.vue'; import OutputCell from './output/index.vue'; export default { + name: 'CodeCell', components: { - 'code-cell': CodeCell, - 'output-cell': OutputCell, + CodeOutput, + OutputCell, }, props: { cell: { @@ -29,8 +30,8 @@ export default { hasOutput() { return this.cell.outputs.length; }, - output() { - return this.cell.outputs[0]; + outputs() { + return this.cell.outputs; }, }, }; @@ -38,7 +39,7 @@ export default { <template> <div class="cell"> - <code-cell + <code-output :raw-code="rawInputCode" :count="cell.execution_count" :code-css-class="codeCssClass" @@ -47,7 +48,7 @@ export default { <output-cell v-if="hasOutput" :count="cell.execution_count" - :output="output" + :outputs="outputs" :code-css-class="codeCssClass" /> </div> diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue index 8bf2431c4c6..98b6cdd0944 100644 --- a/app/assets/javascripts/notebook/cells/code/index.vue +++ b/app/assets/javascripts/notebook/cells/code/index.vue @@ -3,8 +3,9 @@ import Prism from '../../lib/highlight'; import Prompt from '../prompt.vue'; export default { + name: 'CodeOutput', components: { - prompt: Prompt, + Prompt, }, props: { count: { diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue index c6fc786fa76..8dc2d73af9b 100644 --- a/app/assets/javascripts/notebook/cells/output/html.vue +++ b/app/assets/javascripts/notebook/cells/output/html.vue @@ -4,13 +4,21 @@ import Prompt from '../prompt.vue'; export default { components: { - prompt: Prompt, + Prompt, }, props: { + count: { + type: Number, + required: true, + }, rawCode: { type: String, required: true, }, + index: { + type: Number, + required: true, + }, }, computed: { sanitizedOutput() { @@ -21,13 +29,16 @@ export default { }, }); }, + showOutput() { + return this.index === 0; + }, }, }; </script> <template> <div class="output"> - <prompt /> + <prompt type="Out" :count="count" :show-output="showOutput" /> <div v-html="sanitizedOutput"></div> </div> </template> diff --git a/app/assets/javascripts/notebook/cells/output/image.vue b/app/assets/javascripts/notebook/cells/output/image.vue index fe8c81398fb..f1130275525 100644 --- a/app/assets/javascripts/notebook/cells/output/image.vue +++ b/app/assets/javascripts/notebook/cells/output/image.vue @@ -6,6 +6,10 @@ export default { prompt: Prompt, }, props: { + count: { + type: Number, + required: true, + }, outputType: { type: String, required: true, @@ -14,10 +18,24 @@ export default { type: String, required: true, }, + index: { + type: Number, + required: true, + }, + }, + computed: { + imgSrc() { + return `data:${this.outputType};base64,${this.rawCode}`; + }, + showOutput() { + return this.index === 0; + }, }, }; </script> <template> - <div class="output"><prompt /> <img :src="'data:' + outputType + ';base64,' + rawCode" /></div> + <div class="output"> + <prompt type="out" :count="count" :show-output="showOutput" /> <img :src="imgSrc" /> + </div> </template> diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue index bd0bcc0d819..c5ae7e7ee10 100644 --- a/app/assets/javascripts/notebook/cells/output/index.vue +++ b/app/assets/javascripts/notebook/cells/output/index.vue @@ -1,14 +1,9 @@ <script> -import CodeCell from '../code/index.vue'; -import Html from './html.vue'; -import Image from './image.vue'; +import CodeOutput from '../code/index.vue'; +import HtmlOutput from './html.vue'; +import ImageOutput from './image.vue'; export default { - components: { - 'code-cell': CodeCell, - 'html-output': Html, - 'image-output': Image, - }, props: { codeCssClass: { type: String, @@ -20,68 +15,69 @@ export default { required: false, default: 0, }, - output: { - type: Object, + outputs: { + type: Array, required: true, - default: () => ({}), }, }, - computed: { - componentName() { - if (this.output.text) { - return 'code-cell'; - } else if (this.output.data['image/png']) { - return 'image-output'; - } else if (this.output.data['text/html']) { - return 'html-output'; - } else if (this.output.data['image/svg+xml']) { - return 'html-output'; - } + data() { + return { + outputType: '', + }; + }, + methods: { + dataForType(output, type) { + let data = output.data[type]; - return 'code-cell'; - }, - rawCode() { - if (this.output.text) { - return this.output.text.join(''); + if (typeof data === 'object') { + data = data.join(''); } - return this.dataForType(this.outputType); + return data; }, - outputType() { - if (this.output.text) { - return ''; - } else if (this.output.data['image/png']) { - return 'image/png'; - } else if (this.output.data['text/html']) { - return 'text/html'; - } else if (this.output.data['image/svg+xml']) { - return 'image/svg+xml'; + getComponent(output) { + if (output.text) { + return CodeOutput; + } else if (output.data['image/png']) { + this.outputType = 'image/png'; + + return ImageOutput; + } else if (output.data['text/html']) { + this.outputType = 'text/html'; + + return HtmlOutput; + } else if (output.data['image/svg+xml']) { + this.outputType = 'image/svg+xml'; + + return HtmlOutput; } - return 'text/plain'; + this.outputType = 'text/plain'; + return CodeOutput; }, - }, - methods: { - dataForType(type) { - let data = this.output.data[type]; - - if (typeof data === 'object') { - data = data.join(''); + rawCode(output) { + if (output.text) { + return output.text.join(''); } - return data; + return this.dataForType(output, this.outputType); }, }, }; </script> <template> - <component - :is="componentName" - :output-type="outputType" - :count="count" - :raw-code="rawCode" - :code-css-class="codeCssClass" - type="output" - /> + <div> + <component + :is="getComponent(output)" + v-for="(output, index) in outputs" + :key="index" + type="output" + :output-type="outputType" + :count="count" + :index="index" + :raw-code="rawCode(output)" + :code-css-class="codeCssClass" + /> + </div> </template> diff --git a/app/assets/javascripts/notebook/cells/prompt.vue b/app/assets/javascripts/notebook/cells/prompt.vue index 3f1f239a806..1eeb61844a4 100644 --- a/app/assets/javascripts/notebook/cells/prompt.vue +++ b/app/assets/javascripts/notebook/cells/prompt.vue @@ -11,18 +11,26 @@ export default { required: false, default: 0, }, + showOutput: { + type: Boolean, + required: false, + default: true, + }, }, computed: { hasKeys() { return this.type !== '' && this.count; }, + showTypeText() { + return this.type && this.count && this.showOutput; + }, }, }; </script> <template> <div class="prompt"> - <span v-if="hasKeys"> {{ type }} [{{ count }}]: </span> + <span v-if="showTypeText"> {{ type }} [{{ count }}]: </span> </div> </template> diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue index 6a54d0b3823..e7056c03e4a 100644 --- a/app/assets/javascripts/notebook/index.vue +++ b/app/assets/javascripts/notebook/index.vue @@ -3,8 +3,8 @@ import { MarkdownCell, CodeCell } from './cells'; export default { components: { - 'code-cell': CodeCell, - 'markdown-cell': MarkdownCell, + CodeCell, + MarkdownCell, }, props: { notebook: { diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index dfb53c986fc..c9c01354333 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -138,8 +138,6 @@ export default class Notes { this.$wrapperEl.on('click', '.js-note-delete', this.removeNote); // delete note attachment this.$wrapperEl.on('click', '.js-note-attachment-delete', this.removeAttachment); - // reset main target form when clicking discard - this.$wrapperEl.on('click', '.js-note-discard', this.resetMainTargetForm); // update the file name when an attachment is selected this.$wrapperEl.on('change', '.js-note-attachment-input', this.updateFormAttachment); // reply to diff/discussion notes @@ -191,7 +189,6 @@ export default class Notes { this.$wrapperEl.off('keyup input', '.js-note-text'); this.$wrapperEl.off('click', '.js-note-target-reopen'); this.$wrapperEl.off('click', '.js-note-target-close'); - this.$wrapperEl.off('click', '.js-note-discard'); this.$wrapperEl.off('keydown', '.js-note-text'); this.$wrapperEl.off('click', '.js-comment-resolve-button'); this.$wrapperEl.off('click', '.system-note-commit-list-toggler'); @@ -986,11 +983,9 @@ export default class Notes { form.find('#note_position').val(dataHolder.attr('data-position')); form - .find('.js-note-discard') + .find('.js-close-discussion-note-form') .show() - .removeClass('js-note-discard') - .addClass('js-close-discussion-note-form') - .text(form.find('.js-close-discussion-note-form').data('cancelText')); + .removeClass('hide'); form.find('.js-note-target-close').remove(); form.find('.js-note-new-discussion').remove(); this.setupNoteForm(form); @@ -1194,12 +1189,11 @@ export default class Notes { } updateTargetButtons(e) { - var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea; + var closebtn, closetext, form, reopenbtn, reopentext, textarea; textarea = $(e.target); form = textarea.parents('form'); reopenbtn = form.find('.js-note-target-reopen'); closebtn = form.find('.js-note-target-close'); - discardbtn = form.find('.js-note-discard'); if (textarea.val().trim().length > 0) { reopentext = reopenbtn.attr('data-alternative-text'); @@ -1216,9 +1210,6 @@ export default class Notes { if (closebtn.is(':not(.btn-comment-and-close)')) { closebtn.addClass('btn-comment-and-close'); } - if (discardbtn.is(':hidden')) { - return discardbtn.show(); - } } else { reopentext = reopenbtn.data('originalText'); closetext = closebtn.data('originalText'); @@ -1234,9 +1225,6 @@ export default class Notes { if (closebtn.is('.btn-comment-and-close')) { closebtn.removeClass('btn-comment-and-close'); } - if (discardbtn.is(':visible')) { - return discardbtn.hide(); - } } } @@ -1251,15 +1239,13 @@ export default class Notes { var postUrl = $originalContentEl.data('postUrl'); var targetId = $originalContentEl.data('targetId'); var targetType = $originalContentEl.data('targetType'); - var markdownVersion = $originalContentEl.data('markdownVersion'); this.glForm = new GLForm($editForm.find('form'), this.enableGFM); $editForm .find('form') .attr('action', `${postUrl}?html=true`) - .attr('data-remote', 'true') - .attr('data-markdown-version', markdownVersion); + .attr('data-remote', 'true'); $editForm.find('.js-form-target-id').val(targetId); $editForm.find('.js-form-target-type').val(targetType); $editForm diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index ce56beb1e6b..1d6cb9485f7 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -39,11 +39,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, }, data() { return { @@ -342,7 +337,6 @@ Please check your network connection and try again.`; :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" - :markdown-version="markdownVersion" :add-spacing-classes="false" > <textarea @@ -357,9 +351,9 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" data-supports-quick-actions="true" aria-label="Description" placeholder="Write a comment or drag your files here…" - @keydown.up="editCurrentUserLastNote();" - @keydown.meta.enter="handleSave();" - @keydown.ctrl.enter="handleSave();" + @keydown.up="editCurrentUserLastNote()" + @keydown.meta.enter="handleSave()" + @keydown.ctrl.enter="handleSave()" > </textarea> </markdown-field> @@ -370,10 +364,10 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" > <button :disabled="isSubmitButtonDisabled" - class="btn btn-create comment-btn js-comment-button js-comment-submit-button + class="btn btn-success js-comment-button js-comment-submit-button qa-comment-button" type="submit" - @click.prevent="handleSave();" + @click.prevent="handleSave()" > {{ __(commentButtonTitle) }} </button> @@ -381,7 +375,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" :disabled="isSubmitButtonDisabled" name="button" type="button" - class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown" + class="btn btn-success note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown" data-display="static" data-toggle="dropdown" aria-label="Open comment type dropdown" @@ -394,7 +388,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" <button type="button" class="btn btn-transparent" - @click.prevent="setNoteType('comment');" + @click.prevent="setNoteType('comment')" > <i aria-hidden="true" class="fa fa-check icon"> </i> <div class="description"> @@ -408,7 +402,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" <button type="button" class="btn btn-transparent qa-discussion-option" - @click.prevent="setNoteType('discussion');" + @click.prevent="setNoteType('discussion')" > <i aria-hidden="true" class="fa fa-check icon"> </i> <div class="description"> @@ -429,17 +423,8 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" ]" :disabled="isToggleStateButtonLoading || isSubmitting" :label="issueActionButtonTitle" - @click="handleSave(true);" + @click="handleSave(true)" /> - - <button - v-if="note.length" - type="button" - class="btn btn-cancel js-note-discard" - @click="discard" - > - Discard draft - </button> </div> </form> </div> diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index af821df0fd2..d8947e8ca50 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -5,8 +5,7 @@ import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; import { GlSkeletonLoading } from '@gitlab/ui'; import { getDiffMode } from '~/diffs/store/utils'; - -const FIRST_CHAR_REGEX = /^(\+|-| )/; +import { diffViewerModes } from '~/ide/constants'; export default { components: { @@ -33,6 +32,12 @@ export default { diffMode() { return getDiffMode(this.discussion.diff_file); }, + diffViewerMode() { + return this.discussion.diff_file.viewer.name; + }, + isTextFile() { + return this.diffViewerMode === diffViewerModes.text; + }, hasTruncatedDiffLines() { return ( this.discussion.truncated_diff_lines && this.discussion.truncated_diff_lines.length !== 0 @@ -54,27 +59,20 @@ export default { this.error = true; }); }, - trimChar(line) { - return line.replace(FIRST_CHAR_REGEX, ''); - }, }, userColorSchemeClass: window.gon.user_color_scheme, }; </script> <template> - <div :class="{ 'text-file': discussion.diff_file.text }" class="diff-file file-holder"> + <div :class="{ 'text-file': isTextFile }" class="diff-file file-holder"> <diff-file-header :discussion-path="discussion.discussion_path" :diff-file="discussion.diff_file" :can-current-user-fork="false" - :expanded="!discussion.diff_file.collapsed" + :expanded="!discussion.diff_file.viewer.collapsed" /> - <div - v-if="discussion.diff_file.text" - :class="$options.userColorSchemeClass" - class="diff-content code" - > + <div v-if="isTextFile" :class="$options.userColorSchemeClass" class="diff-content code"> <table> <template v-if="hasTruncatedDiffLines"> <tr @@ -85,7 +83,7 @@ export default { > <td class="diff-line-num old_line">{{ line.old_line }}</td> <td class="diff-line-num new_line">{{ line.new_line }}</td> - <td :class="line.type" class="line_content" v-html="trimChar(line.rich_text)"></td> + <td :class="line.type" class="line_content" v-html="line.rich_text"></td> </tr> </template> <tr v-if="!hasTruncatedDiffLines" class="line_holder line-holder-placeholder"> @@ -114,6 +112,7 @@ export default { <div v-else> <diff-viewer :diff-mode="diffMode" + :diff-viewer-mode="diffViewerMode" :new-path="discussion.diff_file.new_path" :new-sha="discussion.diff_file.diff_refs.head_sha" :old-path="discussion.diff_file.old_path" diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index 86c114a761a..31164f74201 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -1,8 +1,15 @@ <script> import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; +import { getLocationHash } from '../../lib/utils/url_utility'; import Icon from '~/vue_shared/components/icon.vue'; -import { DISCUSSION_FILTERS_DEFAULT_VALUE, HISTORY_ONLY_FILTER_VALUE } from '../constants'; +import { + DISCUSSION_FILTERS_DEFAULT_VALUE, + HISTORY_ONLY_FILTER_VALUE, + DISCUSSION_TAB_LABEL, + DISCUSSION_FILTER_TYPES, +} from '../constants'; +import notesEventHub from '../event_hub'; export default { components: { @@ -23,6 +30,7 @@ export default { return { currentValue: this.selectedValue, defaultValue: DISCUSSION_FILTERS_DEFAULT_VALUE, + displayFilters: true, }; }, computed: { @@ -32,31 +40,73 @@ export default { return this.filters.find(filter => filter.value === this.currentValue); }, }, + created() { + if (window.mrTabs) { + const { eventHub, currentTab } = window.mrTabs; + + eventHub.$on('MergeRequestTabChange', this.toggleFilters); + this.toggleFilters(currentTab); + } + + notesEventHub.$on('dropdownSelect', this.selectFilter); + window.addEventListener('hashchange', this.handleLocationHash); + this.handleLocationHash(); + }, mounted() { this.toggleCommentsForm(); }, + destroyed() { + notesEventHub.$off('dropdownSelect', this.selectFilter); + window.removeEventListener('hashchange', this.handleLocationHash); + }, methods: { - ...mapActions(['filterDiscussion', 'setCommentsDisabled']), + ...mapActions(['filterDiscussion', 'setCommentsDisabled', 'setTargetNoteHash']), selectFilter(value) { const filter = parseInt(value, 10); // close dropdown - $(this.$refs.dropdownToggle).dropdown('toggle'); + this.toggleDropdown(); if (filter === this.currentValue) return; this.currentValue = filter; this.filterDiscussion({ path: this.getNotesDataByProp('discussionsPath'), filter }); this.toggleCommentsForm(); }, + toggleDropdown() { + $(this.$refs.dropdownToggle).dropdown('toggle'); + }, toggleCommentsForm() { this.setCommentsDisabled(this.currentValue === HISTORY_ONLY_FILTER_VALUE); }, + toggleFilters(tab) { + this.displayFilters = tab === DISCUSSION_TAB_LABEL; + }, + handleLocationHash() { + const hash = getLocationHash(); + + if (/^note_/.test(hash) && this.currentValue !== DISCUSSION_FILTERS_DEFAULT_VALUE) { + this.selectFilter(this.defaultValue); + this.toggleDropdown(); // close dropdown + this.setTargetNoteHash(hash); + } + }, + filterType(value) { + if (value === 0) { + return DISCUSSION_FILTER_TYPES.ALL; + } else if (value === 1) { + return DISCUSSION_FILTER_TYPES.COMMENTS; + } + return DISCUSSION_FILTER_TYPES.HISTORY; + }, }, }; </script> <template> - <div class="discussion-filter-container d-inline-block align-bottom"> + <div + v-if="displayFilters" + class="discussion-filter-container js-discussion-filter-container d-inline-block align-bottom" + > <button id="discussion-filter-dropdown" ref="dropdownToggle" @@ -67,17 +117,22 @@ export default { {{ currentFilter.title }} <icon name="chevron-down" /> </button> <div + ref="dropdownMenu" class="dropdown-menu dropdown-menu-selectable dropdown-menu-right" aria-labelledby="discussion-filter-dropdown" > <div class="dropdown-content"> <ul> - <li v-for="filter in filters" :key="filter.value"> + <li + v-for="filter in filters" + :key="filter.value" + :data-filter-type="filterType(filter.value)" + > <button :class="{ 'is-active': filter.value === currentValue }" class="qa-filter-options" type="button" - @click="selectFilter(filter.value);" + @click="selectFilter(filter.value)" > {{ filter.title }} </button> diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue new file mode 100644 index 00000000000..46661e06f6d --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue @@ -0,0 +1,52 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { __, sprintf } from '~/locale'; + +import notesEventHub from '../event_hub'; + +export default { + components: { + GlButton, + Icon, + }, + computed: { + timelineContent() { + return sprintf( + __( + "You're only seeing %{startTag}other activity%{endTag} in the feed. To add a comment, switch to one of the following options.", + ), + { + startTag: `<b>`, + endTag: `</b>`, + }, + false, + ); + }, + }, + methods: { + selectFilter(value) { + notesEventHub.$emit('dropdownSelect', value); + }, + }, +}; +</script> + +<template> + <li class="timeline-entry note note-wrapper discussion-filter-note js-discussion-filter-note"> + <div class="timeline-icon"> + <icon name="comment" /> + </div> + <div class="timeline-content"> + <div v-html="timelineContent"></div> + <div class="discussion-filter-actions mt-2"> + <gl-button variant="default" @click="selectFilter(0)"> + {{ __('Show all activity') }} + </gl-button> + <gl-button variant="default" @click="selectFilter(1)"> + {{ __('Show comments only') }} + </gl-button> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue b/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue new file mode 100644 index 00000000000..07a5bda6bcb --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue @@ -0,0 +1,28 @@ +<script> +import icon from '~/vue_shared/components/icon.vue'; +import { GlTooltipDirective } from '@gitlab/ui'; + +export default { + name: 'JumpToNextDiscussionButton', + components: { + icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, +}; +</script> + +<template> + <div class="btn-group" role="group"> + <button + ref="button" + v-gl-tooltip + class="btn btn-default discussion-next-btn" + :title="s__('MergeRequests|Jump to next unresolved discussion')" + @click="$emit('onClick')" + > + <icon name="comment-next" /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue new file mode 100644 index 00000000000..ea590905e3c --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue @@ -0,0 +1,17 @@ +<script> +export default { + name: 'ReplyPlaceholder', +}; +</script> + +<template> + <button + ref="button" + type="button" + class="js-vue-discussion-reply btn btn-text-field" + :title="s__('MergeRequests|Add a reply')" + @click="$emit('onClick')" + > + {{ s__('MergeRequests|Reply...') }} + </button> +</template> diff --git a/app/assets/javascripts/notes/components/discussion_resolve_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_button.vue new file mode 100644 index 00000000000..2b29d710236 --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_resolve_button.vue @@ -0,0 +1,28 @@ +<script> +export default { + name: 'ResolveDiscussionButton', + props: { + isResolving: { + type: Boolean, + required: false, + default: false, + }, + buttonTitle: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <button ref="button" type="button" class="btn btn-default ml-sm-2" @click="$emit('onClick')"> + <i + v-if="isResolving" + ref="isResolvingIcon" + aria-hidden="true" + class="fa fa-spinner fa-spin" + ></i> + {{ buttonTitle }} + </button> +</template> diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue new file mode 100644 index 00000000000..e413398696a --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue @@ -0,0 +1,34 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; + +export default { + name: 'ResolveWithIssueButton', + components: { + Icon, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + url: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="btn-group" role="group"> + <gl-button + v-gl-tooltip + :href="url" + :title="s__('MergeRequests|Resolve this discussion in a new issue')" + class="new-issue-for-discussion discussion-create-issue-btn" + > + <icon name="issue-new" /> + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index d99694b06e9..de1ea0f58d6 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -2,11 +2,13 @@ import { mapGetters } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import ReplyButton from './note_actions/reply_button.vue'; export default { name: 'NoteActions', components: { Icon, + ReplyButton, GlLoadingIcon, }, directives: { @@ -36,6 +38,10 @@ export default { required: false, default: null, }, + showReply: { + type: Boolean, + required: true, + }, canEdit: { type: Boolean, required: true, @@ -80,6 +86,9 @@ export default { }, computed: { ...mapGetters(['getUserDataByProp']), + showReplyButton() { + return gon.features && gon.features.replyToIndividualNotes && this.showReply; + }, shouldShowActionsDropdown() { return this.currentUserId && (this.canEdit || this.canReportAsAbuse); }, @@ -112,6 +121,11 @@ export default { onResolve() { this.$emit('handleResolve'); }, + closeTooltip() { + this.$nextTick(() => { + this.$root.$emit('bv::hide::tooltip'); + }); + }, }, }; </script> @@ -121,6 +135,7 @@ export default { <span v-if="accessLevel" class="note-role user-access-role">{{ accessLevel }}</span> <div v-if="canResolve" class="note-actions-item"> <button + ref="resolveButton" v-gl-tooltip :class="{ 'is-disabled': !resolvable, 'is-active': isResolved }" :title="resolveButtonTitle" @@ -137,10 +152,9 @@ export default { </div> <div v-if="canAwardEmoji" class="note-actions-item"> <a - v-gl-tooltip.bottom + v-gl-tooltip :class="{ 'js-user-authored': isAuthoredByCurrentUser }" class="note-action-button note-emoji-button js-add-award js-note-emoji" - data-position="right" href="#" title="Add reaction" > @@ -153,9 +167,15 @@ export default { <icon css-classes="link-highlight award-control-icon-super-positive" name="emoji_smiley" /> </a> </div> + <reply-button + v-if="showReplyButton" + ref="replyButton" + class="js-reply-button" + @startReplying="$emit('startReplying')" + /> <div v-if="canEdit" class="note-actions-item"> <button - v-gl-tooltip.bottom + v-gl-tooltip type="button" title="Edit comment" class="note-action-button js-note-edit btn btn-transparent" @@ -166,7 +186,7 @@ export default { </div> <div v-if="showDeleteAction" class="note-actions-item"> <button - v-gl-tooltip.bottom + v-gl-tooltip type="button" title="Delete comment" class="note-action-button js-note-delete btn btn-transparent" @@ -177,11 +197,12 @@ export default { </div> <div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions note-actions-item"> <button - v-gl-tooltip.bottom + v-gl-tooltip type="button" title="More actions" class="note-action-button more-actions-toggle btn btn-transparent" data-toggle="dropdown" + @click="closeTooltip" > <icon css-classes="icon" name="ellipsis_v" /> </button> diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue new file mode 100644 index 00000000000..f50cab81efe --- /dev/null +++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue @@ -0,0 +1,30 @@ +<script> +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'ReplyButton', + components: { + Icon, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, +}; +</script> + +<template> + <div class="note-actions-item"> + <gl-button + ref="button" + v-gl-tooltip.bottom + class="note-action-button" + variant="transparent" + :title="__('Reply to comment')" + @click="$emit('startReplying')" + > + <icon name="comment" css-classes="link-highlight" /> + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 3d60eb02db8..17e5fcab5b7 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -1,6 +1,6 @@ <script> import { mapActions, mapGetters } from 'vuex'; -import { GlTooltipDirective } from '@gitlab/ui'; +import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; import Flash from '../../flash'; import { glEmojiTag } from '../../emoji'; @@ -10,7 +10,7 @@ export default { Icon, }, directives: { - GlTooltip: GlTooltipDirective, + tooltip, }, props: { awards: { @@ -167,25 +167,25 @@ export default { <button v-for="(awardList, awardName, index) in groupedAwards" :key="index" - v-gl-tooltip.bottom="{ boundary: 'viewport' }" + v-tooltip :class="getAwardClassBindings(awardList)" :title="awardTitle(awardList)" + data-boundary="viewport" class="btn award-control" type="button" - @click="handleAward(awardName);" + @click="handleAward(awardName)" > <span v-html="getAwardHTML(awardName)"></span> <span class="award-control-text js-counter">{{ awardList.length }}</span> </button> <div v-if="canAwardEmoji" class="award-menu-holder"> <button - v-gl-tooltip + v-tooltip :class="{ 'js-user-authored': isAuthoredByMe }" class="award-control btn js-add-award" title="Add reaction" aria-label="Add reaction" data-boundary="viewport" - data-placement="bottom" type="button" > <span class="award-control-icon award-control-icon-neutral"> diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index bcf5d334da4..fb1d98355b3 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -95,6 +95,7 @@ export default { <div ref="note-body" :class="{ 'js-task-list-container': canEdit }" class="note-body"> <suggestions v-if="hasSuggestion && !isEditing" + class="note-text md" :suggestions="note.suggestions" :note-html="note.note_html" :line-type="lineType" @@ -111,7 +112,6 @@ export default { :line="line" :note="note" :help-page-path="helpPagePath" - :markdown-version="note.cached_markdown_version" @handleFormUpdate="handleFormUpdate" @cancelForm="formCancelHandler" /> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 9b7f3d3588d..92258a25438 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -6,6 +6,7 @@ import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue'; import issuableStateMixin from '../mixins/issuable_state'; import resolvable from '../mixins/resolvable'; +import { __ } from '~/locale'; export default { name: 'NoteForm', @@ -25,15 +26,10 @@ export default { required: false, default: '', }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, saveButtonTitle: { type: String, required: false, - default: 'Save comment', + default: __('Save comment'), }, discussion: { type: Object, @@ -149,6 +145,9 @@ export default { return shouldResolve || shouldToggleState; }, + handleKeySubmit() { + this.handleUpdate(); + }, handleUpdate(shouldResolve) { const beforeSubmitDiscussionState = this.discussionResolved; this.isSubmitting = true; @@ -198,7 +197,6 @@ export default { <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :markdown-version="markdownVersion" :quick-actions-docs-path="quickActionsDocsPath" :line="line" :note="discussionNote" @@ -216,32 +214,32 @@ export default { class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form js-vue-textarea qa-reply-input" aria-label="Description" placeholder="Write a comment or drag your files here…" - @keydown.meta.enter="handleUpdate();" - @keydown.ctrl.enter="handleUpdate();" - @keydown.up="editMyLastNote();" - @keydown.esc="cancelHandler(true);" + @keydown.meta.enter="handleKeySubmit()" + @keydown.ctrl.enter="handleKeySubmit()" + @keydown.up="editMyLastNote()" + @keydown.esc="cancelHandler(true)" ></textarea> </markdown-field> <div class="note-form-actions clearfix"> <button :disabled="isDisabled" type="button" - class="js-vue-issue-save btn btn-success js-comment-button" - @click="handleUpdate();" + class="js-vue-issue-save btn btn-success js-comment-button qa-reply-comment-button" + @click="handleUpdate()" > {{ saveButtonTitle }} </button> <button v-if="discussion.resolvable" class="btn btn-nr btn-default append-right-10 js-comment-resolve-button" - @click.prevent="handleUpdate(true);" + @click.prevent="handleUpdate(true)" > {{ resolveButtonTitle }} </button> <button class="btn btn-cancel note-edit-cancel js-close-discussion-note-form" type="button" - @click="cancelHandler();" + @click="cancelHandler()" > Cancel </button> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 7b39901024d..68b753a4abf 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -69,7 +69,7 @@ export default { type="button" @click="handleToggle" > - <i :class="toggleChevronClass" class="fa" aria-hidden="true"> </i> + <i :class="toggleChevronClass" class="fa" aria-hidden="true"></i> {{ __('Toggle discussion') }} </button> </div> @@ -81,19 +81,18 @@ export default { :data-user-id="author.id" :data-username="author.username" > + <slot name="note-header-info"></slot> <span class="note-header-author-name">{{ author.name }}</span> <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> - <span class="note-headline-light"> @{{ author.username }} </span> + <span class="note-headline-light">@{{ author.username }}</span> </a> - <span v-else> {{ __('A deleted user') }} </span> + <span v-else>{{ __('A deleted user') }}</span> <span class="note-headline-light"> <span class="note-headline-meta"> <span class="system-note-message"> <slot></slot> </span> <template v-if="createdAt"> <span class="system-note-separator"> - <template v-if="actionText"> - {{ actionText }} - </template> + <template v-if="actionText">{{ actionText }}</template> </span> <a :href="noteTimestampLink" @@ -107,8 +106,7 @@ export default { class="fa fa-spinner fa-spin editing-spinner" aria-label="Comment is being updated" aria-hidden="true" - > - </i> + ></i> </span> </span> </div> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 7c3f5d00308..fc51998935d 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -6,12 +6,14 @@ import { truncateSha } from '~/lib/utils/text_utility'; import { s__, __, sprintf } from '~/locale'; import systemNote from '~/vue_shared/components/notes/system_note.vue'; import icon from '~/vue_shared/components/icon.vue'; +import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; import { SYSTEM_NOTE } from '../constants'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import noteableNote from './noteable_note.vue'; import noteHeader from './note_header.vue'; +import resolveDiscussionButton from './discussion_resolve_button.vue'; import toggleRepliesWidget from './toggle_replies_widget.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue'; import noteEditedText from './note_edited_text.vue'; @@ -23,6 +25,10 @@ import autosave from '../mixins/autosave'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; import discussionNavigation from '../mixins/discussion_navigation'; +import ReplyPlaceholder from './discussion_reply_placeholder.vue'; +import ResolveWithIssueButton from './discussion_resolve_with_issue_button.vue'; +import jumpToNextDiscussionButton from './discussion_jump_to_next_button.vue'; +import eventHub from '../event_hub'; export default { name: 'NoteableDiscussion', @@ -34,16 +40,21 @@ export default { noteSignedOutWidget, noteEditedText, noteForm, + resolveDiscussionButton, + jumpToNextDiscussionButton, toggleRepliesWidget, + ReplyPlaceholder, placeholderNote, placeholderSystemNote, + ResolveWithIssueButton, systemNote, + DraftNote: () => import('ee_component/batch_comments/components/draft_note.vue'), TimelineEntryItem, }, directives: { GlTooltip: GlTooltipDirective, }, - mixins: [autosave, noteable, resolvable, discussionNavigation], + mixins: [autosave, noteable, resolvable, discussionNavigation, diffLineNoteFormMixin], props: { discussion: { type: Object, @@ -87,6 +98,7 @@ export default { }, computed: { ...mapGetters([ + 'convertedDisscussionIds', 'getNoteableData', 'nextUnresolvedDiscussionId', 'unresolvedDiscussionsCount', @@ -206,11 +218,28 @@ export default { return sprintf(text, { commitId, linkStart, linkEnd }, false); }, diffLine() { + if (this.line) { + return this.line; + } + if (this.discussion.diff_discussion && this.discussion.truncated_diff_lines) { return this.discussion.truncated_diff_lines.slice(-1)[0]; } - return this.line; + return null; + }, + commit() { + if (!this.discussion.for_commit) { + return null; + } + + return { + id: this.discussion.commit_id, + url: this.discussion.discussion_path, + }; + }, + resolveWithIssuePath() { + return !this.discussionResolved && this.discussion.resolve_with_issue_path; }, }, watch: { @@ -225,6 +254,12 @@ export default { } }, }, + created() { + eventHub.$on('startReplying', this.onStartReplying); + }, + beforeDestroy() { + eventHub.$off('startReplying', this.onStartReplying); + }, methods: { ...mapActions([ 'saveNote', @@ -232,6 +267,7 @@ export default { 'removePlaceholderNotes', 'toggleResolveNote', 'expandDiscussion', + 'removeConvertedDiscussion', ]), truncateSha, componentName(note) { @@ -271,6 +307,10 @@ export default { } } + if (this.convertedDisscussionIds.includes(this.discussion.id)) { + this.removeConvertedDiscussion(this.discussion.id); + } + this.isReplying = false; this.resetAutoSave(); }, @@ -281,6 +321,10 @@ export default { note: { note: noteText }, }; + if (this.convertedDisscussionIds.includes(this.discussion.id)) { + postData.return_discussion = true; + } + if (this.discussion.for_commit) { postData.note_project_id = this.discussion.project_id; } @@ -320,6 +364,11 @@ Please check your network connection and try again.`; deleteNoteHandler(note) { this.$emit('noteDeleted', this.discussion, note); }, + onStartReplying(discussionId) { + if (this.discussion.id === discussionId) { + this.showReplyForm(); + } + }, }, }; </script> @@ -338,30 +387,32 @@ Please check your network connection and try again.`; :img-size="40" /> </div> - <note-header - :author="author" - :created-at="initialDiscussion.created_at" - :note-id="initialDiscussion.id" - :include-toggle="true" - :expanded="discussion.expanded" - @toggleHandler="toggleDiscussionHandler" - > - <span v-html="actionText"></span> - </note-header> - <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" - /> - <note-edited-text - v-else-if="lastUpdatedAt" - :edited-at="lastUpdatedAt" - :edited-by="lastUpdatedBy" - action-text="Last updated" - class-name="discussion-headline-light js-discussion-headline" - /> + <div class="timeline-content"> + <note-header + :author="author" + :created-at="initialDiscussion.created_at" + :note-id="initialDiscussion.id" + :include-toggle="true" + :expanded="discussion.expanded" + @toggleHandler="toggleDiscussionHandler" + > + <span v-html="actionText"></span> + </note-header> + <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" + /> + <note-edited-text + v-else-if="lastUpdatedAt" + :edited-at="lastUpdatedAt" + :edited-by="lastUpdatedBy" + action-text="Last updated" + class-name="discussion-headline-light js-discussion-headline" + /> + </div> </div> <div v-if="shouldShowDiscussions" class="discussion-body"> <component @@ -376,8 +427,11 @@ Please check your network connection and try again.`; :is="componentName(initialDiscussion)" :note="componentData(initialDiscussion)" :line="line" + :commit="commit" :help-page-path="helpPagePath" + :show-reply-button="canReply" @handleDeleteNote="deleteNoteHandler" + @startReplying="showReplyForm" > <note-edited-text v-if="discussion.resolved" @@ -428,49 +482,26 @@ Please check your network connection and try again.`; > <template v-if="!isReplying && canReply"> <div class="discussion-with-resolve-btn"> - <button - type="button" - class="js-vue-discussion-reply btn btn-text-field qa-discussion-reply" - title="Add a reply" - @click="showReplyForm" - > - Reply... - </button> - <div v-if="discussion.resolvable"> - <button - type="button" - class="btn btn-default ml-sm-2" - @click="resolveHandler();" - > - <i v-if="isResolving" aria-hidden="true" class="fa fa-spinner fa-spin"></i> - {{ resolveButtonTitle }} - </button> - </div> + <reply-placeholder class="qa-discussion-reply" @onClick="showReplyForm" /> + <resolve-discussion-button + v-if="discussion.resolvable" + :is-resolving="isResolving" + :button-title="resolveButtonTitle" + @onClick="resolveHandler" + /> <div v-if="discussion.resolvable" class="btn-group discussion-actions ml-sm-2" role="group" > - <div v-if="!discussionResolved" class="btn-group" role="group"> - <a - v-gl-tooltip - :href="discussion.resolve_with_issue_path" - :title="s__('MergeRequests|Resolve this discussion in a new issue')" - class="new-issue-for-discussion btn btn-default discussion-create-issue-btn" - > - <icon name="issue-new" /> - </a> - </div> - <div v-if="shouldShowJumpToNextDiscussion" class="btn-group" role="group"> - <button - v-gl-tooltip - class="btn btn-default discussion-next-btn" - title="Jump to next unresolved discussion" - @click="jumpToNextDiscussion" - > - <icon name="comment-next" /> - </button> - </div> + <resolve-with-issue-button + v-if="resolveWithIssuePath" + :url="resolveWithIssuePath" + /> + <jump-to-next-discussion-button + v-if="shouldShowJumpToNextDiscussion" + @onClick="jumpToNextDiscussion" + /> </div> </div> </template> @@ -481,6 +512,7 @@ Please check your network connection and try again.`; :is-editing="false" :line="diffLine" save-button-title="Comment" + @handleFormUpdateAddToReview="addReplyToReview" @handleFormUpdate="saveReply" @cancelForm="cancelReplyForm" /> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 4c02588127e..5fa0ab3de98 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -2,7 +2,9 @@ import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; import { escape } from 'underscore'; +import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import { s__, sprintf } from '../../locale'; import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import noteHeader from './note_header.vue'; @@ -37,6 +39,16 @@ export default { required: false, default: '', }, + commit: { + type: Object, + required: false, + default: () => null, + }, + showReplyButton: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -47,7 +59,7 @@ export default { }; }, computed: { - ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData']), + ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData', 'commentsDisabled']), author() { return this.note.author; }, @@ -73,6 +85,27 @@ export default { isTarget() { return this.targetNoteHash === this.noteAnchorId; }, + discussionId() { + if (this.discussion) { + return this.discussion.id; + } + return ''; + }, + actionText() { + if (!this.commit) { + return ''; + } + + // We need to do this to ensure we have the currect sentence order + // when translating this as the sentence order may change from one + // language to the next. See: + // https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24427#note_133713771 + const { id, url } = this.commit; + const commitLink = `<a class="commit-sha monospace" href="${escape(url)}">${truncateSha( + id, + )}</a>`; + return sprintf(s__('MergeRequests|commented on commit %{commitLink}'), { commitLink }, false); + }, }, created() { @@ -186,7 +219,7 @@ export default { :class="classNameBindings" :data-award-url="note.toggle_award_path" :data-note-id="note.id" - class="note note-wrapper" + class="note note-wrapper qa-noteable-note-item" > <div v-once class="timeline-icon"> <user-avatar-link @@ -200,18 +233,16 @@ export default { </div> <div class="timeline-content"> <div class="note-header"> - <note-header - v-once - :author="author" - :created-at="note.created_at" - :note-id="note.id" - action-text="commented" - /> + <note-header v-once :author="author" :created-at="note.created_at" :note-id="note.id"> + <span v-if="commit" v-html="actionText"></span> + <span v-else class="d-none d-sm-inline">·</span> + </note-header> <note-actions :author-id="author.id" :note-id="note.id" :note-url="note.noteable_note_url" :access-level="note.human_access" + :show-reply="showReplyButton" :can-edit="note.current_user.can_edit" :can-award-emoji="note.current_user.can_award_emoji" :can-delete="note.current_user.can_edit" @@ -225,6 +256,7 @@ export default { @handleEdit="editHandler" @handleDelete="deleteHandler" @handleResolve="resolveHandler" + @startReplying="$emit('startReplying')" /> </div> <div class="timeline-discussion-body"> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index f3fcfdfda05..e2bd59f7631 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -6,6 +6,7 @@ import * as constants from '../constants'; import eventHub from '../event_hub'; import noteableNote from './noteable_note.vue'; import noteableDiscussion from './noteable_discussion.vue'; +import discussionFilterNote from './discussion_filter_note.vue'; import systemNote from '../../vue_shared/components/notes/system_note.vue'; import commentForm from './comment_form.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; @@ -24,6 +25,7 @@ export default { placeholderNote, placeholderSystemNote, skeletonLoadingContainer, + discussionFilterNote, }, props: { noteableData: { @@ -44,11 +46,6 @@ export default { required: false, default: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, helpPagePath: { type: String, required: false, @@ -65,9 +62,11 @@ export default { ...mapGetters([ 'isNotesFetched', 'discussions', + 'convertedDisscussionIds', 'getNotesDataByProp', 'isLoading', 'commentsDisabled', + 'getNoteableData', ]), noteableType() { return this.noteableData.noteableType; @@ -83,6 +82,9 @@ export default { return this.discussions; }, + canReply() { + return this.getNoteableData.current_user.can_create_note && !this.commentsDisabled; + }, }, watch: { shouldShow() { @@ -90,8 +92,15 @@ export default { this.fetchNotes(); } }, + allDiscussions() { + if (this.discussonsCount) { + this.discussonsCount.textContent = this.allDiscussions.length; + } + }, }, created() { + this.discussonsCount = document.querySelector('.js-discussions-count'); + this.setNotesData(this.notesData); this.setNoteableData(this.noteableData); this.setUserData(this.userData); @@ -133,6 +142,7 @@ export default { 'setNotesFetchedState', 'expandDiscussion', 'startTaskList', + 'convertToDiscussion', ]), fetchNotes() { if (this.isFetching) return null; @@ -180,6 +190,11 @@ export default { } } }, + startReplying(discussionId) { + return this.convertToDiscussion(discussionId) + .then(() => this.$nextTick()) + .then(() => eventHub.$emit('startReplying', discussionId)); + }, }, systemNote: constants.SYSTEM_NOTE, }; @@ -198,13 +213,21 @@ export default { /> <placeholder-note v-else :key="discussion.id" :note="discussion.notes[0]" /> </template> - <template v-else-if="discussion.individual_note"> + <template + v-else-if="discussion.individual_note && !convertedDisscussionIds.includes(discussion.id)" + > <system-note v-if="discussion.notes[0].system" :key="discussion.id" :note="discussion.notes[0]" /> - <noteable-note v-else :key="discussion.id" :note="discussion.notes[0]" /> + <noteable-note + v-else + :key="discussion.id" + :note="discussion.notes[0]" + :show-reply-button="canReply" + @startReplying="startReplying(discussion.id)" + /> </template> <noteable-discussion v-else @@ -214,12 +237,9 @@ export default { :help-page-path="helpPagePath" /> </template> + <discussion-filter-note v-show="commentsDisabled" /> </ul> - <comment-form - v-if="!commentsDisabled" - :noteable-type="noteableType" - :markdown-version="markdownVersion" - /> + <comment-form v-if="!commentsDisabled" :noteable-type="noteableType" /> </div> </template> diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue index 72a8ff28466..f1b0b12bdce 100644 --- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue +++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue @@ -57,7 +57,7 @@ export default { tooltip-placement="bottom" /> </div> - <button class="btn btn-link js-replies-text" type="button" @click="toggle"> + <button class="btn btn-link js-replies-text qa-expand-replies" type="button" @click="toggle"> {{ replies.length }} {{ n__('reply', 'replies', replies.length) }} </button> {{ __('Last reply by') }} @@ -66,7 +66,11 @@ export default { </a> <time-ago-tooltip :time="lastReply.created_at" tooltip-placement="bottom" /> </template> - <span v-else class="collapse-replies-btn js-collapse-replies" @click="toggle"> + <span + v-else + class="collapse-replies-btn js-collapse-replies qa-collapse-replies" + @click="toggle" + > <icon name="chevron-down" /> {{ s__('Notes|Collapse replies') }} </span> </li> diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index 3147dc64c27..fba3db8542c 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -17,9 +17,16 @@ export const RESOLVE_NOTE_METHOD_NAME = 'post'; export const DESCRIPTION_TYPE = 'changed the description'; export const HISTORY_ONLY_FILTER_VALUE = 2; export const DISCUSSION_FILTERS_DEFAULT_VALUE = 0; +export const DISCUSSION_TAB_LABEL = 'show'; export const NOTEABLE_TYPE_MAPPING = { Issue: ISSUE_NOTEABLE_TYPE, MergeRequest: MERGE_REQUEST_NOTEABLE_TYPE, Epic: EPIC_NOTEABLE_TYPE, }; + +export const DISCUSSION_FILTER_TYPES = { + ALL: 'all', + COMMENTS: 'comments', + HISTORY: 'history', +}; diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 2f715c85fa6..4883266dae5 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -18,7 +18,6 @@ document.addEventListener('DOMContentLoaded', () => { const notesDataset = document.getElementById('js-vue-notes').dataset; const parsedUserData = JSON.parse(notesDataset.currentUserData); const noteableData = JSON.parse(notesDataset.noteableData); - const markdownVersion = parseInt(notesDataset.markdownVersion, 10); let currentUserData = {}; noteableData.noteableType = notesDataset.noteableType; @@ -37,7 +36,6 @@ document.addEventListener('DOMContentLoaded', () => { return { noteableData, currentUserData, - markdownVersion, notesData: JSON.parse(notesDataset.notesData), }; }, @@ -47,7 +45,6 @@ document.addEventListener('DOMContentLoaded', () => { noteableData: this.noteableData, notesData: this.notesData, userData: this.currentUserData, - markdownVersion: this.markdownVersion, }, }); }, diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js new file mode 100644 index 00000000000..188556e8921 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js @@ -0,0 +1,10 @@ +export default { + computed: { + draftForDiscussion: () => () => ({}), + }, + methods: { + showDraft: () => false, + addReplyToReview: () => {}, + addToReview: () => {}, + }, +}; diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index 8edf3d088bb..2329727bca2 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -31,6 +31,10 @@ export default { }, methods: { resolveHandler(resolvedState = false) { + if (this.note && this.note.isDraft) { + return this.$emit('toggleResolveStatus'); + } + this.isResolving = true; const isResolved = this.discussionResolved || resolvedState; const discussion = this.resolveAsThread; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 65f85314fa0..1a0dba69a7c 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -83,12 +83,44 @@ export const updateNote = ({ commit, dispatch }, { endpoint, note }) => dispatch('startTaskList'); }); -export const replyToDiscussion = ({ commit }, { endpoint, data }) => +export const updateOrCreateNotes = ({ commit, state, getters, dispatch }, notes) => { + const { notesById } = getters; + + notes.forEach(note => { + if (notesById[note.id]) { + commit(types.UPDATE_NOTE, note); + } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) { + const discussion = utils.findNoteObjectById(state.discussions, note.discussion_id); + + if (discussion) { + commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note); + } else if (note.type === constants.DIFF_NOTE) { + dispatch('fetchDiscussions', { path: state.notesData.discussionsPath }); + } else { + commit(types.ADD_NEW_NOTE, note); + } + } else { + commit(types.ADD_NEW_NOTE, note); + } + }); +}; + +export const replyToDiscussion = ({ commit, state, getters, dispatch }, { endpoint, data }) => service .replyToDiscussion(endpoint, data) .then(res => res.json()) .then(res => { - commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res); + if (res.discussion) { + commit(types.UPDATE_DISCUSSION, res.discussion); + + updateOrCreateNotes({ commit, state, getters, dispatch }, res.discussion.notes); + + dispatch('updateMergeRequestWidget'); + dispatch('startTaskList'); + dispatch('updateResolvableDiscussonsCounts'); + } else { + commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res); + } return res; }); @@ -262,25 +294,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => { const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => { if (resp.notes && resp.notes.length) { - const { notesById } = getters; - - resp.notes.forEach(note => { - if (notesById[note.id]) { - commit(types.UPDATE_NOTE, note); - } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) { - const discussion = utils.findNoteObjectById(state.discussions, note.discussion_id); - - if (discussion) { - commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note); - } else if (note.type === constants.DIFF_NOTE) { - dispatch('fetchDiscussions', { path: state.notesData.discussionsPath }); - } else { - commit(types.ADD_NEW_NOTE, note); - } - } else { - commit(types.ADD_NEW_NOTE, note); - } - }); + updateOrCreateNotes({ commit, state, getters, dispatch }, resp.notes); dispatch('startTaskList'); } @@ -415,15 +429,22 @@ export const submitSuggestion = ( commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }); callback(); }) - .catch(() => { - Flash( - __('Something went wrong while applying the suggestion. Please try again.'), - 'alert', - flashContainer, + .catch(err => { + const defaultMessage = __( + 'Something went wrong while applying the suggestion. Please try again.', ); + const flashMessage = err.response.data ? `${err.response.data.message}.` : defaultMessage; + + Flash(__(flashMessage), 'alert', flashContainer); callback(); }); }; +export const convertToDiscussion = ({ commit }, noteId) => + commit(types.CONVERT_TO_DISCUSSION, noteId); + +export const removeConvertedDiscussion = ({ commit }, noteId) => + commit(types.REMOVE_CONVERTED_DISCUSSION, noteId); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 0ffc0cb2593..fcc8889b0c7 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -4,6 +4,8 @@ import { collapseSystemNotes } from './collapse_utils'; export const discussions = state => collapseSystemNotes(state.discussions); +export const convertedDisscussionIds = state => state.convertedDisscussionIds; + export const targetNoteHash = state => state.targetNoteHash; export const getNotesData = state => state.notesData; @@ -189,6 +191,9 @@ export const firstUnresolvedDiscussionId = (state, getters) => diffOrder => { return getters.unresolvedDiscussionsIdsByDate[0]; }; +export const getDiscussion = state => discussionId => + state.discussions.find(discussion => discussion.id === discussionId); + export const commentsDisabled = state => state.commentsDisabled; // prevent babel-plugin-rewire from generating an invalid default during karma tests diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 887e6d22b06..6168aeae35d 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -5,6 +5,7 @@ import mutations from '../mutations'; export default () => ({ state: { discussions: [], + convertedDisscussionIds: [], targetNoteHash: null, lastFetchedAt: null, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index df943c155f4..796370920bb 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -17,6 +17,8 @@ export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE'; export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE'; export const DISABLE_COMMENTS = 'DISABLE_COMMENTS'; export const APPLY_SUGGESTION = 'APPLY_SUGGESTION'; +export const CONVERT_TO_DISCUSSION = 'CONVERT_TO_DISCUSSION'; +export const REMOVE_CONVERTED_DISCUSSION = 'REMOVE_CONVERTED_DISCUSSION'; // DISCUSSION export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 8992454be2e..fa44ef2d057 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -105,7 +105,10 @@ export default { if (discussion.diff_file) { diffData.file_hash = discussion.diff_file.file_hash; - diffData.truncated_diff_lines = discussion.truncated_diff_lines || []; + + diffData.truncated_diff_lines = utils.prepareDiffLines( + discussion.truncated_diff_lines || [], + ); } // To support legacy notes, should be very rare case. @@ -190,6 +193,10 @@ export default { const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id); if (noteObj.individual_note) { + if (note.type === constants.DISCUSSION_NOTE) { + noteObj.individual_note = false; + } + noteObj.notes.splice(0, 1, note); } else { const comment = utils.findNoteObjectById(noteObj.notes, note.id); @@ -243,7 +250,7 @@ export default { [types.SET_DISCUSSION_DIFF_LINES](state, { discussionId, diffLines }) { const discussion = utils.findNoteObjectById(state.discussions, discussionId); - discussion.truncated_diff_lines = diffLines; + discussion.truncated_diff_lines = utils.prepareDiffLines(diffLines); }, [types.DISABLE_COMMENTS](state, value) { @@ -261,4 +268,16 @@ export default { ).length; state.hasUnresolvedDiscussions = state.unresolvedDiscussionsCount > 1; }, + + [types.CONVERT_TO_DISCUSSION](state, discussionId) { + const convertedDisscussionIds = [...state.convertedDisscussionIds, discussionId]; + Object.assign(state, { convertedDisscussionIds }); + }, + + [types.REMOVE_CONVERTED_DISCUSSION](state, discussionId) { + const convertedDisscussionIds = [...state.convertedDisscussionIds]; + + convertedDisscussionIds.splice(convertedDisscussionIds.indexOf(discussionId), 1); + Object.assign(state, { convertedDisscussionIds }); + }, }; diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js index dd57539e4d8..4b0feb0f94d 100644 --- a/app/assets/javascripts/notes/stores/utils.js +++ b/app/assets/javascripts/notes/stores/utils.js @@ -1,4 +1,5 @@ import AjaxCache from '~/lib/utils/ajax_cache'; +import { trimFirstCharOfLineContent } from '~/diffs/store/utils'; const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; @@ -28,3 +29,6 @@ export const getQuickActionText = note => { export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note); export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim(); + +export const prepareDiffLines = diffLines => + diffLines.map(line => ({ ...trimFirstCharOfLineContent(line) })); diff --git a/app/assets/javascripts/pages/admin/application_settings/show/index.js b/app/assets/javascripts/pages/admin/application_settings/show/index.js new file mode 100644 index 00000000000..5ec9688a6e4 --- /dev/null +++ b/app/assets/javascripts/pages/admin/application_settings/show/index.js @@ -0,0 +1,3 @@ +import initUserInternalRegexPlaceholder from '../../application_settings/account_and_limits'; + +document.addEventListener('DOMContentLoaded', initUserInternalRegexPlaceholder()); diff --git a/app/assets/javascripts/pages/admin/index.js b/app/assets/javascripts/pages/admin/index.js index 3aa793e47b9..8a32556f06c 100644 --- a/app/assets/javascripts/pages/admin/index.js +++ b/app/assets/javascripts/pages/admin/index.js @@ -1,7 +1,3 @@ import initAdmin from './admin'; -import initUserInternalRegexPlaceholder from './application_settings/account_and_limits'; -document.addEventListener('DOMContentLoaded', () => { - initAdmin(); - initUserInternalRegexPlaceholder(); -}); +document.addEventListener('DOMContentLoaded', initAdmin()); diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js index 260484726f3..ff758fcb4fe 100644 --- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js +++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js @@ -1,10 +1,11 @@ import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; +import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; document.addEventListener('DOMContentLoaded', () => { - IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests(); + addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, diff --git a/app/assets/javascripts/pages/dashboard/projects/index.js b/app/assets/javascripts/pages/dashboard/projects/index.js index 8f98be79640..01001d4f3ff 100644 --- a/app/assets/javascripts/pages/dashboard/projects/index.js +++ b/app/assets/javascripts/pages/dashboard/projects/index.js @@ -1,7 +1,5 @@ import ProjectsList from '~/projects_list'; -import Star from '../../../star'; document.addEventListener('DOMContentLoaded', () => { new ProjectsList(); // eslint-disable-line no-new - new Star('.project-row'); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/pages/explore/projects/index.js b/app/assets/javascripts/pages/explore/projects/index.js index 0c585e162cb..01001d4f3ff 100644 --- a/app/assets/javascripts/pages/explore/projects/index.js +++ b/app/assets/javascripts/pages/explore/projects/index.js @@ -1,3 +1,5 @@ import ProjectsList from '~/projects_list'; -document.addEventListener('DOMContentLoaded', () => new ProjectsList()); +document.addEventListener('DOMContentLoaded', () => { + new ProjectsList(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/groups/clusters/update/index.js b/app/assets/javascripts/pages/groups/clusters/edit/index.js index 8001d2dd1da..8001d2dd1da 100644 --- a/app/assets/javascripts/pages/groups/clusters/update/index.js +++ b/app/assets/javascripts/pages/groups/clusters/edit/index.js diff --git a/app/assets/javascripts/pages/groups/clusters/index/index.js b/app/assets/javascripts/pages/groups/clusters/index/index.js index 845a5f7042c..30d519d0e37 100644 --- a/app/assets/javascripts/pages/groups/clusters/index/index.js +++ b/app/assets/javascripts/pages/groups/clusters/index/index.js @@ -1,5 +1,6 @@ -import initDismissableCallout from '~/dismissable_callout'; +import PersistentUserCallout from '~/persistent_user_callout'; document.addEventListener('DOMContentLoaded', () => { - initDismissableCallout('.gcp-signup-offer'); + const callout = document.querySelector('.gcp-signup-offer'); + PersistentUserCallout.factory(callout); }); diff --git a/app/assets/javascripts/pages/groups/group_members/index/index.js b/app/assets/javascripts/pages/groups/group_members/index/index.js index c22a164cd4e..e4f4c3b574e 100644 --- a/app/assets/javascripts/pages/groups/group_members/index/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index/index.js @@ -1,7 +1,7 @@ /* eslint-disable no-new */ import memberExpirationDate from '~/member_expiration_date'; -import Members from '~/members'; +import Members from 'ee_else_ce/members'; import UsersSelect from '~/users_select'; document.addEventListener('DOMContentLoaded', () => { diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/index.js index bf80d8b8193..451be6497de 100644 --- a/app/assets/javascripts/pages/groups/index.js +++ b/app/assets/javascripts/pages/groups/index.js @@ -1,6 +1,11 @@ -import initDismissableCallout from '~/dismissable_callout'; +import PersistentUserCallout from '~/persistent_user_callout'; import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; +function initGcpSignupCallout() { + const callout = document.querySelector('.gcp-signup-offer'); + PersistentUserCallout.factory(callout); +} + document.addEventListener('DOMContentLoaded', () => { const { page } = document.body.dataset; const newClusterViews = [ @@ -10,7 +15,7 @@ document.addEventListener('DOMContentLoaded', () => { ]; if (newClusterViews.indexOf(page) > -1) { - initDismissableCallout('.gcp-signup-offer'); + initGcpSignupCallout(); initGkeDropdowns(); } }); diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 736c6a62610..35d4b034654 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -1,9 +1,11 @@ import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import { FILTERED_SEARCH } from '~/pages/constants'; -import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; document.addEventListener('DOMContentLoaded', () => { + IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); + initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, isGroupDecendent: true, diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index 339ce67438a..12a26fd88fa 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -1,10 +1,11 @@ import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import { FILTERED_SEARCH } from '~/pages/constants'; document.addEventListener('DOMContentLoaded', () => { - IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests(); + addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, diff --git a/app/assets/javascripts/pages/import/gitea/status/index.js b/app/assets/javascripts/pages/import/gitea/status/index.js new file mode 100644 index 00000000000..dcd84f0faf9 --- /dev/null +++ b/app/assets/javascripts/pages/import/gitea/status/index.js @@ -0,0 +1,7 @@ +import mountImportProjectsTable from '~/import_projects'; + +document.addEventListener('DOMContentLoaded', () => { + const mountElement = document.getElementById('import-projects-mount-element'); + + mountImportProjectsTable(mountElement); +}); diff --git a/app/assets/javascripts/pages/import/github/status/index.js b/app/assets/javascripts/pages/import/github/status/index.js new file mode 100644 index 00000000000..dcd84f0faf9 --- /dev/null +++ b/app/assets/javascripts/pages/import/github/status/index.js @@ -0,0 +1,7 @@ +import mountImportProjectsTable from '~/import_projects'; + +document.addEventListener('DOMContentLoaded', () => { + const mountElement = document.getElementById('import-projects-mount-element'); + + mountImportProjectsTable(mountElement); +}); diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js index c7ce4675573..c9d3bbc8c39 100644 --- a/app/assets/javascripts/pages/profiles/show/index.js +++ b/app/assets/javascripts/pages/profiles/show/index.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import createFlash from '~/flash'; import GfmAutoComplete from '~/gfm_auto_complete'; +import emojiRegex from 'emoji-regex'; import EmojiMenu from './emoji_menu'; const defaultStatusEmoji = 'speech_balloon'; @@ -42,32 +43,47 @@ document.addEventListener('DOMContentLoaded', () => { const emojiAutocomplete = new GfmAutoComplete(); emojiAutocomplete.setup($(statusMessageField), { emojis: true }); + const userNameInput = document.getElementById('user_name'); + userNameInput.addEventListener('input', () => { + const EMOJI_REGEX = emojiRegex(); + if (EMOJI_REGEX.test(userNameInput.value)) { + // set field to invalid so it gets detected by GlFieldErrors + userNameInput.setCustomValidity('Invalid field'); + } else { + userNameInput.setCustomValidity(''); + } + }); + import(/* webpackChunkName: 'emoji' */ '~/emoji') .then(Emoji => { - const emojiMenu = new EmojiMenu( - Emoji, - toggleEmojiMenuButtonSelector, - 'js-status-emoji-menu', - selectEmojiCallback, - ); - emojiMenu.bindEvents(); + Emoji.initEmojiMap() + .then(() => { + const emojiMenu = new EmojiMenu( + Emoji, + toggleEmojiMenuButtonSelector, + 'js-status-emoji-menu', + selectEmojiCallback, + ); + emojiMenu.bindEvents(); - const defaultEmojiTag = Emoji.glEmojiTag(defaultStatusEmoji); - statusMessageField.addEventListener('input', () => { - const hasStatusMessage = statusMessageField.value.trim() !== ''; - const statusEmoji = findStatusEmoji(); - if (hasStatusMessage && statusEmoji) { - return; - } + const defaultEmojiTag = Emoji.glEmojiTag(defaultStatusEmoji); + statusMessageField.addEventListener('input', () => { + const hasStatusMessage = statusMessageField.value.trim() !== ''; + const statusEmoji = findStatusEmoji(); + if (hasStatusMessage && statusEmoji) { + return; + } - if (hasStatusMessage) { - toggleNoEmojiPlaceholder(false); - toggleEmojiMenuButton.innerHTML += defaultEmojiTag; - } else if (statusEmoji.dataset.name === defaultStatusEmoji) { - toggleNoEmojiPlaceholder(true); - removeStatusEmoji(); - } - }); + if (hasStatusMessage) { + toggleNoEmojiPlaceholder(false); + toggleEmojiMenuButton.innerHTML += defaultEmojiTag; + } else if (statusEmoji.dataset.name === defaultStatusEmoji) { + toggleNoEmojiPlaceholder(true); + removeStatusEmoji(); + } + }); + }) + .catch(() => createFlash('Failed to load emoji list.')); }) .catch(() => createFlash('Failed to load emoji list.')); }); diff --git a/app/assets/javascripts/pages/projects/clusters/update/index.js b/app/assets/javascripts/pages/projects/clusters/edit/index.js index 8001d2dd1da..8001d2dd1da 100644 --- a/app/assets/javascripts/pages/projects/clusters/update/index.js +++ b/app/assets/javascripts/pages/projects/clusters/edit/index.js diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js index 845a5f7042c..30d519d0e37 100644 --- a/app/assets/javascripts/pages/projects/clusters/index/index.js +++ b/app/assets/javascripts/pages/projects/clusters/index/index.js @@ -1,5 +1,6 @@ -import initDismissableCallout from '~/dismissable_callout'; +import PersistentUserCallout from '~/persistent_user_callout'; document.addEventListener('DOMContentLoaded', () => { - initDismissableCallout('.gcp-signup-offer'); + const callout = document.querySelector('.gcp-signup-offer'); + PersistentUserCallout.factory(callout); }); diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js index 0b644780ad4..0d69a689316 100644 --- a/app/assets/javascripts/pages/projects/environments/metrics/index.js +++ b/app/assets/javascripts/pages/projects/environments/metrics/index.js @@ -1,3 +1,3 @@ -import monitoringBundle from '~/monitoring/monitoring_bundle'; +import monitoringBundle from 'ee_else_ce/monitoring/monitoring_bundle'; document.addEventListener('DOMContentLoaded', monitoringBundle); diff --git a/app/assets/javascripts/pages/projects/error_tracking/index.js b/app/assets/javascripts/pages/projects/error_tracking/index.js new file mode 100644 index 00000000000..5a8fe137e9a --- /dev/null +++ b/app/assets/javascripts/pages/projects/error_tracking/index.js @@ -0,0 +1,5 @@ +import ErrorTracking from '~/error_tracking'; + +document.addEventListener('DOMContentLoaded', () => { + ErrorTracking(); +}); diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js index 3ccad513c05..314519ee442 100644 --- a/app/assets/javascripts/pages/projects/graphs/charts/index.js +++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js @@ -2,65 +2,86 @@ import $ from 'jquery'; import Chart from 'chart.js'; import _ from 'underscore'; +import { barChartOptions, pieChartOptions } from '~/lib/utils/chart_utils'; + document.addEventListener('DOMContentLoaded', () => { const projectChartData = JSON.parse(document.getElementById('projectChartData').innerHTML); - const responsiveChart = (selector, data) => { - const options = { - scaleOverlay: true, - responsive: true, - pointHitDetectionRadius: 2, - maintainAspectRatio: false, - }; + const barChart = (selector, data) => { // get selector by context const ctx = selector.get(0).getContext('2d'); // pointing parent container to make chart.js inherit its width const container = $(selector).parent(); - const generateChart = () => { - selector.attr('width', $(container).width()); - if (window.innerWidth < 768) { - // Scale fonts if window width lower than 768px (iPad portrait) - options.scaleFontSize = 8; - } - return new Chart(ctx).Bar(data, options); - }; - // enabling auto-resizing - $(window).resize(generateChart); - return generateChart(); + selector.attr('width', $(container).width()); + + // Scale fonts if window width lower than 768px (iPad portrait) + const shouldAdjustFontSize = window.innerWidth < 768; + return new Chart(ctx, { + type: 'bar', + data, + options: barChartOptions(shouldAdjustFontSize), + }); + }; + + const pieChart = (context, data) => { + const options = pieChartOptions(); + + return new Chart(context, { + type: 'pie', + data, + options, + }); }; const chartData = data => ({ labels: Object.keys(data), datasets: [ { - fillColor: 'rgba(220,220,220,0.5)', - strokeColor: 'rgba(220,220,220,1)', - barStrokeWidth: 1, - barValueSpacing: 1, - barDatasetSpacing: 1, + backgroundColor: 'rgba(220,220,220,0.5)', + borderColor: 'rgba(220,220,220,1)', + borderWidth: 1, data: _.values(data), }, ], }); + const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => { + if (firstDayOfWeek === 0) { + return weekDays; + } + + return Object.keys(weekDays).reduce((acc, dayName, idx, arr) => { + const reorderedDayName = arr[(idx + firstDayOfWeek) % arr.length]; + + return { + ...acc, + [reorderedDayName]: weekDays[reorderedDayName], + }; + }, {}); + }; + const hourData = chartData(projectChartData.hour); - responsiveChart($('#hour-chart'), hourData); + barChart($('#hour-chart'), hourData); - const dayData = chartData(projectChartData.weekDays); - responsiveChart($('#weekday-chart'), dayData); + const weekDays = reorderWeekDays(projectChartData.weekDays, gon.first_day_of_week); + const dayData = chartData(weekDays); + barChart($('#weekday-chart'), dayData); const monthData = chartData(projectChartData.month); - responsiveChart($('#month-chart'), monthData); + barChart($('#month-chart'), monthData); - const data = projectChartData.languages; + const data = { + datasets: [ + { + data: projectChartData.languages.map(x => x.value), + backgroundColor: projectChartData.languages.map(x => x.color), + hoverBackgroundColor: projectChartData.languages.map(x => x.highlight), + }, + ], + labels: projectChartData.languages.map(x => x.label), + }; const ctx = $('#languages-chart') .get(0) .getContext('2d'); - const options = { - scaleOverlay: true, - responsive: true, - maintainAspectRatio: false, - }; - - new Chart(ctx).Pie(data, options); + pieChart(ctx, data); }); diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index 5659e13981a..d4bd02c14e9 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,5 +1,5 @@ -import initDismissableCallout from '~/dismissable_callout'; import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; +import PersistentUserCallout from '../../persistent_user_callout'; import Project from './project'; import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation'; @@ -12,7 +12,9 @@ document.addEventListener('DOMContentLoaded', () => { ]; if (newClusterViews.indexOf(page) > -1) { - initDismissableCallout('.gcp-signup-offer'); + const callout = document.querySelector('.gcp-signup-offer'); + PersistentUserCallout.factory(callout); + initGkeDropdowns(); } diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js index ffc84dc106b..aecc6484b26 100644 --- a/app/assets/javascripts/pages/projects/issues/edit/index.js +++ b/app/assets/javascripts/pages/projects/issues/edit/index.js @@ -1,3 +1,3 @@ -import initForm from '../form'; +import initForm from 'ee_else_ce/pages/projects/issues/form'; document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js index f99023ad8e7..941c4552579 100644 --- a/app/assets/javascripts/pages/projects/issues/form.js +++ b/app/assets/javascripts/pages/projects/issues/form.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import GLForm from '~/gl_form'; -import IssuableForm from '~/issuable_form'; +import IssuableForm from 'ee_else_ce/issuable_form'; import LabelsSelect from '~/labels_select'; import MilestoneSelect from '~/milestone_select'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index a56c0bb6be8..8bf0c2edc71 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -4,11 +4,13 @@ import IssuableIndex from '~/issuable_index'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import UsersSelect from '~/users_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; -import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; document.addEventListener('DOMContentLoaded', () => { + IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); + initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js index ffc84dc106b..aecc6484b26 100644 --- a/app/assets/javascripts/pages/projects/issues/new/index.js +++ b/app/assets/javascripts/pages/projects/issues/new/index.js @@ -1,3 +1,3 @@ -import initForm from '../form'; +import initForm from 'ee_else_ce/pages/projects/issues/form'; document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index ec39db12e74..0bcca22e40f 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -2,12 +2,13 @@ import IssuableIndex from '~/issuable_index'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import UsersSelect from '~/users_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; +import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; document.addEventListener('DOMContentLoaded', () => { - IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests(); + addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js index e3971618da5..8f0dc8554e2 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js @@ -4,7 +4,7 @@ import $ from 'jquery'; import Diff from '~/diff'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import GLForm from '~/gl_form'; -import IssuableForm from '~/issuable_form'; +import IssuableForm from 'ee_else_ce/issuable_form'; import LabelsSelect from '~/labels_select'; import MilestoneSelect from '~/milestone_select'; import IssuableTemplateSelectors from '~/templates/issuable_template_selectors'; 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 db2a4041ec0..bd4309e47ad 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 @@ -70,7 +70,7 @@ export default { :checked="isEditable" class="label-bold" type="radio" - @click="toggleCustomInput(true);" + @click="toggleCustomInput(true)" /> <label for="custom"> {{ s__('PipelineSheduleIntervalPattern|Custom') }} </label> @@ -88,7 +88,7 @@ export default { :value="cronIntervalPresets.everyDay" class="label-bold" type="radio" - @click="toggleCustomInput(false);" + @click="toggleCustomInput(false)" /> <label class="label-bold" for="every-day"> {{ __('Every day (at 4:00am)') }} </label> @@ -102,7 +102,7 @@ export default { :value="cronIntervalPresets.everyWeek" class="label-bold" type="radio" - @click="toggleCustomInput(false);" + @click="toggleCustomInput(false)" /> <label class="label-bold" for="every-week"> @@ -118,7 +118,7 @@ export default { :value="cronIntervalPresets.everyMonth" class="label-bold" type="radio" - @click="toggleCustomInput(false);" + @click="toggleCustomInput(false)" /> <label class="label-bold" for="every-month"> diff --git a/app/assets/javascripts/pages/projects/pipelines/charts/index.js b/app/assets/javascripts/pages/projects/pipelines/charts/index.js index 48353f3b4ef..9fa580d2ba9 100644 --- a/app/assets/javascripts/pages/projects/pipelines/charts/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/charts/index.js @@ -1,29 +1,31 @@ import $ from 'jquery'; import Chart from 'chart.js'; -const options = { - scaleOverlay: true, - responsive: true, - maintainAspectRatio: false, -}; +import { barChartOptions, lineChartOptions } from '~/lib/utils/chart_utils'; + +const SUCCESS_LINE_COLOR = '#1aaa55'; + +const TOTAL_LINE_COLOR = '#707070'; -const buildChart = chartScope => { +const buildChart = (chartScope, shouldAdjustFontSize) => { const data = { labels: chartScope.labels, datasets: [ { - fillColor: '#707070', - strokeColor: '#707070', - pointColor: '#707070', - pointStrokeColor: '#EEE', - data: chartScope.totalValues, + backgroundColor: SUCCESS_LINE_COLOR, + borderColor: SUCCESS_LINE_COLOR, + pointBackgroundColor: SUCCESS_LINE_COLOR, + pointBorderColor: '#fff', + data: chartScope.successValues, + fill: 'origin', }, { - fillColor: '#1aaa55', - strokeColor: '#1aaa55', - pointColor: '#1aaa55', - pointStrokeColor: '#fff', - data: chartScope.successValues, + backgroundColor: TOTAL_LINE_COLOR, + borderColor: TOTAL_LINE_COLOR, + pointBackgroundColor: TOTAL_LINE_COLOR, + pointBorderColor: '#EEE', + data: chartScope.totalValues, + fill: '-1', }, ], }; @@ -31,36 +33,51 @@ const buildChart = chartScope => { .get(0) .getContext('2d'); - new Chart(ctx).Line(data, options); + return new Chart(ctx, { + type: 'line', + data, + options: lineChartOptions({ + width: ctx.canvas.width, + numberOfPoints: chartScope.totalValues.length, + shouldAdjustFontSize, + }), + }); }; -document.addEventListener('DOMContentLoaded', () => { - const chartTimesData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML); - const chartsData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML); +const buildBarChart = (chartTimesData, shouldAdjustFontSize) => { const data = { labels: chartTimesData.labels, datasets: [ { - fillColor: 'rgba(220,220,220,0.5)', - strokeColor: 'rgba(220,220,220,1)', - barStrokeWidth: 1, + backgroundColor: 'rgba(220,220,220,0.5)', + borderColor: 'rgba(220,220,220,1)', + borderWidth: 1, barValueSpacing: 1, barDatasetSpacing: 1, data: chartTimesData.values, }, ], }; - - if (window.innerWidth < 768) { - // Scale fonts if window width lower than 768px (iPad portrait) - options.scaleFontSize = 8; - } - - new Chart( + return new Chart( $('#build_timesChart') .get(0) .getContext('2d'), - ).Bar(data, options); + { + type: 'bar', + data, + options: barChartOptions(shouldAdjustFontSize), + }, + ); +}; + +document.addEventListener('DOMContentLoaded', () => { + const chartTimesData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML); + const chartsData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML); + + // Scale fonts if window width lower than 768px (iPad portrait) + const shouldAdjustFontSize = window.innerWidth < 768; + + buildBarChart(chartTimesData, shouldAdjustFontSize); - chartsData.forEach(scope => buildChart(scope)); + chartsData.forEach(scope => buildChart(scope, shouldAdjustFontSize)); }); diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index adbe744290a..f39765818e7 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -1,7 +1,7 @@ +import Members from 'ee_else_ce/members'; import memberExpirationDate from '../../../member_expiration_date'; import UsersSelect from '../../../users_select'; import groupsSelect from '../../../groups_select'; -import Members from '../../../members'; document.addEventListener('DOMContentLoaded', () => { memberExpirationDate('.js-access-expiration-date-groups'); diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js new file mode 100644 index 00000000000..73c745179be --- /dev/null +++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js @@ -0,0 +1,5 @@ +import mountErrorTrackingForm from '~/error_tracking_settings'; + +document.addEventListener('DOMContentLoaded', () => { + mountErrorTrackingForm(); +}); 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 08c7719dcf2..19d9903c988 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 @@ -325,8 +325,8 @@ export default { <project-setting-row v-if="pagesAvailable && pagesAccessControlEnabled" :help-path="pagesHelpPath" - label="Pages" - help-text="Static website for the project." + label="Pages access control" + help-text="Access control for the project's static website" > <project-feature-setting v-model="pagesAccessLevel" diff --git a/app/assets/javascripts/pages/projects/tags/releases/index.js b/app/assets/javascripts/pages/projects/tags/releases/index.js new file mode 100644 index 00000000000..d6afc71fb03 --- /dev/null +++ b/app/assets/javascripts/pages/projects/tags/releases/index.js @@ -0,0 +1,8 @@ +import $ from 'jquery'; +import ZenMode from '~/zen_mode'; +import GLForm from '~/gl_form'; + +document.addEventListener('DOMContentLoaded', () => { + new ZenMode(); // eslint-disable-line no-new + new GLForm($('.release-form')); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index 07f32210d93..e1a3f42a71f 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -1,13 +1,20 @@ import $ from 'jquery'; import UsernameValidator from './username_validator'; +import NoEmojiValidator from '../../../emoji/no_emoji_validator'; import SigninTabsMemoizer from './signin_tabs_memoizer'; import OAuthRememberMe from './oauth_remember_me'; +import preserveUrlFragment from './preserve_url_fragment'; document.addEventListener('DOMContentLoaded', () => { new UsernameValidator(); // eslint-disable-line no-new new SigninTabsMemoizer(); // eslint-disable-line no-new + new NoEmojiValidator(); // eslint-disable-line no-new new OAuthRememberMe({ container: $('.omniauth-container'), }).bindEvents(); + + // Save the URL fragment from the current window location. This will be present if the user was + // redirected to sign-in after attempting to access a protected URL that included a fragment. + preserveUrlFragment(window.location.hash); }); diff --git a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js index 761618109a4..191221a48cd 100644 --- a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js +++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { mergeUrlParams, removeParams } from '~/lib/utils/url_utility'; /** * OAuth-based login buttons have a separate "remember me" checkbox. @@ -24,9 +25,9 @@ export default class OAuthRememberMe { const href = $(element).attr('href'); if (rememberMe) { - $(element).attr('href', `${href}?remember_me=1`); + $(element).attr('href', mergeUrlParams({ remember_me: 1 }, href)); } else { - $(element).attr('href', href.replace('?remember_me=1', '')); + $(element).attr('href', removeParams(['remember_me'], href)); } }); } diff --git a/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js b/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js new file mode 100644 index 00000000000..e617fecaa0f --- /dev/null +++ b/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js @@ -0,0 +1,32 @@ +import { mergeUrlParams, setUrlFragment } from '~/lib/utils/url_utility'; + +/** + * Ensure the given URL fragment is preserved by appending it to sign-in/sign-up form actions and + * OAuth/SAML login links. + * + * @param fragment {string} - url fragment to be preserved + */ +export default function preserveUrlFragment(fragment = '') { + if (fragment) { + const normalFragment = fragment.replace(/^#/, ''); + + // Append the fragment to all sign-in/sign-up form actions so it is preserved when the user is + // eventually redirected back to the originally requested URL. + const forms = document.querySelectorAll('#signin-container form'); + Array.prototype.forEach.call(forms, form => { + const actionWithFragment = setUrlFragment(form.getAttribute('action'), `#${normalFragment}`); + form.setAttribute('action', actionWithFragment); + }); + + // Append a redirect_fragment query param to all oauth provider links. The redirect_fragment + // query param will be available in the omniauth callback upon successful authentication + const anchors = document.querySelectorAll('#signin-container a.oauth-login'); + Array.prototype.forEach.call(anchors, anchor => { + const newHref = mergeUrlParams( + { redirect_fragment: normalFragment }, + anchor.getAttribute('href'), + ); + anchor.setAttribute('href', newHref); + }); + } +} diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 8a84ac37dab..61204c37307 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -10,6 +10,12 @@ import { __ } from '~/locale'; const d3 = { select, scaleLinear, scaleThreshold }; +const firstDayOfWeekChoices = Object.freeze({ + sunday: 0, + monday: 1, + saturday: 6, +}); + const LOADING_HTML = ` <div class="text-center"> <i class="fa fa-spinner fa-spin user-calendar-activities-loading"></i> @@ -49,7 +55,7 @@ export default class ActivityCalendar { timestamps, calendarActivitiesPath, utcOffset = 0, - firstDayOfWeek = 0, + firstDayOfWeek = firstDayOfWeekChoices.sunday, monthsAgo = 12, ) { this.calendarActivitiesPath = calendarActivitiesPath; @@ -159,7 +165,7 @@ export default class ActivityCalendar { .append('g') .attr('transform', (group, i) => { _.each(group, (stamp, a) => { - if (a === 0 && stamp.day === 0) { + if (a === 0 && stamp.day === this.firstDayOfWeek) { const month = stamp.date.getMonth(); const x = this.daySizeWithSpace * i + 1 + this.daySizeWithSpace; const lastMonth = _.last(this.months); @@ -205,6 +211,19 @@ export default class ActivityCalendar { y: 29 + this.dayYPos(5), }, ]; + + if (this.firstDayOfWeek === firstDayOfWeekChoices.monday) { + days.push({ + text: 'S', + y: 29 + this.dayYPos(7), + }); + } else if (this.firstDayOfWeek === firstDayOfWeekChoices.saturday) { + days.push({ + text: 'S', + y: 29 + this.dayYPos(6), + }); + } + this.svg .append('g') .selectAll('text') diff --git a/app/assets/javascripts/pages/users/user_overview_block.js b/app/assets/javascripts/pages/users/user_overview_block.js index eec2b5ca8e5..a7c3c9d104d 100644 --- a/app/assets/javascripts/pages/users/user_overview_block.js +++ b/app/assets/javascripts/pages/users/user_overview_block.js @@ -15,7 +15,8 @@ export default class UserOverviewBlock { } loadData() { - const loadingEl = document.querySelector(`${this.container} .loading`); + const containerEl = document.querySelector(this.container); + const loadingEl = containerEl.querySelector(`.loading`); loadingEl.classList.remove('hide'); @@ -29,18 +30,21 @@ export default class UserOverviewBlock { render(data) { const { html, count } = data; - const contentList = document.querySelector(`${this.container} .overview-content-list`); + const containerEl = document.querySelector(this.container); + const contentList = containerEl.querySelector('.overview-content-list'); contentList.innerHTML += html; - const loadingEl = document.querySelector(`${this.container} .loading`); + const loadingEl = containerEl.querySelector('.loading'); if (count && count > 0) { - document.querySelector(`${this.container} .js-view-all`).classList.remove('hide'); + containerEl.querySelector('.js-view-all').classList.remove('hide'); } else { - document - .querySelector(`${this.container} .nothing-here-block`) - .classList.add('text-left', 'p-0'); + const nothingHereBlock = containerEl.querySelector('.nothing-here-block'); + + if (nothingHereBlock) { + nothingHereBlock.classList.add('p-5'); + } } loadingEl.classList.add('hide'); diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index 1c3fd58ca74..636308c5401 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -221,7 +221,7 @@ export default class UserTabs { const monthsAgo = UserTabs.getVisibleCalendarPeriod($calendarWrap); const calendarActivitiesPath = $calendarWrap.data('calendarActivitiesPath'); const utcOffset = $calendarWrap.data('utcOffset'); - const calendarHint = __('Issues, merge requests, pushes and comments.'); + const calendarHint = __('Issues, merge requests, pushes, and comments.'); $calendarWrap.html(CALENDAR_TEMPLATE); @@ -234,7 +234,7 @@ export default class UserTabs { data, calendarActivitiesPath, utcOffset, - 0, + gon.first_day_of_week, monthsAgo, ); } diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index 74faa35358d..1ec2784cc5a 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -134,6 +134,13 @@ export default { >ms / <span title="Invoke Count">{{ currentRequest.details.gc.invokes }}</span> gc </span> </div> + <div + v-if="currentRequest.details && currentRequest.details.tracing" + id="peek-view-trace" + class="view" + > + <a :href="currentRequest.details.tracing.tracing_url"> trace </a> + </div> <request-selector v-if="currentRequest" :current-request="currentRequest" diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js new file mode 100644 index 00000000000..4a08e158f6b --- /dev/null +++ b/app/assets/javascripts/persistent_user_callout.js @@ -0,0 +1,42 @@ +import axios from './lib/utils/axios_utils'; +import { __ } from './locale'; +import Flash from './flash'; + +export default class PersistentUserCallout { + constructor(container) { + const { dismissEndpoint, featureId } = container.dataset; + this.container = container; + this.dismissEndpoint = dismissEndpoint; + this.featureId = featureId; + + this.init(); + } + + init() { + const closeButton = this.container.querySelector('.js-close'); + closeButton.addEventListener('click', event => this.dismiss(event)); + } + + dismiss(event) { + event.preventDefault(); + + axios + .post(this.dismissEndpoint, { + feature_name: this.featureId, + }) + .then(() => { + this.container.remove(); + }) + .catch(() => { + Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.')); + }); + } + + static factory(container) { + if (!container) { + return undefined; + } + + return new PersistentUserCallout(container); + } +} diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 59cebaba717..a49dc311bd0 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,59 +1,20 @@ <script> -import _ from 'underscore'; import { GlLoadingIcon } from '@gitlab/ui'; import StageColumnComponent from './stage_column_component.vue'; +import GraphMixin from '../../mixins/graph_component_mixin'; export default { components: { StageColumnComponent, GlLoadingIcon, }, - props: { - isLoading: { - type: Boolean, - required: true, - }, - pipeline: { - type: Object, - required: true, - }, - }, - computed: { - graph() { - return this.pipeline.details && this.pipeline.details.stages; - }, - }, - methods: { - capitalizeStageName(name) { - const escapedName = _.escape(name); - return escapedName.charAt(0).toUpperCase() + escapedName.slice(1); - }, - isFirstColumn(index) { - return index === 0; - }, - stageConnectorClass(index, stage) { - let className; - - // If it's the first stage column and only has one job - if (index === 0 && stage.groups.length === 1) { - className = 'no-margin'; - } else if (index > 0) { - // If it is not the first column - className = 'left-margin'; - } - - return className; - }, - refreshPipelineGraph() { - this.$emit('refreshPipelineGraph'); - }, - }, + mixins: [GraphMixin], }; </script> <template> <div class="build-content middle-block js-pipeline-graph"> <div class="pipeline-visualization pipeline-graph pipeline-tab-content"> - <div class="text-center"><gl-loading-icon v-if="isLoading" :size="3" /></div> + <div v-if="isLoading" class="m-auto"><gl-loading-icon :size="3" /></div> <ul v-if="!isLoading" class="stage-column-list"> <stage-column-component diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index cf9db89e32b..2b32a6e4a98 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -108,7 +108,7 @@ export default { :href="status.details_path" :title="tooltipText" :class="cssClassJobName" - class="js-pipeline-graph-job-link" + class="js-pipeline-graph-job-link qa-job-link" > <job-name-component :name="job.name" :status="job.status" /> </gl-link> diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index 30a5bbf92ce..918622ef8dc 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -1,8 +1,20 @@ <script> import { GlLink, GlTooltipDirective } from '@gitlab/ui'; +import _ from 'underscore'; +import { __, sprintf } from '~/locale'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import popover from '~/vue_shared/directives/popover'; +const popoverTitle = sprintf( + _.escape( + __( + `This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}`, + ), + ), + { strongStart: '<b>', strongEnd: '</b>' }, + false, +); + export default { components: { UserAvatarLink, @@ -32,14 +44,14 @@ export default { trigger: 'focus', placement: 'top', title: `<div class="autodevops-title"> - This pipeline makes use of a predefined CI/CD configuration enabled by <b>Auto DevOps.</b> + ${popoverTitle} </div>`, content: `<a class="autodevops-link" href="${this.autoDevopsHelpPath}" target="_blank" rel="noopener noreferrer nofollow"> - Learn more about Auto DevOps + ${_.escape(__('Learn more about Auto DevOps'))} </a>`, }; }, @@ -54,9 +66,9 @@ export default { <span>by</span> <user-avatar-link v-if="user" - :link-href="pipeline.user.path" - :img-src="pipeline.user.avatar_url" - :tooltip-text="pipeline.user.name" + :link-href="user.path" + :img-src="user.avatar_url" + :tooltip-text="user.name" class="js-pipeline-url-user" /> <span v-if="!user" class="js-pipeline-url-api api"> API </span> @@ -64,10 +76,10 @@ export default { <span v-if="pipeline.flags.latest" v-gl-tooltip + :title="__('Latest pipeline for this branch')" class="js-pipeline-url-latest badge badge-success" - title="__('Latest pipeline for this branch')" > - latest + {{ __('latest') }} </span> <span v-if="pipeline.flags.yaml_errors" @@ -75,7 +87,7 @@ export default { :title="pipeline.yaml_errors" class="js-pipeline-url-yaml badge badge-danger" > - yaml invalid + {{ __('yaml invalid') }} </span> <span v-if="pipeline.flags.failure_reason" @@ -83,7 +95,7 @@ export default { :title="pipeline.failure_reason" class="js-pipeline-url-failure badge badge-danger" > - error + {{ __('error') }} </span> <gl-link v-if="pipeline.flags.auto_devops" @@ -95,15 +107,15 @@ export default { Auto DevOps </gl-link> <span v-if="pipeline.flags.stuck" class="js-pipeline-url-stuck badge badge-warning"> - stuck + {{ __('stuck') }} </span> <span v-if="pipeline.flags.merge_request" v-gl-tooltip - title="__('This pipeline is run in a merge request context')" + :title="__('This pipeline is run in a merge request context')" class="js-pipeline-url-mergerequest badge badge-info" > - merge request + {{ __('merge request') }} </span> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_actions.vue index 2e9f2519fcb..244d332f38f 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_actions.vue @@ -59,28 +59,30 @@ export default { </script> <template> <div class="btn-group"> - <gl-button + <button v-gl-tooltip + type="button" :disabled="isLoading" class="dropdown-new btn btn-default js-pipeline-dropdown-manual-actions" - title="Manual job" + :title="__('Manual job')" data-toggle="dropdown" - aria-label="Manual job" + :aria-label="__('Manual job')" > - <icon name="play" class="icon-play" /> <i class="fa fa-caret-down" aria-hidden="true"> </i> + <icon name="play" class="icon-play" /> + <i class="fa fa-caret-down" aria-hidden="true"></i> <gl-loading-icon v-if="isLoading" /> - </gl-button> + </button> <ul class="dropdown-menu dropdown-menu-right"> <li v-for="action in actions" :key="action.path"> <gl-button :class="{ disabled: isActionDisabled(action) }" :disabled="isActionDisabled(action)" - class="js-pipeline-action-link no-btn btn" - @click="onClickAction(action);" + class="js-pipeline-action-link no-btn btn d-flex align-items-center justify-content-between flex-wrap" + @click="onClickAction(action)" > {{ action.name }} - <span v-if="action.scheduled_at" class="pull-right"> + <span v-if="action.scheduled_at"> <icon name="clock" /> <gl-countdown :end-date-string="action.scheduled_at" /> </span> diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue index 908b10afee6..2ab0ad4d013 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue @@ -1,5 +1,5 @@ <script> -import { GlLink, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; export default { @@ -9,7 +9,6 @@ export default { components: { Icon, GlLink, - GlButton, }, props: { artifacts: { @@ -21,20 +20,22 @@ export default { </script> <template> <div class="btn-group" role="group"> - <gl-button + <button v-gl-tooltip - class="dropdown-toggle build-artifacts js-pipeline-dropdown-download" - title="Artifacts" + type="button" + class="dropdown-toggle build-artifacts btn btn-default js-pipeline-dropdown-download" + :title="__('Artifacts')" data-toggle="dropdown" - aria-label="Artifacts" + :aria-label="__('Artifacts')" > - <icon name="download" /> <i class="fa fa-caret-down" aria-hidden="true"> </i> - </gl-button> + <icon name="download" /> + <i class="fa fa-caret-down" aria-hidden="true"></i> + </button> <ul class="dropdown-menu dropdown-menu-right"> <li v-for="(artifact, i) in artifacts" :key="i"> - <gl-link :href="artifact.path" rel="nofollow" download> - Download {{ artifact.name }} artifacts - </gl-link> + <gl-link :href="artifact.path" rel="nofollow" download + >Download {{ artifact.name }} artifacts</gl-link + > </li> </ul> </div> diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue index 2d3f667e73e..7426936515a 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -172,8 +172,6 @@ export default { <span :aria-label="stage.title" aria-hidden="true" class="no-pointer-events"> <icon :name="borderlessIcon" /> </span> - - <i class="fa fa-caret-down" aria-hidden="true"> </i> </button> <div diff --git a/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js new file mode 100644 index 00000000000..66e9476dadf --- /dev/null +++ b/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js @@ -0,0 +1,44 @@ +import _ from 'underscore'; + +export default { + props: { + isLoading: { + type: Boolean, + required: true, + }, + pipeline: { + type: Object, + required: true, + }, + }, + computed: { + graph() { + return this.pipeline.details && this.pipeline.details.stages; + }, + }, + methods: { + capitalizeStageName(name) { + const escapedName = _.escape(name); + return escapedName.charAt(0).toUpperCase() + escapedName.slice(1); + }, + isFirstColumn(index) { + return index === 0; + }, + stageConnectorClass(index, stage) { + let className; + + // If it's the first stage column and only has one job + if (index === 0 && stage.groups.length === 1) { + className = 'no-margin'; + } else if (index > 0) { + // If it is not the first column + className = 'left-margin'; + } + + return className; + }, + refreshPipelineGraph() { + this.$emit('refreshPipelineGraph'); + }, + }, +}; diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 32bfa47e5f2..3cc9d0a3a4e 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -27,11 +27,7 @@ export default { }, computed: { shouldRenderPagination() { - return ( - !this.isLoading && - this.state.pipelines.length && - this.state.pageInfo.total > this.state.pageInfo.perPage - ); + return !this.isLoading; }, }, beforeMount() { @@ -94,8 +90,7 @@ export default { this.isLoading = false; this.successCallback(response); - // restart polling - this.poll.restart({ data: this.requestData }); + this.poll.enable({ data: this.requestData, response }); }) .catch(() => { this.isLoading = false; diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index a33835472bb..5ee510eb11d 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -5,97 +5,101 @@ import Api from './api'; import ProjectSelectComboButton from './project_select_combo_button'; export default function projectSelect() { - $('.ajax-project-select').each(function(i, select) { - var placeholder; - const simpleFilter = $(select).data('simpleFilter') || false; - this.groupId = $(select).data('groupId'); - this.includeGroups = $(select).data('includeGroups'); - this.allProjects = $(select).data('allProjects') || false; - this.orderBy = $(select).data('orderBy') || 'id'; - this.withIssuesEnabled = $(select).data('withIssuesEnabled'); - this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled'); - this.withShared = - $(select).data('withShared') === undefined ? true : $(select).data('withShared'); - this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; - this.allowClear = $(select).data('allowClear') || false; + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('.ajax-project-select').each(function(i, select) { + var placeholder; + const simpleFilter = $(select).data('simpleFilter') || false; + this.groupId = $(select).data('groupId'); + this.includeGroups = $(select).data('includeGroups'); + this.allProjects = $(select).data('allProjects') || false; + this.orderBy = $(select).data('orderBy') || 'id'; + this.withIssuesEnabled = $(select).data('withIssuesEnabled'); + this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled'); + this.withShared = + $(select).data('withShared') === undefined ? true : $(select).data('withShared'); + this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; + this.allowClear = $(select).data('allowClear') || false; - placeholder = 'Search for project'; - if (this.includeGroups) { - placeholder += ' or group'; - } + placeholder = 'Search for project'; + if (this.includeGroups) { + placeholder += ' or group'; + } - $(select).select2({ - placeholder: placeholder, - minimumInputLength: 0, - query: (function(_this) { - return function(query) { - var finalCallback, projectsCallback; - finalCallback = function(projects) { - var data; - data = { - results: projects, - }; - return query.callback(data); - }; - if (_this.includeGroups) { - projectsCallback = function(projects) { - var groupsCallback; - groupsCallback = function(groups) { + $(select).select2({ + placeholder: placeholder, + minimumInputLength: 0, + query: (function(_this) { + return function(query) { + var finalCallback, projectsCallback; + finalCallback = function(projects) { var data; - data = groups.concat(projects); - return finalCallback(data); + data = { + results: projects, + }; + return query.callback(data); }; - return Api.groups(query.term, {}, groupsCallback); + if (_this.includeGroups) { + projectsCallback = function(projects) { + var groupsCallback; + groupsCallback = function(groups) { + var data; + data = groups.concat(projects); + return finalCallback(data); + }; + return Api.groups(query.term, {}, groupsCallback); + }; + } else { + projectsCallback = finalCallback; + } + if (_this.groupId) { + return Api.groupProjects( + _this.groupId, + query.term, + { + with_issues_enabled: _this.withIssuesEnabled, + with_merge_requests_enabled: _this.withMergeRequestsEnabled, + with_shared: _this.withShared, + include_subgroups: _this.includeProjectsInSubgroups, + }, + projectsCallback, + ); + } else { + return Api.projects( + query.term, + { + order_by: _this.orderBy, + with_issues_enabled: _this.withIssuesEnabled, + with_merge_requests_enabled: _this.withMergeRequestsEnabled, + membership: !_this.allProjects, + }, + projectsCallback, + ); + } }; - } else { - projectsCallback = finalCallback; - } - if (_this.groupId) { - return Api.groupProjects( - _this.groupId, - query.term, - { - with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled, - with_shared: _this.withShared, - include_subgroups: _this.includeProjectsInSubgroups, - }, - projectsCallback, - ); - } else { - return Api.projects( - query.term, - { - order_by: _this.orderBy, - with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled, - membership: !_this.allProjects, - }, - projectsCallback, - ); - } - }; - })(this), - id: function(project) { - if (simpleFilter) return project.id; - return JSON.stringify({ - name: project.name, - url: project.web_url, - }); - }, - text: function(project) { - return project.name_with_namespace || project.name; - }, + })(this), + id: function(project) { + if (simpleFilter) return project.id; + return JSON.stringify({ + name: project.name, + url: project.web_url, + }); + }, + text: function(project) { + return project.name_with_namespace || project.name; + }, - initSelection: function(el, callback) { - return Api.project(el.val()).then(({ data }) => callback(data)); - }, + initSelection: function(el, callback) { + return Api.project(el.val()).then(({ data }) => callback(data)); + }, - allowClear: this.allowClear, + allowClear: this.allowClear, - dropdownCssClass: 'ajax-project-dropdown', - }); - if (simpleFilter) return select; - return new ProjectSelectComboButton(select); - }); + dropdownCssClass: 'ajax-project-dropdown', + }); + if (simpleFilter) return select; + return new ProjectSelectComboButton(select); + }); + }) + .catch(() => {}); } diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js index 3dbac3ff942..d3b5f532dc1 100644 --- a/app/assets/javascripts/project_select_combo_button.js +++ b/app/assets/javascripts/project_select_combo_button.js @@ -44,9 +44,13 @@ export default class ProjectSelectComboButton { // eslint-disable-next-line class-methods-use-this openDropdown(event) { - $(event.currentTarget) - .siblings('.project-item-select') - .select2('open'); + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $(event.currentTarget) + .siblings('.project-item-select') + .select2('open'); + }) + .catch(() => {}); } selectProject() { diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue index 21095fcba16..83811ab489a 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue @@ -108,9 +108,7 @@ export default { </span> </li> <li v-for="result in results" :key="result.id"> - <button type="button" @click.prevent="setItem(result.name);"> - {{ result.name }} - </button> + <button type="button" @click.prevent="setItem(result.name)">{{ result.name }}</button> </li> </ul> </div> diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue index 056584c8865..a2eb79af4f9 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue @@ -169,7 +169,7 @@ export default { </span> </li> <li v-for="result in results" :key="result.project_number"> - <button type="button" @click.prevent="setItem(result);">{{ result.name }}</button> + <button type="button" @click.prevent="setItem(result)">{{ result.name }}</button> </li> </ul> </div> diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue index 728616a441f..fd5d5f86401 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue @@ -34,7 +34,7 @@ export default { }, errorMessage() { return sprintf( - s__('ClusterIntegration|An error occured while trying to fetch project zones: %{error}'), + s__('ClusterIntegration|An error occurred while trying to fetch project zones: %{error}'), { error: this.gapiError }, ); }, @@ -82,9 +82,7 @@ export default { </span> </li> <li v-for="result in results" :key="result.id"> - <button type="button" @click.prevent="setItem(result.name);"> - {{ result.name }} - </button> + <button type="button" @click.prevent="setItem(result.name)">{{ result.name }}</button> </li> </ul> </div> diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 998554d1be5..1ade1811033 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -115,15 +115,63 @@ const bindEvents = () => { const templates = { rails: { text: 'Ruby on Rails', - icon: '.template-option svg.icon-rails', + icon: '.template-option .icon-rails', }, express: { text: 'NodeJS Express', - icon: '.template-option svg.icon-node-express', + icon: '.template-option .icon-express', }, spring: { text: 'Spring', - icon: '.template-option svg.icon-java-spring', + icon: '.template-option .icon-spring', + }, + dotnetcore: { + text: '.NET Core', + icon: '.template-option .icon-dotnet', + }, + gomicro: { + text: 'Go Micro', + icon: '.template-option .icon-gomicro', + }, + hugo: { + text: 'Pages/Hugo', + icon: '.template-option .icon-hugo', + }, + jekyll: { + text: 'Pages/Jekyll', + icon: '.template-option .icon-jekyll', + }, + plainhtml: { + text: 'Pages/Plain HTML', + icon: '.template-option .icon-plainhtml', + }, + gitbook: { + text: 'Pages/GitBook', + icon: '.template-option .icon-gitbook', + }, + hexo: { + text: 'Pages/Hexo', + icon: '.template-option .icon-hexo', + }, + nfhugo: { + text: 'Netlify/Hugo', + icon: '.template-option .icon-netlify', + }, + nfjekyll: { + text: 'Netlify/Jekyll', + icon: '.template-option .icon-netlify', + }, + nfplainhtml: { + text: 'Netlify/Plain HTML', + icon: '.template-option .icon-netlify', + }, + nfgitbook: { + text: 'Netlify/GitBook', + icon: '.template-option .icon-netlify', + }, + nfhexo: { + text: 'Netlify/Hexo', + icon: '.template-option .icon-netlify', }, }; diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index 78c7671856a..81fe0a48c06 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -70,7 +70,7 @@ export default { </thead> <tbody> <tr v-for="item in repo.list" :key="item.tag"> - <td> + <td class="monospace"> {{ item.tag }} <clipboard-button v-if="item.location" @@ -80,7 +80,9 @@ export default { /> </td> <td> - <span v-gl-tooltip.bottom :title="item.revision">{{ item.shortRevision }}</span> + <span v-gl-tooltip.bottom class="monospace" :title="item.revision">{{ + item.shortRevision + }}</span> </td> <td> {{ formatSize(item.size) }} @@ -104,7 +106,7 @@ export default { :aria-label="s__('ContainerRegistry|Remove tag')" variant="danger" class="js-delete-registry d-none d-sm-block float-right" - @click="handleDeleteRegistry(item);" + @click="handleDeleteRegistry(item)" > <icon name="remove" /> </gl-button> diff --git a/app/assets/javascripts/releases/components/app.vue b/app/assets/javascripts/releases/components/app.vue index 0ad5ee2915c..5a06c4fec58 100644 --- a/app/assets/javascripts/releases/components/app.vue +++ b/app/assets/javascripts/releases/components/app.vue @@ -1,12 +1,12 @@ <script> import { mapState, mapActions } from 'vuex'; -import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; +import { GlSkeletonLoading, GlEmptyState } from '@gitlab/ui'; import ReleaseBlock from './release_block.vue'; export default { name: 'ReleasesApp', components: { - GlLoadingIcon, + GlSkeletonLoading, GlEmptyState, ReleaseBlock, }, @@ -43,7 +43,7 @@ export default { </script> <template> <div class="prepend-top-default"> - <gl-loading-icon v-if="isLoading" :size="2" class="js-loading prepend-top-20" /> + <gl-skeleton-loading v-if="isLoading" class="js-loading" /> <gl-empty-state v-else-if="shouldRenderEmptyState" diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue index 34b97826cdb..7ed1b407ddd 100644 --- a/app/assets/javascripts/releases/components/release_block.vue +++ b/app/assets/javascripts/releases/components/release_block.vue @@ -45,7 +45,7 @@ export default { return this.release.author || {}; }, hasAuthor() { - return _.isEmpty(this.author); + return !_.isEmpty(this.author); }, }, }; @@ -87,7 +87,7 @@ export default { <div v-if="assets.links.length || assets.sources.length" - Sclass="card-text prepend-top-default" + class="card-text prepend-top-default" > <b> {{ __('Assets') }} @@ -98,7 +98,7 @@ export default { <li v-for="link in assets.links" :key="link.name" class="append-bottom-8"> <gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.url"> <icon name="package" class="align-middle append-right-4 align-text-bottom" /> - {{ link.name }} + {{ link.name }} <span v-if="link.external"> {{ __('(external source)') }}</span> </gl-link> </li> </ul> diff --git a/app/assets/javascripts/releases/store/actions.js b/app/assets/javascripts/releases/store/actions.js index baa2251403e..e0a922d5ef6 100644 --- a/app/assets/javascripts/releases/store/actions.js +++ b/app/assets/javascripts/releases/store/actions.js @@ -11,7 +11,7 @@ export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES); /** * Fetches the main endpoint. * Will dispatch requestNamespace action before starting the request. - * Will dispatch receiveNamespaceSuccess if the request is successfull + * Will dispatch receiveNamespaceSuccess if the request is successful * Will dispatch receiveNamesapceError if the request returns an error * * @param {String} projectId @@ -30,7 +30,7 @@ export const receiveReleasesSuccess = ({ commit }, data) => export const receiveReleasesError = ({ commit }) => { commit(types.RECEIVE_RELEASES_ERROR); - createFlash(__('An error occured while fetching the releases. Please try again.')); + createFlash(__('An error occurred while fetching the releases. Please try again.')); }; // prevent babel-plugin-rewire from generating an invalid default during karma tests diff --git a/app/assets/javascripts/reports/components/modal_open_name.vue b/app/assets/javascripts/reports/components/modal_open_name.vue index 118e4b02c46..4f81cee2a38 100644 --- a/app/assets/javascripts/reports/components/modal_open_name.vue +++ b/app/assets/javascripts/reports/components/modal_open_name.vue @@ -26,7 +26,7 @@ export default { <button type="button" class="btn-link btn-blank text-left break-link vulnerability-name-button" - @click="handleIssueClick();" + @click="handleIssueClick()" > {{ issue.title }} </button> diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue index 938e83de546..7700f49bf7d 100644 --- a/app/assets/javascripts/reports/components/test_issue_body.vue +++ b/app/assets/javascripts/reports/components/test_issue_body.vue @@ -30,7 +30,7 @@ export default { <button type="button" class="btn-link btn-blank text-left break-link vulnerability-name-button" - @click="openModal({ issue });" + @click="openModal({ issue })" > <div v-if="isNew" class="badge badge-danger append-right-5">{{ s__('New') }}</div> {{ issue.name }} diff --git a/app/assets/javascripts/serverless/components/environment_row.vue b/app/assets/javascripts/serverless/components/environment_row.vue new file mode 100644 index 00000000000..4d18c5c4bdd --- /dev/null +++ b/app/assets/javascripts/serverless/components/environment_row.vue @@ -0,0 +1,65 @@ +<script> +import FunctionRow from './function_row.vue'; +import ItemCaret from '~/groups/components/item_caret.vue'; + +export default { + components: { + ItemCaret, + FunctionRow, + }, + props: { + env: { + type: Array, + required: true, + }, + envName: { + type: String, + required: true, + }, + }, + data() { + return { + isOpen: true, + }; + }, + computed: { + envId() { + if (this.envName === '*') { + return 'env-global'; + } + + return `env-${this.envName}`; + }, + isOpenClass() { + return { + 'is-open': this.isOpen, + }; + }, + }, + methods: { + toggleOpen() { + this.isOpen = !this.isOpen; + }, + }, +}; +</script> + +<template> + <li :id="envId" :class="isOpenClass" class="group-row has-children"> + <div + class="group-row-contents d-flex justify-content-end align-items-center" + role="button" + @click.stop="toggleOpen" + > + <div class="folder-toggle-wrap d-flex align-items-center"> + <item-caret :is-group-open="isOpen" /> + </div> + <div class="group-text flex-grow title namespace-title prepend-left-default"> + {{ envName }} + </div> + </div> + <ul v-if="isOpen" class="content-list group-list-tree"> + <function-row v-for="(f, index) in env" :key="f.name" :index="index" :func="f" /> + </ul> + </li> +</template> diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue new file mode 100644 index 00000000000..4f89ad69129 --- /dev/null +++ b/app/assets/javascripts/serverless/components/function_details.vue @@ -0,0 +1,56 @@ +<script> +import PodBox from './pod_box.vue'; +import Url from './url.vue'; + +export default { + components: { + PodBox, + Url, + }, + props: { + func: { + type: Object, + required: true, + }, + }, + computed: { + name() { + return this.func.name; + }, + description() { + return this.func.description; + }, + funcUrl() { + return this.func.url; + }, + podCount() { + return this.func.podcount || 0; + }, + }, +}; +</script> + +<template> + <section id="serverless-function-details"> + <h3>{{ name }}</h3> + <div class="append-bottom-default"> + <div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div> + </div> + <url :uri="funcUrl" /> + + <h4>{{ s__('ServerlessDetails|Kubernetes Pods') }}</h4> + <div v-if="podCount > 0"> + <p> + <b v-if="podCount == 1">{{ podCount }} {{ s__('ServerlessDetails|pod in use') }}</b> + <b v-else>{{ podCount }} {{ s__('ServerlessDetails|pods in use') }}</b> + </p> + <pod-box :count="podCount" /> + <p> + {{ + s__('ServerlessDetails|Number of Kubernetes pods in use over time based on necessity.') + }} + </p> + </div> + <div v-else><p>No pods loaded at this time.</p></div> + </section> +</template> diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue index 31f5427c771..773d18781fd 100644 --- a/app/assets/javascripts/serverless/components/function_row.vue +++ b/app/assets/javascripts/serverless/components/function_row.vue @@ -1,9 +1,12 @@ <script> import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; +import Url from './url.vue'; +import { visitUrl } from '~/lib/utils/url_utility'; export default { components: { Timeago, + Url, }, props: { func: { @@ -15,7 +18,18 @@ export default { name() { return this.func.name; }, - url() { + description() { + const desc = this.func.description.split('\n'); + if (desc.length > 1) { + return desc[1]; + } + + return desc[0]; + }, + detailUrl() { + return this.func.detail_url; + }, + targetUrl() { return this.func.url; }, image() { @@ -25,16 +39,34 @@ export default { return this.func.created_at; }, }, + methods: { + checkClass(element) { + if (element.closest('.no-expand') === null) { + return true; + } + + return false; + }, + openDetails(e) { + if (this.checkClass(e.target)) { + visitUrl(this.detailUrl); + } + }, + }, }; </script> <template> - <div class="gl-responsive-table-row"> - <div class="table-section section-20">{{ name }}</div> - <div class="table-section section-50"> - <a :href="url">{{ url }}</a> + <li :id="name" class="group-row"> + <div class="group-row-contents" role="button" @click="openDetails"> + <p class="float-right text-right"> + <span>{{ image }}</span + ><br /> + <timeago :time="timestamp" /> + </p> + <b>{{ name }}</b> + <div v-for="line in description.split('\n')" :key="line">{{ line }}</div> + <url :uri="targetUrl" class="prepend-top-8 no-expand" /> </div> - <div class="table-section section-20">{{ image }}</div> - <div class="table-section section-10"><timeago :time="timestamp" /></div> - </div> + </li> </template> diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index 349e14670b1..4bde409f906 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -1,19 +1,21 @@ <script> import { GlSkeletonLoading } from '@gitlab/ui'; import FunctionRow from './function_row.vue'; +import EnvironmentRow from './environment_row.vue'; import EmptyState from './empty_state.vue'; export default { components: { + EnvironmentRow, FunctionRow, EmptyState, GlSkeletonLoading, }, props: { functions: { - type: Array, + type: Object, required: true, - default: () => [], + default: () => ({}), }, installed: { type: Boolean, @@ -45,30 +47,21 @@ export default { <section id="serverless-functions"> <div v-if="installed"> <div v-if="hasFunctionData"> - <div class="ci-table js-services-list function-element"> - <div class="gl-responsive-table-row table-row-header" role="row"> - <div class="table-section section-20" role="rowheader"> - {{ s__('Serverless|Function') }} - </div> - <div class="table-section section-50" role="rowheader"> - {{ s__('Serverless|Domain') }} - </div> - <div class="table-section section-20" role="rowheader"> - {{ s__('Serverless|Runtime') }} - </div> - <div class="table-section section-10" role="rowheader"> - {{ s__('Serverless|Last Update') }} - </div> + <template v-if="loadingData"> + <div v-for="j in 3" :key="j" class="gl-responsive-table-row"><gl-skeleton-loading /></div> + </template> + <template v-else> + <div class="groups-list-tree-container"> + <ul class="content-list group-list-tree"> + <environment-row + v-for="(env, index) in functions" + :key="index" + :env="env" + :env-name="index" + /> + </ul> </div> - <template v-if="loadingData"> - <div v-for="j in 3" :key="j" class="gl-responsive-table-row"> - <gl-skeleton-loading /> - </div> - </template> - <template v-else> - <function-row v-for="f in functions" :key="f.name" :func="f" /> - </template> - </div> + </template> </div> <div v-else class="empty-state js-empty-state"> <div class="text-content"> @@ -108,16 +101,3 @@ export default { <empty-state v-else :clusters-path="clustersPath" :help-path="helpPath" /> </section> </template> - -<style> -.top-area { - border-bottom: 0; -} - -.function-element { - border-bottom: 1px solid #e5e5e5; - border-bottom-color: rgb(229, 229, 229); - border-bottom-style: solid; - border-bottom-width: 1px; -} -</style> diff --git a/app/assets/javascripts/serverless/components/pod_box.vue b/app/assets/javascripts/serverless/components/pod_box.vue new file mode 100644 index 00000000000..04d3641bce3 --- /dev/null +++ b/app/assets/javascripts/serverless/components/pod_box.vue @@ -0,0 +1,36 @@ +<script> +export default { + props: { + count: { + type: Number, + required: true, + }, + color: { + type: String, + required: false, + default: 'green', + }, + }, + methods: { + boxOffset(i) { + return 20 * (i - 1); + }, + }, +}; +</script> + +<template> + <svg :width="boxOffset(count + 1)" :height="20"> + <rect + v-for="i in count" + :key="i" + width="15" + height="15" + rx="5" + ry="5" + :fill="color" + :x="boxOffset(i)" + y="0" + /> + </svg> +</template> diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue new file mode 100644 index 00000000000..ca53bf6c52a --- /dev/null +++ b/app/assets/javascripts/serverless/components/url.vue @@ -0,0 +1,38 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + GlButton, + ClipboardButton, + }, + props: { + uri: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="clipboard-group"> + <div class="url-text-field label label-monospace">{{ uri }}</div> + <clipboard-button + :text="uri" + :title="s__('ServerlessURL|Copy URL to clipboard')" + class="input-group-text js-clipboard-btn" + /> + <gl-button + :href="uri" + target="_blank" + rel="noopener noreferrer nofollow" + class="input-group-text btn btn-default" + > + <icon name="external-link" /> + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js index 3e3b81ba247..47a510d5fb5 100644 --- a/app/assets/javascripts/serverless/serverless_bundle.js +++ b/app/assets/javascripts/serverless/serverless_bundle.js @@ -4,23 +4,65 @@ import { s__ } from '../locale'; import Flash from '../flash'; import Poll from '../lib/utils/poll'; import ServerlessStore from './stores/serverless_store'; +import ServerlessDetailsStore from './stores/serverless_details_store'; import GetFunctionsService from './services/get_functions_service'; import Functions from './components/functions.vue'; +import FunctionDetails from './components/function_details.vue'; export default class Serverless { constructor() { - const { statusPath, clustersPath, helpPath, installed } = document.querySelector( - '.js-serverless-functions-page', - ).dataset; + if (document.querySelector('.js-serverless-function-details-page') != null) { + const { + serviceName, + serviceDescription, + serviceEnvironment, + serviceUrl, + serviceNamespace, + servicePodcount, + } = document.querySelector('.js-serverless-function-details-page').dataset; + const el = document.querySelector('#js-serverless-function-details'); + this.store = new ServerlessDetailsStore(); + const { store } = this; - this.service = new GetFunctionsService(statusPath); - this.knativeInstalled = installed !== undefined; - this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath); - this.initServerless(); - this.functionLoadCount = 0; + const service = { + name: serviceName, + description: serviceDescription, + environment: serviceEnvironment, + url: serviceUrl, + namespace: serviceNamespace, + podcount: servicePodcount, + }; - if (statusPath && this.knativeInstalled) { - this.initPolling(); + this.store.updateDetailedFunction(service); + this.functionDetails = new Vue({ + el, + data() { + return { + state: store.state, + }; + }, + render(createElement) { + return createElement(FunctionDetails, { + props: { + func: this.state.functionDetail, + }, + }); + }, + }); + } else { + const { statusPath, clustersPath, helpPath, installed } = document.querySelector( + '.js-serverless-functions-page', + ).dataset; + + this.service = new GetFunctionsService(statusPath); + this.knativeInstalled = installed !== undefined; + this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath); + this.initServerless(); + this.functionLoadCount = 0; + + if (statusPath && this.knativeInstalled) { + this.initPolling(); + } } } @@ -55,7 +97,7 @@ export default class Serverless { resource: this.service, method: 'fetchData', successCallback: data => this.handleSuccess(data), - errorCallback: () => this.handleError(), + errorCallback: () => Serverless.handleError(), }); if (!Visibility.hidden()) { @@ -64,7 +106,7 @@ export default class Serverless { this.service .fetchData() .then(data => this.handleSuccess(data)) - .catch(() => this.handleError()); + .catch(() => Serverless.handleError()); } Visibility.change(() => { @@ -102,5 +144,6 @@ export default class Serverless { } this.functions.$destroy(); + this.functionDetails.$destroy(); } } diff --git a/app/assets/javascripts/serverless/stores/serverless_details_store.js b/app/assets/javascripts/serverless/stores/serverless_details_store.js new file mode 100644 index 00000000000..5394d2cded1 --- /dev/null +++ b/app/assets/javascripts/serverless/stores/serverless_details_store.js @@ -0,0 +1,11 @@ +export default class ServerlessDetailsStore { + constructor() { + this.state = { + functionDetail: {}, + }; + } + + updateDetailedFunction(func) { + this.state.functionDetail = func; + } +} diff --git a/app/assets/javascripts/serverless/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js index 774c15b5b12..816d55a03f9 100644 --- a/app/assets/javascripts/serverless/stores/serverless_store.js +++ b/app/assets/javascripts/serverless/stores/serverless_store.js @@ -1,7 +1,7 @@ export default class ServerlessStore { constructor(knativeInstalled = false, clustersPath, helpPath) { this.state = { - functions: [], + functions: {}, hasFunctionData: true, loadingData: true, installed: knativeInstalled, @@ -10,8 +10,13 @@ export default class ServerlessStore { }; } - updateFunctionsFromServer(functions = []) { - this.state.functions = functions; + updateFunctionsFromServer(upstreamFunctions = []) { + this.state.functions = upstreamFunctions.reduce((rv, func) => { + const envs = rv; + envs[func.environment_scope] = (rv[func.environment_scope] || []).concat([func]); + + return envs; + }, {}); } updateLoadingState(loadingData) { diff --git a/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js b/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js index 14a89ef9293..3a8631a196f 100644 --- a/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js +++ b/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js @@ -12,9 +12,8 @@ class EmojiMenuInModal extends AwardsHandler { this.bindEvents(); } - postEmoji($emojiButton, awardUrl, selectedEmoji, callback) { + postEmoji($emojiButton, awardUrl, selectedEmoji) { this.selectEmojiCallback(selectedEmoji, this.emoji.glEmojiTag(selectedEmoji)); - callback(); } } 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 f04f7606976..e9ed05e30cd 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 @@ -66,19 +66,23 @@ export default { import(/* webpackChunkName: 'emoji' */ '~/emoji') .then(Emoji => { - if (this.emoji) { - this.emojiTag = Emoji.glEmojiTag(this.emoji); - } - this.noEmoji = this.emoji === ''; - this.defaultEmojiTag = Emoji.glEmojiTag('speech_balloon'); + Emoji.initEmojiMap() + .then(() => { + if (this.emoji) { + this.emojiTag = Emoji.glEmojiTag(this.emoji); + } + this.noEmoji = this.emoji === ''; + this.defaultEmojiTag = Emoji.glEmojiTag('speech_balloon'); - this.emojiMenu = new EmojiMenuInModal( - Emoji, - toggleEmojiMenuButtonSelector, - emojiMenuClass, - this.setEmoji, - this.$refs.userStatusForm, - ); + this.emojiMenu = new EmojiMenuInModal( + Emoji, + toggleEmojiMenuButtonSelector, + emojiMenuClass, + this.setEmoji, + this.$refs.userStatusForm, + ); + }) + .catch(() => createFlash(__('Failed to load emoji list.'))); }) .catch(() => createFlash(__('Failed to load emoji list.'))); }, @@ -219,7 +223,7 @@ export default { name="button" type="button" class="js-clear-user-status-button clear-user-status btn" - @click="clearStatusInputs();" + @click="clearStatusInputs()" > <icon name="close" /> </button> diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue index faea64c9841..c5cfa92f3c8 100644 --- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue @@ -104,9 +104,7 @@ export default { </div> <div class="title hide-collapsed"> - {{ - sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName }) - }} + {{ sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName }) }} <button v-if="isEditable" class="float-right lock-edit" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index d3a4f9c81e0..c03b2a68c78 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -102,13 +102,13 @@ export default { /> <div class="title hide-collapsed"> {{ __('Time tracking') }} - <div v-if="!showHelpState" class="help-button float-right" @click="toggleHelpState(true);"> + <div v-if="!showHelpState" class="help-button float-right" @click="toggleHelpState(true)"> <i class="fa fa-question-circle" aria-hidden="true"> </i> </div> <div v-if="showHelpState" class="close-help-button float-right" - @click="toggleHelpState(false);" + @click="toggleHelpState(false)" > <i class="fa fa-close" aria-hidden="true"> </i> </div> diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index edefb3735d7..5172a1ef3d6 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import 'deckar01-task_list'; +import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import Flash from './flash'; @@ -8,46 +9,79 @@ export default class TaskList { this.selector = options.selector; this.dataType = options.dataType; this.fieldName = options.fieldName; + this.lockVersion = options.lockVersion; + this.taskListContainerSelector = `${this.selector} .js-task-list-container`; + this.updateHandler = this.update.bind(this); this.onSuccess = options.onSuccess || (() => {}); - this.onError = function showFlash(e) { - let errorMessages = ''; + this.onError = + options.onError || + function showFlash(e) { + let errorMessages = ''; - if (e.response.data && typeof e.response.data === 'object') { - errorMessages = e.response.data.errors.join(' '); - } + if (e.response.data && typeof e.response.data === 'object') { + errorMessages = e.response.data.errors.join(' '); + } - return new Flash(errorMessages || 'Update failed', 'alert'); - }; + return new Flash(errorMessages || __('Update failed'), 'alert'); + }; this.init(); } init() { - // Prevent duplicate event bindings - this.disable(); - $(`${this.selector} .js-task-list-container`).taskList('enable'); - $(document).on( - 'tasklist:changed', - `${this.selector} .js-task-list-container`, - this.update.bind(this), - ); + this.disable(); // Prevent duplicate event bindings + + $(this.taskListContainerSelector).taskList('enable'); + $(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler); + } + + getTaskListTarget(e) { + return e && e.currentTarget ? $(e.currentTarget) : $(this.taskListContainerSelector); + } + + disableTaskListItems(e) { + this.getTaskListTarget(e).taskList('disable'); + } + + enableTaskListItems(e) { + this.getTaskListTarget(e).taskList('enable'); } disable() { - $(`${this.selector} .js-task-list-container`).taskList('disable'); - $(document).off('tasklist:changed', `${this.selector} .js-task-list-container`); + this.disableTaskListItems(); + $(document).off('tasklist:changed', this.taskListContainerSelector); } update(e) { const $target = $(e.target); + const { index, checked, lineNumber, lineSource } = e.detail; const patchData = {}; + patchData[this.dataType] = { [this.fieldName]: $target.val(), + lock_version: this.lockVersion, + update_task: { + index, + checked, + line_number: lineNumber, + line_source: lineSource, + }, }; + this.disableTaskListItems(e); + return axios .patch($target.data('updateUrl') || $('form.js-issuable-update').attr('action'), patchData) - .then(({ data }) => this.onSuccess(data)) - .catch(err => this.onError(err)); + .then(({ data }) => { + this.lockVersion = data.lock_version; + this.enableTaskListItems(e); + + return this.onSuccess(data); + }) + .catch(({ response }) => { + this.enableTaskListItems(e); + + return this.onError(response.data); + }); } } diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js index 560f50ebf8f..e5dd7a465ea 100644 --- a/app/assets/javascripts/terminal/terminal.js +++ b/app/assets/javascripts/terminal/terminal.js @@ -2,11 +2,13 @@ import _ from 'underscore'; import $ from 'jquery'; import { Terminal } from 'xterm'; import * as fit from 'xterm/lib/addons/fit/fit'; +import * as webLinks from 'xterm/lib/addons/webLinks/webLinks'; import { canScrollUp, canScrollDown } from '~/lib/utils/dom_utils'; const SCROLL_MARGIN = 5; Terminal.applyAddon(fit); +Terminal.applyAddon(webLinks); export default class GLTerminal { constructor(element, options = {}) { @@ -48,6 +50,7 @@ export default class GLTerminal { this.terminal.open(this.container); this.terminal.fit(); + this.terminal.webLinksInit(); this.terminal.focus(); this.socket.onopen = () => { diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index ce051582299..8c71615dff2 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -93,23 +93,22 @@ function UsersSelect(currentUser, els, options = {}) { } // Save current selected user to the DOM - const input = document.createElement('input'); - input.type = 'hidden'; - input.name = $dropdown.data('fieldName'); - - const currentUserInfo = $dropdown.data('currentUserInfo'); - - if (currentUserInfo) { - input.value = currentUserInfo.id; - input.dataset.meta = _.escape(currentUserInfo.name); - } else if (_this.currentUser) { - input.value = _this.currentUser.id; - } + const currentUserInfo = $dropdown.data('currentUserInfo') || {}; + const currentUser = _this.currentUser || {}; + const fieldName = $dropdown.data('fieldName'); + const userName = currentUserInfo.name; + const userId = currentUserInfo.id || currentUser.id; + + const inputHtmlString = _.template(` + <input type="hidden" name="<%- fieldName %>" + data-meta="<%- userName %>" + value="<%- userId %>" /> + `)({ fieldName, userName, userId }); if ($selectbox) { - $dropdown.parent().before(input); + $dropdown.parent().before(inputHtmlString); } else { - $dropdown.after(input); + $dropdown.after(inputHtmlString); } }; @@ -579,101 +578,109 @@ function UsersSelect(currentUser, els, options = {}) { }; })(this), ); - $('.ajax-users-select').each( - (function(_this) { - return function(i, select) { - var firstUser, showAnyUser, showEmailUser, showNullUser; - var options = {}; - options.skipLdap = $(select).hasClass('skip_ldap'); - options.projectId = $(select).data('projectId'); - options.groupId = $(select).data('groupId'); - options.showCurrentUser = $(select).data('currentUser'); - options.authorId = $(select).data('authorId'); - options.skipUsers = $(select).data('skipUsers'); - showNullUser = $(select).data('nullUser'); - showAnyUser = $(select).data('anyUser'); - showEmailUser = $(select).data('emailUser'); - firstUser = $(select).data('firstUser'); - return $(select).select2({ - placeholder: 'Search for a user', - multiple: $(select).hasClass('multiselect'), - minimumInputLength: 0, - query: function(query) { - return _this.users(query.term, options, function(users) { - var anyUser, data, emailUser, index, len, name, nullUser, obj, ref; - data = { - results: users, - }; - if (query.term.length === 0) { - if (firstUser) { - // Move current user to the front of the list - ref = data.results; - - for (index = 0, len = ref.length; index < len; index += 1) { - obj = ref[index]; - if (obj.username === firstUser) { - data.results.splice(index, 1); - data.results.unshift(obj); - break; + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('.ajax-users-select').each( + (function(_this) { + return function(i, select) { + var firstUser, showAnyUser, showEmailUser, showNullUser; + var options = {}; + options.skipLdap = $(select).hasClass('skip_ldap'); + options.projectId = $(select).data('projectId'); + options.groupId = $(select).data('groupId'); + options.showCurrentUser = $(select).data('currentUser'); + options.authorId = $(select).data('authorId'); + options.skipUsers = $(select).data('skipUsers'); + showNullUser = $(select).data('nullUser'); + showAnyUser = $(select).data('anyUser'); + showEmailUser = $(select).data('emailUser'); + firstUser = $(select).data('firstUser'); + return $(select).select2({ + placeholder: 'Search for a user', + multiple: $(select).hasClass('multiselect'), + minimumInputLength: 0, + query: function(query) { + return _this.users(query.term, options, function(users) { + var anyUser, data, emailUser, index, len, name, nullUser, obj, ref; + data = { + results: users, + }; + if (query.term.length === 0) { + if (firstUser) { + // Move current user to the front of the list + ref = data.results; + + for (index = 0, len = ref.length; index < len; index += 1) { + obj = ref[index]; + if (obj.username === firstUser) { + data.results.splice(index, 1); + data.results.unshift(obj); + break; + } + } + } + if (showNullUser) { + nullUser = { + name: 'Unassigned', + id: 0, + }; + data.results.unshift(nullUser); + } + if (showAnyUser) { + name = showAnyUser; + if (name === true) { + name = 'Any User'; + } + anyUser = { + name: name, + id: null, + }; + data.results.unshift(anyUser); } } - } - if (showNullUser) { - nullUser = { - name: 'Unassigned', - id: 0, - }; - data.results.unshift(nullUser); - } - if (showAnyUser) { - name = showAnyUser; - if (name === true) { - name = 'Any User'; + if ( + showEmailUser && + data.results.length === 0 && + query.term.match(/^[^@]+@[^@]+$/) + ) { + var trimmed = query.term.trim(); + emailUser = { + name: 'Invite "' + trimmed + '" by email', + username: trimmed, + id: trimmed, + invite: true, + }; + data.results.unshift(emailUser); } - anyUser = { - name: name, - id: null, - }; - data.results.unshift(anyUser); - } - } - if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { - var trimmed = query.term.trim(); - emailUser = { - name: 'Invite "' + trimmed + '" by email', - username: trimmed, - id: trimmed, - invite: true, - }; - data.results.unshift(emailUser); - } - return query.callback(data); + return query.callback(data); + }); + }, + initSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.initSelection.apply(_this, args); + }, + formatResult: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatResult.apply(_this, args); + }, + formatSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatSelection.apply(_this, args); + }, + dropdownCssClass: 'ajax-users-dropdown', + // we do not want to escape markup since we are displaying html in results + escapeMarkup: function(m) { + return m; + }, }); - }, - initSelection: function() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.initSelection.apply(_this, args); - }, - formatResult: function() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.formatResult.apply(_this, args); - }, - formatSelection: function() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.formatSelection.apply(_this, args); - }, - dropdownCssClass: 'ajax-users-dropdown', - // we do not want to escape markup since we are displaying html in results - escapeMarkup: function(m) { - return m; - }, - }); - }; - })(this), - ); + }; + })(this), + ); + }) + .catch(() => {}); } UsersSelect.prototype.initSelection = function(element, callback) { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue index 2f2a37347af..da0a9483f8e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -54,6 +54,12 @@ export default { deployTimeago() { return this.timeFormated(this.deployment.deployed_at); }, + deploymentExternalUrl() { + if (this.deployment.changes && this.deployment.changes.length === 1) { + return this.deployment.changes[0].external_url; + } + return this.deployment.external_url; + }, hasExternalUrls() { return !!(this.deployment.external_url && this.deployment.external_url_formatted); }, @@ -78,7 +84,7 @@ export default { : ''; }, shouldRenderDropdown() { - return this.deployment.changes && this.deployment.changes.length > 0; + return this.deployment.changes && this.deployment.changes.length > 1; }, showMemoryUsage() { return this.hasMetrics && this.showMetrics; @@ -154,12 +160,12 @@ export default { v-if="shouldRenderDropdown" class="js-mr-wigdet-deployment-dropdown inline" :items="deployment.changes" - :main-action-link="deployment.external_url" + :main-action-link="deploymentExternalUrl" filter-key="path" > <template slot="mainAction" slot-scope="slotProps"> <review-app-link - :link="deployment.external_url" + :link="deploymentExternalUrl" :css-class="`deploy-link js-deploy-url inline ${slotProps.className}`" /> </template> @@ -183,7 +189,7 @@ export default { </filtered-search-dropdown> <review-app-link v-else - :link="deployment.external_url" + :link="deploymentExternalUrl" css-class="js-deploy-url js-deploy-url-feature-flag deploy-link btn btn-default btn-sm inlin" /> </template> 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 3b9fc2661ef..50ab7ead582 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 @@ -135,7 +135,7 @@ export default { <span class="dropdown"> <button type="button" - class="btn dropdown-toggle" + class="btn dropdown-toggle qa-dropdown-toggle" data-toggle="dropdown" aria-label="Download as" aria-haspopup="true" @@ -145,12 +145,20 @@ export default { </button> <ul class="dropdown-menu dropdown-menu-right"> <li> - <a :href="mr.emailPatchesPath" class="js-download-email-patches" download> + <a + :href="mr.emailPatchesPath" + class="js-download-email-patches qa-download-email-patches" + download + > {{ s__('mrWidget|Email patches') }} </a> </li> <li> - <a :href="mr.plainDiffPath" class="js-download-plain-diff" download> + <a + :href="mr.plainDiffPath" + class="js-download-plain-diff qa-download-plain-diff" + download + > {{ s__('mrWidget|Plain diff') }} </a> </li> 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 f11cf21b0ca..9e63aa00341 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 @@ -5,6 +5,7 @@ import PipelineStage from '~/pipelines/components/stage.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline'; export default { name: 'MRWidgetPipeline', @@ -13,7 +14,10 @@ export default { CiIcon, Icon, TooltipOnTruncate, + LinkedPipelinesMiniList: () => + import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), }, + mixins: [mrWidgetPipelineMixin], props: { pipeline: { type: Object, @@ -82,8 +86,7 @@ export default { <div v-if="hasPipeline || hasCIError" class="ci-widget media"> <template v-if="hasCIError"> <div - class="add-border ci-status-icon ci-status-icon-failed ci-error - js-ci-error append-right-default" + class="add-border ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-default" > <icon :size="32" name="status_failed_borderless" /> </div> @@ -101,16 +104,13 @@ export default { <a :href="pipeline.path" class="pipeline-id font-weight-normal pipeline-number" >#{{ pipeline.id }}</a > - {{ pipeline.details.status.label }} - <template v-if="hasCommitInfo"> for <a :href="pipeline.commit.commit_path" class="commit-sha js-commit-link font-weight-normal" - > - {{ pipeline.commit.short_id }}</a + >{{ pipeline.commit.short_id }}</a > on <tooltip-on-truncate @@ -126,15 +126,22 @@ export default { </div> <div> <span class="mr-widget-pipeline-graph"> - <span v-if="hasStages" class="stage-cell"> - <div - v-for="(stage, i) in pipeline.details.stages" - :key="i" - class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages" - > - <pipeline-stage :stage="stage" /> - </div> + <span class="stage-cell"> + <linked-pipelines-mini-list v-if="triggeredBy.length" :triggered-by="triggeredBy" /> + <template v-if="hasStages"> + <div + v-for="(stage, i) in pipeline.details.stages" + :key="i" + :class="{ + 'has-downstream': hasDownstream(i), + }" + class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages" + > + <pipeline-stage :stage="stage" /> + </div> + </template> </span> + <linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" /> </span> </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 dd940548e30..780ecdcdac4 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 @@ -7,7 +7,7 @@ export default { tooltip, }, created() { - this.removesBranchText = __('<strong>Removes</strong> source branch'); + this.removesBranchText = __('<strong>Deletes</strong> source branch'); this.tooltipTitle = __('A user with write access to the source branch selected this option'); }, }; 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 new file mode 100644 index 00000000000..a38f25cce35 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue @@ -0,0 +1,40 @@ +<script> +export default { + props: { + value: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + inputId: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <li> + <div class="commit-message-editor"> + <div class="d-flex flex-wrap align-items-center justify-content-between"> + <label class="col-form-label" :for="inputId"> + <strong>{{ label }}</strong> + </label> + <slot name="header"></slot> + </div> + <textarea + :id="inputId" + :value="value" + class="form-control js-gfm-input append-bottom-default commit-message-edit" + required="required" + rows="7" + @input="$emit('input', $event.target.value)" + ></textarea> + <slot name="checkbox"></slot> + </div> + </li> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue new file mode 100644 index 00000000000..b6722de5277 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue @@ -0,0 +1,37 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; + +export default { + components: { + GlDropdown, + GlDropdownItem, + }, + props: { + commits: { + type: Array, + required: true, + default: () => [], + }, + }, +}; +</script> + +<template> + <div> + <gl-dropdown + right + text="Use an existing commit message" + variant="link" + class="mr-commit-dropdown" + > + <gl-dropdown-item + v-for="commit in commits" + :key="commit.short_id" + class="text-nowrap text-truncate" + @click="$emit('input', commit.message)" + > + <span class="monospace mr-2">{{ commit.short_id }}</span> {{ commit.title }} + </gl-dropdown-item> + </gl-dropdown> + </div> +</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 new file mode 100644 index 00000000000..33963d5e1e6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue @@ -0,0 +1,91 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import _ from 'underscore'; +import { __, n__, sprintf, s__ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + GlButton, + }, + props: { + isSquashEnabled: { + type: Boolean, + required: true, + }, + commitsCount: { + type: Number, + required: false, + default: 0, + }, + targetBranch: { + type: String, + required: true, + }, + }, + data() { + return { + expanded: false, + }; + }, + computed: { + collapseIcon() { + return this.expanded ? 'chevron-down' : 'chevron-right'; + }, + commitsCountMessage() { + return n__(__('%d commit'), __('%d commits'), this.isSquashEnabled ? 1 : this.commitsCount); + }, + modifyLinkMessage() { + return this.isSquashEnabled ? __('Modify commit messages') : __('Modify merge commit'); + }, + ariaLabel() { + return this.expanded ? __('Collapse') : __('Expand'); + }, + message() { + return sprintf( + s__( + 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}.', + ), + { + commitCount: `<strong class="commits-count-message">${this.commitsCountMessage}</strong>`, + mergeCommitCount: `<strong>${s__('mrWidgetCommitsAdded|1 merge commit')}</strong>`, + targetBranch: `<span class="label-branch">${_.escape(this.targetBranch)}</span>`, + }, + false, + ); + }, + }, + methods: { + toggle() { + this.expanded = !this.expanded; + }, + }, +}; +</script> + +<template> + <div> + <div + class="js-mr-widget-commits-count mr-widget-extension clickable d-flex align-items-center px-3 py-2" + @click="toggle()" + > + <gl-button + :aria-label="ariaLabel" + variant="blank" + class="commit-edit-toggle square s24 mr-2" + @click.stop="toggle()" + > + <icon :name="collapseIcon" :size="16" /> + </gl-button> + <span v-if="expanded">{{ __('Collapse') }}</span> + <span v-else> + <span class="vertical-align-middle" v-html="message"></span> + <gl-button variant="link" class="modify-message-button"> + {{ modifyLinkMessage }} + </gl-button> + </span> + </div> + <div v-show="expanded"><slot></slot></div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index 27352e0b2b1..f6f445c1cef 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -1,10 +1,14 @@ <script> -import statusIcon from '../mr_widget_status_icon.vue'; +import $ from 'jquery'; +import _ from 'underscore'; +import { s__, sprintf } from '~/locale'; +import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover'; +import StatusIcon from '../mr_widget_status_icon.vue'; export default { name: 'MRWidgetConflicts', components: { - statusIcon, + StatusIcon, }, props: { /* TODO: This is providing all store and service down when it @@ -15,6 +19,52 @@ export default { default: () => ({}), }, }, + computed: { + popoverTitle() { + return s__( + 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.', + ); + }, + showResolveButton() { + return this.mr.conflictResolutionPath && this.mr.canMerge; + }, + showPopover() { + return this.showResolveButton && this.mr.sourceBranchProtected; + }, + }, + mounted() { + if (this.showPopover) { + const $el = $(this.$refs.popover); + + $el + .popover({ + html: true, + trigger: 'focus', + container: 'body', + placement: 'top', + template: + '<div class="popover" role="tooltip"><div class="arrow"></div><p class="popover-header"></p><div class="popover-body"></div></div>', + title: s__( + 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.', + ), + content: sprintf( + s__('mrWidget|%{link_start}Learn more about resolving conflicts%{link_end}'), + { + link_start: `<a href="${_.escape( + this.mr.conflictsDocsPath, + )}" target="_blank" rel="noopener noreferrer">`, + link_end: '</a>', + }, + false, + ), + }) + .on('mouseenter', mouseenter) + .on('mouseleave', debouncedMouseleave(300)) + .on('show.bs.popover', () => { + window.addEventListener('scroll', togglePopover.bind($el, false), { once: true }); + }); + } + }, }; </script> <template> @@ -38,13 +88,15 @@ To merge this request, first rebase locally.`) }} </span> </span> - <a - v-if="mr.canMerge && mr.conflictResolutionPath" - :href="mr.conflictResolutionPath" - class="js-resolve-conflicts-button btn btn-default btn-sm" - > - {{ s__('mrWidget|Resolve conflicts') }} - </a> + <span v-if="showResolveButton" ref="popover"> + <a + :href="mr.conflictResolutionPath" + :disabled="mr.sourceBranchProtected" + class="js-resolve-conflicts-button btn btn-default btn-sm" + > + {{ s__('mrWidget|Resolve conflicts') }} + </a> + </span> <button v-if="mr.canMerge" class="js-merge-locally-button btn btn-default btn-sm" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue index 02c76db4a50..1b3af2fccf2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue @@ -106,11 +106,11 @@ export default { <a :href="mr.targetBranchPath" class="label-branch"> {{ mr.targetBranch }} </a> </p> <p v-if="mr.shouldRemoveSourceBranch"> - {{ s__('mrWidget|The source branch will be removed') }} + {{ s__('mrWidget|The source branch will be deleted') }} </p> <p v-else class="d-flex align-items-start"> <span class="append-right-10"> - {{ s__('mrWidget|The source branch will not be removed') }} + {{ s__('mrWidget|The source branch will not be deleted') }} </span> <a v-if="canRemoveSourceBranch" @@ -121,7 +121,7 @@ export default { @click.prevent="removeSourceBranch" > <i v-if="isRemovingSourceBranch" class="fa fa-spinner fa-spin" aria-hidden="true"> </i> - {{ s__('mrWidget|Remove source branch') }} + {{ s__('mrWidget|Delete source branch') }} </a> </p> </section> 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 fe83fe58b67..b9562fbc260 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 @@ -84,7 +84,7 @@ export default { .removeSourceBranch() .then(res => res.data) .then(data => { - if (data.message === 'Branch was removed') { + if (data.message === 'Branch was deleted') { eventHub.$emit('MRWidgetUpdateRequested', () => { this.isMakingRequest = false; }); @@ -174,22 +174,22 @@ export default { </template> </p> <p v-if="mr.sourceBranchRemoved"> - {{ s__('mrWidget|The source branch has been removed') }} + {{ s__('mrWidget|The source branch has been deleted') }} </p> <p v-if="shouldShowRemoveSourceBranch" class="space-children"> - <span>{{ s__('mrWidget|You can remove source branch now') }}</span> + <span>{{ s__('mrWidget|You can delete the source branch now') }}</span> <button :disabled="isMakingRequest" type="button" class="btn btn-sm btn-default js-remove-branch-button" @click="removeSourceBranch" > - {{ s__('mrWidget|Remove Source Branch') }} + {{ s__('mrWidget|Delete source branch') }} </button> </p> <p v-if="shouldShowSourceBranchRemoving"> <gl-loading-icon :inline="true" /> - <span> {{ s__('mrWidget|The source branch is being removed') }} </span> + <span> {{ s__('mrWidget|The source branch is being deleted') }} </span> </p> </section> </div> 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 84c8a3464a5..9b4e80ee3a3 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 @@ -2,17 +2,24 @@ import successSvg from 'icons/_icon_status_success.svg'; import warningSvg from 'icons/_icon_status_warning.svg'; import simplePoll from '~/lib/utils/simple_poll'; +import { __ } from '~/locale'; import MergeRequest from '../../../merge_request'; import Flash from '../../../flash'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; import SquashBeforeMerge from './squash_before_merge.vue'; +import CommitsHeader from './commits_header.vue'; +import CommitEdit from './commit_edit.vue'; +import CommitMessageDropdown from './commit_message_dropdown.vue'; export default { name: 'ReadyToMerge', components: { statusIcon, - 'squash-before-merge': SquashBeforeMerge, + SquashBeforeMerge, + CommitsHeader, + CommitEdit, + CommitMessageDropdown, }, props: { mr: { type: Object, required: true }, @@ -22,26 +29,20 @@ export default { return { removeSourceBranch: this.mr.shouldRemoveSourceBranch, mergeWhenBuildSucceeds: false, - useCommitMessageWithDescription: false, setToMergeWhenPipelineSucceeds: false, - showCommitMessageEditor: false, isMakingRequest: false, isMergingImmediately: false, commitMessage: this.mr.commitMessage, + squashBeforeMerge: this.mr.squash, successSvg, warningSvg, + squashCommitMessage: this.mr.squashCommitMessage, }; }, computed: { shouldShowMergeWhenPipelineSucceedsText() { return this.mr.isPipelineActive; }, - commitMessageLinkTitle() { - const withDesc = 'Include description in commit message'; - const withoutDesc = "Don't include description in commit message"; - - return this.useCommitMessageWithDescription ? withoutDesc : withDesc; - }, status() { const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr; @@ -83,9 +84,9 @@ export default { }, mergeButtonText() { if (this.isMergingImmediately) { - return 'Merge in progress'; + return __('Merge in progress'); } else if (this.shouldShowMergeWhenPipelineSucceedsText) { - return 'Merge when pipeline succeeds'; + return __('Merge when pipeline succeeds'); } return 'Merge'; @@ -97,7 +98,7 @@ export default { const { commitMessage } = this; return Boolean( !commitMessage.length || - !this.shouldShowMergeControls() || + !this.shouldShowMergeControls || this.isMakingRequest || this.mr.preventMerge, ); @@ -109,24 +110,14 @@ export default { const { commitsCount, enableSquashBeforeMerge } = this.mr; return enableSquashBeforeMerge && commitsCount > 1; }, - }, - created() { - eventHub.$on('MRWidgetUpdateSquash', this.handleUpdateSquash); - }, - beforeDestroy() { - eventHub.$off('MRWidgetUpdateSquash', this.handleUpdateSquash); - }, - methods: { shouldShowMergeControls() { return this.mr.isMergeAllowed || this.shouldShowMergeWhenPipelineSucceedsText; }, - updateCommitMessage() { - const cmwd = this.mr.commitMessageWithDescription; - this.useCommitMessageWithDescription = !this.useCommitMessageWithDescription; - this.commitMessage = this.useCommitMessageWithDescription ? cmwd : this.mr.commitMessage; - }, - toggleCommitMessageEditor() { - this.showCommitMessageEditor = !this.showCommitMessageEditor; + }, + methods: { + updateMergeCommitMessage(includeDescription) { + const { commitMessageWithDescription, commitMessage } = this.mr; + this.commitMessage = includeDescription ? commitMessageWithDescription : commitMessage; }, handleMergeButtonClick(mergeWhenBuildSucceeds, mergeImmediately) { // TODO: Remove no-param-reassign @@ -143,7 +134,8 @@ export default { commit_message: this.commitMessage, merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds, should_remove_source_branch: this.removeSourceBranch === true, - squash: this.mr.squash, + squash: this.squashBeforeMerge, + squash_commit_message: this.squashCommitMessage, }; this.isMakingRequest = true; @@ -163,12 +155,9 @@ export default { }) .catch(() => { this.isMakingRequest = false; - new Flash('Something went wrong. Please try again.'); // eslint-disable-line + new Flash(__('Something went wrong. Please try again.')); // eslint-disable-line }); }, - handleUpdateSquash(val) { - this.mr.squash = val; - }, initiateMergePolling() { simplePoll((continuePolling, stopPolling) => { this.handleMergePolling(continuePolling, stopPolling); @@ -202,7 +191,7 @@ export default { } }) .catch(() => { - new Flash('Something went wrong while merging this merge request. Please try again.'); // eslint-disable-line + new Flash(__('Something went wrong while merging this merge request. Please try again.')); // eslint-disable-line }); }, initiateRemoveSourceBranchPolling() { @@ -231,7 +220,7 @@ export default { } }) .catch(() => { - new Flash('Something went wrong while removing the source branch. Please try again.'); // eslint-disable-line + new Flash(__('Something went wrong while deleting the source branch. Please try again.')); // eslint-disable-line }); }, }, @@ -239,126 +228,136 @@ export default { </script> <template> - <div class="mr-widget-body media"> - <status-icon :status="iconClass" /> - <div class="media-body"> - <div class="mr-widget-body-controls media space-children"> - <span class="btn-group"> - <button - :disabled="isMergeButtonDisabled" - :class="mergeButtonClass" - type="button" - class="qa-merge-button" - @click="handleMergeButtonClick();" - > - <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"></i> - {{ mergeButtonText }} - </button> - <button - v-if="shouldShowMergeOptionsDropdown" - :disabled="isMergeButtonDisabled" - type="button" - class="btn btn-sm btn-info dropdown-toggle js-merge-moment" - data-toggle="dropdown" - aria-label="Select merge moment" - > - <i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i> - </button> - <ul - v-if="shouldShowMergeOptionsDropdown" - class="dropdown-menu dropdown-menu-right" - role="menu" - > - <li> - <a - class="merge_when_pipeline_succeeds qa-merge-when-pipeline-succeeds-option" - href="#" - @click.prevent="handleMergeButtonClick(true);" - > - <span class="media"> - <span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span> - <span class="media-body merge-opt-title">Merge when pipeline succeeds</span> - </span> - </a> - </li> - <li> - <a - class="accept-merge-request qa-merge-immediately-option" - href="#" - @click.prevent="handleMergeButtonClick(false, true);" - > - <span class="media"> - <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span> - <span class="media-body merge-opt-title">Merge immediately</span> - </span> - </a> - </li> - </ul> - </span> - <div class="media-body-wrap space-children"> - <template v-if="shouldShowMergeControls()"> - <label v-if="mr.canRemoveSourceBranch"> - <input - id="remove-source-branch-input" - v-model="removeSourceBranch" - :disabled="isRemoveSourceBranchButtonDisabled" - class="js-remove-source-branch-checkbox" - type="checkbox" - /> - Remove source branch - </label> - - <!-- Placeholder for EE extension of this component --> - <squash-before-merge - v-if="shouldShowSquashBeforeMerge" - :mr="mr" - :is-merge-button-disabled="isMergeButtonDisabled" - /> - - <span v-if="mr.ffOnlyEnabled" class="js-fast-forward-message"> - Fast-forward merge without a merge commit - </span> + <div> + <div class="mr-widget-body media"> + <status-icon :status="iconClass" /> + <div class="media-body"> + <div class="mr-widget-body-controls media space-children"> + <span class="btn-group"> <button - v-else :disabled="isMergeButtonDisabled" - class="js-modify-commit-message-button btn btn-default btn-sm" + :class="mergeButtonClass" type="button" - @click="toggleCommitMessageEditor" + class="qa-merge-button" + @click="handleMergeButtonClick()" > - Modify commit message + <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"></i> + {{ mergeButtonText }} </button> - </template> - <template v-else> - <span class="bold js-resolve-mr-widget-items-message"> - You can only merge once the items above are resolved - </span> - </template> - </div> - </div> - <div v-if="showCommitMessageEditor" class="prepend-top-default commit-message-editor"> - <div class="form-group clearfix"> - <label class="col-form-label" for="commit-message"> Commit message </label> - <div class="col-sm-10"> - <div class="commit-message-container"> - <div class="max-width-marker"></div> - <textarea - id="commit-message" - v-model="commitMessage" - class="form-control js-commit-message" - required="required" - rows="14" - name="Commit message" - ></textarea> - </div> - <p class="hint"> - Try to keep the first line under 52 characters and the others under 72 - </p> - <div class="hint"> - <a href="#" @click.prevent="updateCommitMessage"> {{ commitMessageLinkTitle }} </a> - </div> + <button + v-if="shouldShowMergeOptionsDropdown" + :disabled="isMergeButtonDisabled" + type="button" + class="btn btn-sm btn-info dropdown-toggle js-merge-moment" + data-toggle="dropdown" + aria-label="Select merge moment" + > + <i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i> + </button> + <ul + v-if="shouldShowMergeOptionsDropdown" + class="dropdown-menu dropdown-menu-right" + role="menu" + > + <li> + <a + class="merge_when_pipeline_succeeds qa-merge-when-pipeline-succeeds-option" + href="#" + @click.prevent="handleMergeButtonClick(true)" + > + <span class="media"> + <span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span> + <span class="media-body merge-opt-title">{{ + __('Merge when pipeline succeeds') + }}</span> + </span> + </a> + </li> + <li> + <a + class="accept-merge-request qa-merge-immediately-option" + href="#" + @click.prevent="handleMergeButtonClick(false, true)" + > + <span class="media"> + <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span> + <span class="media-body merge-opt-title">{{ __('Merge immediately') }}</span> + </span> + </a> + </li> + </ul> + </span> + <div class="media-body-wrap space-children"> + <template v-if="shouldShowMergeControls"> + <label v-if="mr.canRemoveSourceBranch"> + <input + id="remove-source-branch-input" + v-model="removeSourceBranch" + :disabled="isRemoveSourceBranchButtonDisabled" + class="js-remove-source-branch-checkbox" + type="checkbox" + /> + {{ __('Delete source branch') }} + </label> + + <!-- Placeholder for EE extension of this component --> + <squash-before-merge + v-if="shouldShowSquashBeforeMerge" + v-model="squashBeforeMerge" + :help-path="mr.squashBeforeMergeHelpPath" + :is-disabled="isMergeButtonDisabled" + /> + </template> + <template v-else> + <span class="bold js-resolve-mr-widget-items-message"> + {{ __('You can only merge once the items above are resolved') }} + </span> + </template> </div> </div> </div> </div> + <template v-if="shouldShowMergeControls"> + <div v-if="mr.ffOnlyEnabled" class="mr-fast-forward-message"> + {{ __('Fast-forward merge without a merge commit') }} + </div> + <template v-else> + <commits-header + :is-squash-enabled="squashBeforeMerge" + :commits-count="mr.commitsCount" + :target-branch="mr.targetBranch" + > + <ul class="border-top content-list commits-list flex-list"> + <commit-edit + v-if="squashBeforeMerge && shouldShowSquashBeforeMerge" + v-model="squashCommitMessage" + :label="__('Squash commit message')" + input-id="squash-message-edit" + squash + > + <commit-message-dropdown + slot="header" + v-model="squashCommitMessage" + :commits="mr.commits" + /> + </commit-edit> + <commit-edit + v-model="commitMessage" + :label="__('Merge commit message')" + input-id="merge-message-edit" + > + <label slot="checkbox"> + <input + id="include-description" + type="checkbox" + @change="updateMergeCommitMessage($event.target.checked)" + /> + {{ __('Include merge request description') }} + </label> + </commit-edit> + </ul> + </commits-header> + </template> + </template> </div> </template> 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 e71acf0d7dd..b1f5655a15a 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,6 +1,5 @@ <script> import Icon from '~/vue_shared/components/icon.vue'; -import eventHub from '~/vue_merge_request_widget/event_hub'; import tooltip from '~/vue_shared/directives/tooltip'; export default { @@ -11,23 +10,19 @@ export default { tooltip, }, props: { - mr: { - type: Object, - required: true, - }, - isMergeButtonDisabled: { + value: { type: Boolean, required: true, }, - }, - data() { - return { - squashBeforeMerge: this.mr.squash, - }; - }, - methods: { - updateSquashModel() { - eventHub.$emit('MRWidgetUpdateSquash', this.squashBeforeMerge); + helpPath: { + type: String, + required: false, + default: '', + }, + isDisabled: { + type: Boolean, + required: false, + default: false, }, }, }; @@ -37,18 +32,19 @@ export default { <div class="accept-control inline"> <label class="merge-param-checkbox"> <input - v-model="squashBeforeMerge" - :disabled="isMergeButtonDisabled" + :checked="value" + :disabled="isDisabled" type="checkbox" name="squash" class="qa-squash-checkbox" - @change="updateSquashModel" + @change="$emit('input', $event.target.checked)" /> {{ __('Squash commits') }} </label> <a + v-if="helpPath" v-tooltip - :href="mr.squashBeforeMergeHelpPath" + :href="helpPath" data-title="About this feature" data-placement="bottom" target="_blank" diff --git a/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js deleted file mode 100644 index 8780aa4bd1c..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js +++ /dev/null @@ -1,3 +0,0 @@ -import MRWidgetOptions from './mr_widget_options.vue'; - -export default MRWidgetOptions; diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index 60cebbfc2b2..0cedbdbdfef 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import MrWidgetOptions from './ee_switch_mr_widget_options'; +import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue'; import Translate from '../vue_shared/translate'; Vue.use(Translate); diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js new file mode 100644 index 00000000000..96e8bb45e34 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js @@ -0,0 +1,15 @@ +export default { + computed: { + triggered() { + return []; + }, + triggeredBy() { + return []; + }, + }, + methods: { + hasDownstream() { + return false; + }, + }, +}; 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 d8a75388e84..57c4dfbe3b7 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 @@ -3,6 +3,9 @@ import _ from 'underscore'; import { __ } from '~/locale'; import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; +import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; +import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; +import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; import createFlash from '../flash'; import WidgetHeader from './components/mr_widget_header.vue'; import WidgetMergeHelp from './components/mr_widget_merge_help.vue'; @@ -28,11 +31,7 @@ import FailedToMerge from './components/states/mr_widget_failed_to_merge.vue'; import MergeWhenPipelineSucceedsState from './components/states/mr_widget_merge_when_pipeline_succeeds.vue'; import AutoMergeFailed from './components/states/mr_widget_auto_merge_failed.vue'; import CheckingState from './components/states/mr_widget_checking.vue'; -import MRWidgetStore from './stores/ee_switch_mr_widget_store'; -import MRWidgetService from './services/ee_switch_mr_widget_service'; import eventHub from './event_hub'; -import stateMaps from './stores/ee_switch_state_maps'; -import SquashBeforeMerge from './components/states/squash_before_merge.vue'; import notify from '~/lib/utils/notify'; import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue'; import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue'; @@ -59,7 +58,6 @@ export default { 'mr-widget-missing-branch': MissingBranchState, 'mr-widget-ready-to-merge': ReadyToMergeState, 'sha-mismatch': ShaMismatchState, - 'mr-widget-squash-before-merge': SquashBeforeMerge, 'mr-widget-checking': CheckingState, 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState, 'mr-widget-pipeline-blocked': PipelineBlockedState, @@ -106,6 +104,9 @@ export default { (!this.mr.isNothingToMergeState && !this.mr.isMergedState) ); }, + shouldRenderCollaborationStatus() { + return this.mr.allowCollaboration && this.mr.isOpen; + }, shouldRenderMergedPipeline() { return this.mr.state === 'merged' && !_.isEmpty(this.mr.mergePipeline); }, @@ -141,8 +142,8 @@ export default { } }, methods: { - createService(store) { - const endpoints = { + getServiceEndpoints(store) { + return { mergePath: store.mergePath, mergeCheckPath: store.mergeCheckPath, cancelAutoMergePath: store.cancelAutoMergePath, @@ -153,7 +154,9 @@ export default { mergeActionsContentPath: store.mergeActionsContentPath, rebasePath: store.rebasePath, }; - return new MRWidgetService(endpoints); + }, + createService(store) { + return new MRWidgetService(this.getServiceEndpoints(store)); }, checkStatus(cb, isRebased) { return this.service @@ -315,7 +318,7 @@ export default { <div class="mr-widget-section"> <component :is="componentName" :mr="mr" :service="service" /> - <section v-if="mr.allowCollaboration" class="mr-info-list mr-links"> + <section v-if="shouldRenderCollaborationStatus" class="mr-info-list mr-links"> {{ s__('mrWidget|Allows commits from members who can merge to the target branch') }} </section> diff --git a/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js deleted file mode 100644 index ea2aabb78fe..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js +++ /dev/null @@ -1,3 +0,0 @@ -import MRWidgetService from './mr_widget_service'; - -export default MRWidgetService; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js deleted file mode 100644 index ebef30e3eab..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js +++ /dev/null @@ -1,3 +0,0 @@ -import getStateKey from './get_state_key'; - -export default getStateKey; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js deleted file mode 100644 index 92a07c53f2d..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js +++ /dev/null @@ -1,3 +0,0 @@ -import MergeRequestStore from './mr_widget_store'; - -export default MergeRequestStore; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js deleted file mode 100644 index 50cf9503ea7..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js +++ /dev/null @@ -1,3 +0,0 @@ -import stateMaps from './state_maps'; - -export default stateMaps; 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 066a3b833d7..0cc4fd59f5e 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 @@ -13,6 +13,8 @@ export default function deviseState(data) { return stateKey.conflicts; } else if (data.work_in_progress) { return stateKey.workInProgress; + } else if (this.shouldBeRebased) { + return stateKey.rebase; } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { return stateKey.pipelineFailed; } else if (this.hasMergeableDiscussionsState) { @@ -25,8 +27,6 @@ export default function deviseState(data) { return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds; } else if (!this.canMerge) { return stateKey.notAllowedToMerge; - } else if (this.shouldBeRebased) { - return stateKey.rebase; } else if (this.canBeMerged) { return stateKey.readyToMerge; } 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 c777bcca0fa..58363f632a9 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 @@ -1,5 +1,5 @@ import Timeago from 'timeago.js'; -import getStateKey from './ee_switch_get_state_key'; +import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key'; import { stateKey } from './state_maps'; import { formatDate } from '../../lib/utils/datetime_utility'; @@ -29,17 +29,21 @@ export default class MergeRequestStore { this.title = data.title; this.targetBranch = data.target_branch; this.sourceBranch = data.source_branch; + this.sourceBranchProtected = data.source_branch_protected; + this.conflictsDocsPath = data.conflicts_docs_path; this.mergeStatus = data.merge_status; - this.commitMessage = data.merge_commit_message; + this.commitMessage = data.default_merge_commit_message; this.shortMergeCommitSha = data.short_merge_commit_sha; this.mergeCommitSha = data.merge_commit_sha; - this.commitMessageWithDescription = data.merge_commit_message_with_description; + this.commitMessageWithDescription = data.default_merge_commit_message_with_description; this.commitsCount = data.commits_count; this.divergedCommitsCount = data.diverged_commits_count; this.pipeline = data.pipeline || {}; this.mergePipeline = data.merge_pipeline || {}; this.deployments = this.deployments || data.deployments || []; this.postMergeDeployments = this.postMergeDeployments || []; + this.commits = data.commits_without_merge_commits || []; + this.squashCommitMessage = data.default_squash_commit_message; this.initRebase(data); if (data.issues_links) { diff --git a/app/assets/javascripts/vue_shared/components/bar_chart.vue b/app/assets/javascripts/vue_shared/components/bar_chart.vue index 4abf795f7bd..eabf5d4bf60 100644 --- a/app/assets/javascripts/vue_shared/components/bar_chart.vue +++ b/app/assets/javascripts/vue_shared/components/bar_chart.vue @@ -293,8 +293,8 @@ export default { :title="setTooltipTitle(data)" class="bar-rect" data-placement="top" - @mouseover="barHoveredIn(index);" - @mouseout="barHoveredOut(index);" + @mouseover="barHoveredIn(index)" + @mouseout="barHoveredOut(index)" /> </template> </g> diff --git a/app/assets/javascripts/vue_shared/components/callout.vue b/app/assets/javascripts/vue_shared/components/callout.vue index ddbb14ae812..56bafebf4ce 100644 --- a/app/assets/javascripts/vue_shared/components/callout.vue +++ b/app/assets/javascripts/vue_shared/components/callout.vue @@ -11,13 +11,14 @@ export default { }, message: { type: String, - required: true, + required: false, + default: '', }, }, }; </script> <template> <div :class="`bs-callout bs-callout-${category}`" role="alert" aria-live="assertive"> - {{ message }} + {{ message }} <slot></slot> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index bb7710f708e..e9ab6f5ba7a 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -37,6 +37,11 @@ export default { required: false, default: 12, }, + isCentered: { + type: Boolean, + required: false, + default: true, + }, }, computed: { changedIcon() { @@ -78,7 +83,12 @@ export default { </script> <template> - <span v-gl-tooltip.right :title="tooltipTitle" class="file-changed-icon ml-auto"> + <span + v-gl-tooltip.right + :title="tooltipTitle" + :class="{ 'ml-auto': isCentered }" + class="file-changed-icon" + > <icon v-if="showIcon" :name="changedIcon" :size="size" :css-classes="changedIconClass" /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index b8eb555106f..e6f0a1c69cd 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -21,6 +21,7 @@ import Icon from '../../vue_shared/components/icon.vue'; * - Jobs table * - Jobs show view header * - Jobs show view sidebar + * - Linked pipelines */ const validSizes = [8, 12, 16, 18, 24, 32, 48, 72]; @@ -46,6 +47,11 @@ export default { required: false, default: false, }, + cssClasses: { + type: String, + required: false, + default: '', + }, }, computed: { cssClass() { @@ -59,5 +65,5 @@ export default { }; </script> <template> - <span :class="cssClass"> <icon :name="icon" :size="size" /> </span> + <span :class="cssClass"> <icon :name="icon" :size="size" :css-classes="cssClasses" /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue index 2129f90d497..36b3ee05456 100644 --- a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue +++ b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue @@ -95,7 +95,7 @@ export default { class="close float-right" data-dismiss="modal" aria-label="Close" - @click="emitCancel($event);" + @click="emitCancel($event)" > <span aria-hidden="true">×</span> </button> @@ -112,7 +112,7 @@ export default { type="button" class="btn" data-dismiss="modal" - @click="emitCancel($event);" + @click="emitCancel($event)" > {{ closeButtonLabel }} </button> @@ -130,7 +130,7 @@ export default { type="button" class="btn js-primary-button" data-dismiss="modal" - @click="emitSubmit($event);" + @click="emitSubmit($event)" > {{ primaryButtonLabel }} </button> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue index 75c66ed850b..ebb253ff422 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -1,6 +1,5 @@ <script> -import { diffModes } from '~/ide/constants'; -import { viewerInformationForPath } from '../content_viewer/lib/viewer_utils'; +import { diffViewerModes, diffModes } from '~/ide/constants'; import ImageDiffViewer from './viewers/image_diff_viewer.vue'; import DownloadDiffViewer from './viewers/download_diff_viewer.vue'; import RenamedFile from './viewers/renamed.vue'; @@ -12,6 +11,10 @@ export default { type: String, required: true, }, + diffViewerMode: { + type: String, + required: true, + }, newPath: { type: String, required: true, @@ -46,7 +49,7 @@ export default { }, computed: { viewer() { - if (this.diffMode === diffModes.renamed) { + if (this.diffViewerMode === diffViewerModes.renamed) { return RenamedFile; } else if (this.diffMode === diffModes.mode_changed) { return ModeChanged; @@ -54,11 +57,8 @@ export default { if (!this.newPath) return null; - const previewInfo = viewerInformationForPath(this.newPath); - if (!previewInfo) return DownloadDiffViewer; - - switch (previewInfo.id) { - case 'image': + switch (this.diffViewerMode) { + case diffViewerModes.image: return ImageDiffViewer; default: return DownloadDiffViewer; diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue index d5fda7e4ed3..cab92297ca7 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue @@ -75,7 +75,7 @@ export default { :class="{ active: mode === $options.imageViewMode.twoup, }" - @click="changeMode($options.imageViewMode.twoup);" + @click="changeMode($options.imageViewMode.twoup)" > {{ s__('ImageDiffViewer|2-up') }} </li> @@ -83,7 +83,7 @@ export default { :class="{ active: mode === $options.imageViewMode.swipe, }" - @click="changeMode($options.imageViewMode.swipe);" + @click="changeMode($options.imageViewMode.swipe)" > {{ s__('ImageDiffViewer|Swipe') }} </li> @@ -91,7 +91,7 @@ export default { :class="{ active: mode === $options.imageViewMode.onion, }" - @click="changeMode($options.imageViewMode.onion);" + @click="changeMode($options.imageViewMode.onion)" > {{ s__('ImageDiffViewer|Onion skin') }} </li> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/no_preview.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/no_preview.vue new file mode 100644 index 00000000000..c5cdddf2f64 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/no_preview.vue @@ -0,0 +1,5 @@ +<template> + <div class="nothing-here-block"> + {{ __('No preview for this file type') }} + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/not_diffable.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/not_diffable.vue new file mode 100644 index 00000000000..d4d3038f066 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/not_diffable.vue @@ -0,0 +1,5 @@ +<template> + <div class="nothing-here-block"> + {{ __('This diff was suppressed by a .gitattributes entry.') }} + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/empty_component.js b/app/assets/javascripts/vue_shared/components/empty_component.js new file mode 100644 index 00000000000..e4402020096 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/empty_component.js @@ -0,0 +1,3 @@ +export default { + render: () => null, +}; diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue new file mode 100644 index 00000000000..b57455adaad --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -0,0 +1,299 @@ +<script> +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import Mousetrap from 'mousetrap'; +import VirtualList from 'vue-virtual-scroll-list'; +import Item from './item.vue'; +import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; + +export const MAX_FILE_FINDER_RESULTS = 40; +export const FILE_FINDER_ROW_HEIGHT = 55; +export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33; + +const originalStopCallback = Mousetrap.stopCallback; + +export default { + components: { + Item, + VirtualList, + }, + props: { + files: { + type: Array, + required: true, + }, + visible: { + type: Boolean, + required: true, + }, + loading: { + type: Boolean, + required: true, + }, + showDiffStats: { + type: Boolean, + required: false, + default: false, + }, + clearSearchOnClose: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + focusedIndex: -1, + searchText: '', + mouseOver: false, + cancelMouseOver: false, + }; + }, + computed: { + filteredBlobs() { + const searchText = this.searchText.trim(); + + if (searchText === '') { + return this.files.slice(0, MAX_FILE_FINDER_RESULTS); + } + + return fuzzaldrinPlus.filter(this.files, searchText, { + key: 'path', + maxResults: MAX_FILE_FINDER_RESULTS, + }); + }, + filteredBlobsLength() { + return this.filteredBlobs.length; + }, + listShowCount() { + return this.filteredBlobsLength ? Math.min(this.filteredBlobsLength, 5) : 1; + }, + listHeight() { + return this.filteredBlobsLength ? FILE_FINDER_ROW_HEIGHT : FILE_FINDER_EMPTY_ROW_HEIGHT; + }, + showClearInputButton() { + return this.searchText.trim() !== ''; + }, + }, + watch: { + visible() { + this.$nextTick(() => { + if (!this.visible) { + if (this.clearSearchOnClose) { + this.searchText = ''; + } + } else { + this.focusedIndex = 0; + + if (this.$refs.searchInput) { + this.$refs.searchInput.focus(); + } + } + }); + }, + searchText() { + this.focusedIndex = -1; + + this.$nextTick(() => { + this.focusedIndex = 0; + }); + }, + focusedIndex() { + if (!this.mouseOver) { + this.$nextTick(() => { + const el = this.$refs.virtualScrollList.$el; + const scrollTop = this.focusedIndex * FILE_FINDER_ROW_HEIGHT; + const bottom = this.listShowCount * FILE_FINDER_ROW_HEIGHT; + + if (this.focusedIndex === 0) { + // if index is the first index, scroll straight to start + el.scrollTop = 0; + } else if (this.focusedIndex === this.filteredBlobsLength - 1) { + // if index is the last index, scroll to the end + el.scrollTop = this.filteredBlobsLength * FILE_FINDER_ROW_HEIGHT; + } else if (scrollTop >= bottom + el.scrollTop) { + // if element is off the bottom of the scroll list, scroll down one item + el.scrollTop = scrollTop - bottom + FILE_FINDER_ROW_HEIGHT; + } else if (scrollTop < el.scrollTop) { + // if element is off the top of the scroll list, scroll up one item + el.scrollTop = scrollTop; + } + }); + } + }, + }, + mounted() { + if (this.files.length) { + this.focusedIndex = 0; + } + + Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => { + if (e.preventDefault) { + e.preventDefault(); + } + + this.toggle(!this.visible); + }); + + Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo); + }, + methods: { + toggle(visible) { + this.$emit('toggle', visible); + }, + clearSearchInput() { + this.searchText = ''; + + this.$nextTick(() => { + this.$refs.searchInput.focus(); + }); + }, + onKeydown(e) { + switch (e.keyCode) { + case UP_KEY_CODE: + e.preventDefault(); + this.mouseOver = false; + this.cancelMouseOver = true; + if (this.focusedIndex > 0) { + this.focusedIndex -= 1; + } else { + this.focusedIndex = this.filteredBlobsLength - 1; + } + break; + case DOWN_KEY_CODE: + e.preventDefault(); + this.mouseOver = false; + this.cancelMouseOver = true; + if (this.focusedIndex < this.filteredBlobsLength - 1) { + this.focusedIndex += 1; + } else { + this.focusedIndex = 0; + } + break; + default: + break; + } + }, + onKeyup(e) { + switch (e.keyCode) { + case ENTER_KEY_CODE: + this.openFile(this.filteredBlobs[this.focusedIndex]); + break; + case ESC_KEY_CODE: + this.toggle(false); + break; + default: + break; + } + }, + openFile(file) { + this.toggle(false); + this.$emit('click', file); + }, + onMouseOver(index) { + if (!this.cancelMouseOver) { + this.mouseOver = true; + this.focusedIndex = index; + } + }, + onMouseMove(index) { + this.cancelMouseOver = false; + this.onMouseOver(index); + }, + mousetrapStopCallback(e, el, combo) { + if ( + (combo === 't' && el.classList.contains('dropdown-input-field')) || + el.classList.contains('inputarea') + ) { + return true; + } else if (combo === 'command+p' || combo === 'ctrl+p') { + return false; + } + + return originalStopCallback(e, el, combo); + }, + }, +}; +</script> + +<template> + <div class="file-finder-overlay" @mousedown.self="toggle(false)"> + <div class="dropdown-menu diff-file-changes file-finder show"> + <div :class="{ 'has-value': showClearInputButton }" class="dropdown-input"> + <input + ref="searchInput" + v-model="searchText" + :placeholder="__('Search files')" + type="search" + class="dropdown-input-field" + autocomplete="off" + @keydown="onKeydown($event)" + @keyup="onKeyup($event)" + /> + <i + :class="{ + hidden: showClearInputButton, + }" + aria-hidden="true" + class="fa fa-search dropdown-input-search" + ></i> + <i + :aria-label="__('Clear search input')" + role="button" + class="fa fa-times dropdown-input-clear" + @click="clearSearchInput" + ></i> + </div> + <div> + <virtual-list ref="virtualScrollList" :size="listHeight" :remain="listShowCount" wtag="ul"> + <template v-if="filteredBlobsLength"> + <li v-for="(file, index) in filteredBlobs" :key="file.key"> + <item + :file="file" + :search-text="searchText" + :focused="index === focusedIndex" + :index="index" + :show-diff-stats="showDiffStats" + class="disable-hover" + @click="openFile" + @mouseover="onMouseOver" + @mousemove="onMouseMove" + /> + </li> + </template> + <li v-else class="dropdown-menu-empty-item"> + <div class="append-right-default prepend-left-default prepend-top-8 append-bottom-8"> + <template v-if="loading"> + {{ __('Loading...') }} + </template> + <template v-else> + {{ __('No files found.') }} + </template> + </div> + </li> + </virtual-list> + </div> + </div> + </div> +</template> + +<style scoped> +.file-finder-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 200; +} + +.file-finder { + top: 10px; + left: 50%; + transform: translateX(-50%); +} + +.diff-file-changes { + top: 50px; + max-height: 327px; +} +</style> diff --git a/app/assets/javascripts/vue_shared/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue new file mode 100644 index 00000000000..73511879ff2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue @@ -0,0 +1,126 @@ +<script> +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import Icon from '~/vue_shared/components/icon.vue'; +import FileIcon from '../../../vue_shared/components/file_icon.vue'; +import ChangedFileIcon from '../../../vue_shared/components/changed_file_icon.vue'; + +const MAX_PATH_LENGTH = 60; + +export default { + components: { + Icon, + ChangedFileIcon, + FileIcon, + }, + props: { + file: { + type: Object, + required: true, + }, + focused: { + type: Boolean, + required: true, + }, + searchText: { + type: String, + required: true, + }, + index: { + type: Number, + required: true, + }, + showDiffStats: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + pathWithEllipsis() { + const { path } = this.file; + + return path.length < MAX_PATH_LENGTH + ? path + : `...${path.substr(path.length - MAX_PATH_LENGTH)}`; + }, + nameSearchTextOccurences() { + return fuzzaldrinPlus.match(this.file.name, this.searchText); + }, + pathSearchTextOccurences() { + return fuzzaldrinPlus.match(this.pathWithEllipsis, this.searchText); + }, + }, + methods: { + clickRow() { + this.$emit('click', this.file); + }, + mouseOverRow() { + this.$emit('mouseover', this.index); + }, + mouseMove() { + this.$emit('mousemove', this.index); + }, + }, +}; +</script> + +<template> + <button + :class="{ + 'is-focused': focused, + }" + type="button" + class="diff-changed-file" + @click.prevent="clickRow" + @mouseover="mouseOverRow" + @mousemove="mouseMove" + > + <file-icon + :file-name="file.name" + :size="16" + css-classes="diff-file-changed-icon append-right-8" + /> + <span class="diff-changed-file-content append-right-8"> + <strong class="diff-changed-file-name"> + <span + v-for="(char, charIndex) in file.name.split('')" + :key="charIndex + char" + :class="{ + highlighted: nameSearchTextOccurences.indexOf(charIndex) >= 0, + }" + v-text="char" + > + </span> + </strong> + <span class="diff-changed-file-path prepend-top-5"> + <span + v-for="(char, charIndex) in pathWithEllipsis.split('')" + :key="charIndex + char" + :class="{ + highlighted: pathSearchTextOccurences.indexOf(charIndex) >= 0, + }" + v-text="char" + > + </span> + </span> + </span> + <span v-if="file.changed || file.tempFile" v-once class="diff-changed-stats"> + <span v-if="showDiffStats"> + <span class="cgreen bold"> + <icon name="file-addition" class="align-text-top" /> {{ file.addedLines }} + </span> + <span class="cred bold ml-1"> + <icon name="file-deletion" class="align-text-top" /> {{ file.removedLines }} + </span> + </span> + <changed-file-icon v-else :file="file" /> + </span> + </button> +</template> + +<style scoped> +.highlighted { + color: #1f78d1; + font-weight: 600; +} +</style> diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 9e713673678..0cbcdbf2eb4 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -1,11 +1,13 @@ <script> import Icon from '~/vue_shared/components/icon.vue'; +import FileHeader from '~/vue_shared/components/file_row_header.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; export default { name: 'FileRow', components: { + FileHeader, FileIcon, Icon, ChangedFileIcon, @@ -34,21 +36,10 @@ export default { required: false, default: false, }, - displayTextKey: { - type: String, - required: false, - default: 'name', - }, - shouldTruncateStart: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { mouseOver: false, - truncateStart: 0, }; }, computed: { @@ -60,7 +51,7 @@ export default { }, levelIndentation() { return { - marginLeft: `${this.level * 16}px`, + marginLeft: this.level ? `${this.level * 16}px` : null, }; }, fileClass() { @@ -71,14 +62,8 @@ export default { 'is-open': this.file.opened, }; }, - outputText() { - const text = this.file[this.displayTextKey]; - - if (this.truncateStart === 0) { - return text; - } - - return `...${text.substring(this.truncateStart, text.length)}`; + childFilesLevel() { + return this.file.isHeader ? 0 : this.level + 1; }, }, watch: { @@ -92,15 +77,6 @@ export default { if (this.hasPathAtCurrentRoute()) { this.scrollIntoView(true); } - - if (this.shouldTruncateStart) { - const { scrollWidth, offsetWidth } = this.$refs.textOutput; - const textOverflow = scrollWidth - offsetWidth; - - if (textOverflow > 0) { - this.truncateStart = Math.ceil(textOverflow / 5) + 3; - } - } }, methods: { toggleTreeOpen(path) { @@ -156,13 +132,16 @@ export default { <template> <div> + <file-header v-if="file.isHeader" :path="file.path" /> <div + v-else :class="fileClass" + :title="file.name" class="file-row" role="button" @click="clickFile" - @mouseover="toggleHover(true);" - @mouseout="toggleHover(false);" + @mouseover="toggleHover(true)" + @mouseout="toggleHover(false)" > <div class="file-row-name-container"> <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated"> @@ -175,7 +154,7 @@ export default { :size="16" /> <changed-file-icon v-else :file="file" :size="16" class="append-right-5" /> - {{ outputText }} + {{ file.name }} </span> <component :is="extraComponent" @@ -185,17 +164,15 @@ export default { /> </div> </div> - <template v-if="file.opened"> + <template v-if="file.opened || file.isHeader"> <file-row v-for="childFile in file.tree" :key="childFile.key" :file="childFile" - :level="level + 1" + :level="childFilesLevel" :hide-extra-on-tree="hideExtraOnTree" :extra-component="extraComponent" :show-changed-icon="showChangedIcon" - :display-text-key="displayTextKey" - :should-truncate-start="shouldTruncateStart" @toggleTreeOpen="toggleTreeOpen" @clickFile="clickedFile" /> diff --git a/app/assets/javascripts/vue_shared/components/file_row_header.vue b/app/assets/javascripts/vue_shared/components/file_row_header.vue new file mode 100644 index 00000000000..301fd8116a9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/file_row_header.vue @@ -0,0 +1,25 @@ +<script> +import { truncatePathMiddleToLength } from '~/lib/utils/text_utility'; + +const MAX_PATH_LENGTH = 40; + +export default { + props: { + path: { + type: String, + required: true, + }, + }, + computed: { + truncatedPath() { + return truncatePathMiddleToLength(this.path, MAX_PATH_LENGTH); + }, + }, +}; +</script> + +<template> + <div class="file-row-header bg-white sticky-top p-2 js-file-row-header"> + <span class="bold">{{ truncatedPath }}</span> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue b/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue index 834c39a5ee0..4e5dfbf3bf8 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue @@ -1,15 +1,21 @@ <script> import $ from 'jquery'; +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; /** * Renders a split dropdown with * an input that allows to search through the given * array of options. + * + * When there are no results and `showCreateMode` is true + * it renders a create button with the value typed. */ export default { name: 'FilteredSearchDropdown', components: { Icon, + GlButton, }, props: { title: { @@ -43,6 +49,16 @@ export default { type: String, required: true, }, + showCreateMode: { + type: Boolean, + required: false, + default: false, + }, + createButtonText: { + type: String, + required: false, + default: __('Create'), + }, }, data() { return { @@ -64,6 +80,12 @@ export default { return this.items.slice(0, this.visibleItems); }, + computedCreateButtonText() { + return `${this.createButtonText} ${this.filter}`; + }, + shouldRenderCreateButton() { + return this.showCreateMode && this.filteredResults.length === 0 && this.filter !== ''; + }, }, mounted() { /** @@ -112,10 +134,20 @@ export default { <div class="dropdown-content"> <ul> <li v-for="(result, i) in filteredResults" :key="i" class="js-filtered-dropdown-result"> - <slot name="result" :result="result"> {{ result[filterKey] }} </slot> + <slot name="result" :result="result">{{ result[filterKey] }}</slot> </li> </ul> </div> + + <div v-if="shouldRenderCreateButton" class="dropdown-footer"> + <slot name="footer" :filter="filter"> + <gl-button + class="js-dropdown-create-button btn-transparent" + @click="$emit('createItem', filter)" + >{{ computedCreateButtonText }}</gl-button + > + </slot> + </div> </div> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue index faf4181bbaf..438851e5ac7 100644 --- a/app/assets/javascripts/vue_shared/components/gl_modal.vue +++ b/app/assets/javascripts/vue_shared/components/gl_modal.vue @@ -81,7 +81,7 @@ export default { type="button" class="close js-modal-close-action" data-dismiss="modal" - @click="emitCancel($event);" + @click="emitCancel($event)" > <span aria-hidden="true">×</span> </button> @@ -96,7 +96,7 @@ export default { type="button" class="btn js-modal-cancel-action qa-modal-cancel-button" data-dismiss="modal" - @click="emitCancel($event);" + @click="emitCancel($event)" > {{ s__('Modal|Cancel') }} </button> @@ -105,7 +105,7 @@ export default { type="button" class="btn js-modal-primary-action qa-modal-primary-button" data-dismiss="modal" - @click="emitSubmit($event);" + @click="emitSubmit($event)" > {{ footerPrimaryButtonText }} </button> diff --git a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue new file mode 100644 index 00000000000..df6fadf10cd --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue @@ -0,0 +1,69 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlModal } from '@gitlab/ui'; + +/** + * This component keeps the GlModal's visibility in sync with the given vuex module. + */ +export default { + components: { + GlModal, + }, + props: { + modalId: { + type: String, + required: true, + }, + modalModule: { + type: String, + required: true, + }, + }, + computed: { + ...mapState({ + isVisible(state) { + return state[this.modalModule].isVisible; + }, + }), + attrs() { + const { modalId, modalModule, ...attrs } = this.$attrs; + + return attrs; + }, + }, + watch: { + isVisible(val) { + return val ? this.bsShow() : this.bsHide(); + }, + }, + methods: { + ...mapActions({ + syncShow(dispatch) { + return dispatch(`${this.modalModule}/show`); + }, + syncHide(dispatch) { + return dispatch(`${this.modalModule}/hide`); + }, + }), + bsShow() { + this.$root.$emit('bv::show::modal', this.modalId); + }, + bsHide() { + // $root.$emit is a workaround because other b-modal approaches don't work yet with gl-modal + this.$root.$emit('bv::hide::modal', this.modalId); + }, + }, +}; +</script> + +<template> + <gl-modal + v-bind="attrs" + :modal-id="modalId" + v-on="$listeners" + @shown="syncShow" + @hidden="syncHide" + > + <slot></slot> + </gl-modal> +</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 c830f5b49b6..3f45dc7853b 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -148,7 +148,7 @@ export default { :class="action.cssClass" container-class="d-inline" :label="action.label" - @click="onClickAction(action);" + @click="onClickAction(action)" /> </template> </section> diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue new file mode 100644 index 00000000000..27cfa8abb24 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -0,0 +1,116 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; +import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; +import relatedIssuableMixin from '~/vue_shared/mixins/related_issuable_mixin'; + +export default { + name: 'IssueItem', + components: { + IssueMilestone, + IssueAssignees, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [relatedIssuableMixin], + props: { + canReorder: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + stateTitle() { + return sprintf( + '<span class="bold">%{state}</span> %{timeInWords}<br/><span class="text-tertiary">%{timestamp}</span>', + { + state: this.isOpen ? __('Opened') : __('Closed'), + timeInWords: this.isOpen ? this.createdAtInWords : this.closedAtInWords, + timestamp: this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp, + }, + ); + }, + }, +}; +</script> + +<template> + <div + :class="{ + 'issuable-info-container': !canReorder, + 'card-body': canReorder, + }" + class="item-body" + > + <div class="item-contents"> + <div class="item-title d-flex align-items-center"> + <icon + v-if="hasState" + v-tooltip + :css-classes="iconClass" + :name="iconName" + :size="16" + :title="stateTitle" + :aria-label="state" + data-html="true" + /> + <icon + v-if="confidential" + v-gl-tooltip + name="eye-slash" + :size="16" + :title="__('Confidential')" + class="confidential-icon append-right-4" + :aria-label="__('Confidential')" + /> + <a :href="computedPath" class="sortable-link">{{ title }}</a> + </div> + <div class="item-meta"> + <div class="d-flex align-items-center item-path-id"> + <icon + v-if="hasState" + v-tooltip + :css-classes="iconClass" + :name="iconName" + :size="16" + :title="stateTitle" + :aria-label="state" + data-html="true" + /> + <span v-tooltip :title="itemPath" class="path-id-text">{{ itemPath }}</span> + {{ pathIdSeparator }}{{ itemId }} + </div> + <div class="item-meta-child d-flex align-items-center"> + <issue-milestone + v-if="hasMilestone" + :milestone="milestone" + class="d-flex align-items-center item-milestone" + /> + <slot name="dueDate"></slot> + <slot name="weight"></slot> + </div> + <issue-assignees + v-if="assignees.length" + :assignees="assignees" + class="item-assignees d-inline-flex" + /> + </div> + </div> + <button + v-if="canRemove" + ref="removeButton" + v-tooltip + :disabled="removeDisabled" + type="button" + class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button" + title="Remove" + aria-label="Remove" + @click="onRemoveRequest" + > + <icon :size="16" class="btn-item-remove-icon" name="close" /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 2f7ed4a982c..3f607aa2a0a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -27,11 +27,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, addSpacingClasses: { type: Boolean, required: false, @@ -89,7 +84,6 @@ export default { return this.referencedUsers.length >= referencedUsersThreshold; }, lineContent() { - const FIRST_CHAR_REGEX = /^(\+|-)/; const [firstSuggestion] = this.suggestions; if (firstSuggestion) { return firstSuggestion.from_content; @@ -99,7 +93,7 @@ export default { const { rich_text: richText, text } = this.line; if (text) { - return text.replace(FIRST_CHAR_REGEX, ''); + return text; } return _.unescape(stripHtml(richText).replace(/\n/g, '')); @@ -159,7 +153,7 @@ export default { this.markdownPreviewLoading = true; this.markdownPreview = __('Loading…'); this.$http - .post(this.versionedPreviewPath(), { text }) + .post(this.markdownPreviewPath, { text }) .then(resp => resp.json()) .then(data => this.renderMarkdown(data)) .catch(() => new Flash(__('Error loading markdown preview'))); @@ -183,16 +177,9 @@ export default { this.hasSuggestion = data.references.suggestions && data.references.suggestions.length; } - this.$nextTick(() => { - $(this.$refs['markdown-preview']).renderGFM(); - }); - }, - - versionedPreviewPath() { - const { markdownPreviewPath, markdownVersion } = this; - return `${markdownPreviewPath}${ - markdownPreviewPath.indexOf('?') === -1 ? '?' : '&' - }markdown_version=${markdownVersion}`; + this.$nextTick() + .then(() => $(this.$refs['markdown-preview']).renderGFM()) + .catch(() => new Flash(__('Error rendering markdown preview'))); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index bf4d42670ee..cc6ecdb0395 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -78,13 +78,8 @@ export default { <div class="md-header"> <ul class="nav-links clearfix"> <li :class="{ active: !previewMarkdown }" class="md-header-tab"> - <button - class="js-write-link" - tabindex="-1" - type="button" - @click="writeMarkdownTab($event);" - > - Write + <button class="js-write-link" tabindex="-1" type="button" @click="writeMarkdownTab($event)"> + {{ __('Write') }} </button> </li> <li :class="{ active: previewMarkdown }" class="md-header-tab"> @@ -92,38 +87,43 @@ export default { class="js-preview-link js-md-preview-button" tabindex="-1" type="button" - @click="previewMarkdownTab($event);" + @click="previewMarkdownTab($event)" > - Preview + {{ __('Preview') }} </button> </li> <li :class="{ active: !previewMarkdown }" class="md-header-toolbar"> - <toolbar-button tag="**" button-title="Add bold text" icon="bold" /> - <toolbar-button tag="*" button-title="Add italic text" icon="italic" /> - <toolbar-button :prepend="true" tag="> " button-title="Insert a quote" icon="quote" /> - <toolbar-button tag="`" tag-block="```" button-title="Insert code" icon="code" /> + <toolbar-button tag="**" :button-title="__('Add bold text')" icon="bold" /> + <toolbar-button tag="*" :button-title="__('Add italic text')" icon="italic" /> + <toolbar-button + :prepend="true" + tag="> " + :button-title="__('Insert a quote')" + icon="quote" + /> + <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" /> <toolbar-button tag="[{text}](url)" tag-select="url" - button-title="Add a link" + :button-title="__('Add a link')" icon="link" /> <toolbar-button :prepend="true" tag="* " - button-title="Add a bullet list" + :button-title="__('Add a bullet list')" icon="list-bulleted" /> <toolbar-button :prepend="true" tag="1. " - button-title="Add a numbered list" + :button-title="__('Add a numbered list')" icon="list-numbered" /> <toolbar-button :prepend="true" tag="* [ ] " - button-title="Add a task list" + :button-title="__('Add a task list')" icon="task-done" /> <toolbar-button @@ -144,11 +144,11 @@ export default { /> <button v-gl-tooltip - aria-label="Go full screen" + :aria-label="__('Go full screen')" class="toolbar-btn toolbar-fullscreen-btn js-zen-enter" data-container="body" tabindex="-1" - title="Go full screen" + :title="__('Go full screen')" type="button" > <icon name="screen-full" /> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue index f98560f7336..a351ca62c94 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -42,7 +42,7 @@ export default { </script> <template> - <div> + <div class="md-suggestion"> <suggestion-diff-header class="qa-suggestion-diff-header" :can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled" @@ -50,7 +50,7 @@ export default { :help-page-path="helpPagePath" @apply="applySuggestion" /> - <table class="mb-3 md-suggestion-diff"> + <table class="mb-3 md-suggestion-diff js-syntax-highlight code"> <tbody> <!-- Old Line --> <tr class="line_holder old"> 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 563e2f94fcc..c5a2aa1f2af 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 @@ -42,7 +42,7 @@ export default { <div class="md-suggestion-header border-bottom-0 mt-2"> <div class="qa-suggestion-diff-header font-weight-bold"> {{ __('Suggested change') }} - <a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')"> + <a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')" class="js-help-btn"> <icon name="question-o" css-classes="link-highlight" /> </a> </div> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 7c6dbee3e19..dcda701f049 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -82,13 +82,12 @@ export default { // extracts the suggested lines from the markdown // calculates a line number for each line - const FIRST_CHAR_REGEX = /^(\+|-)/; const newLines = suggestionEl.querySelectorAll('.line'); const fromLine = this.suggestions.length ? this.suggestions[0].from_line : this.fromLine; const lines = []; newLines.forEach((line, i) => { - const content = `${line.innerText.replace(FIRST_CHAR_REGEX, '')}\n`; + const content = `${line.innerText}\n`; const lineNumber = fromLine + i; lines.push({ content, lineNumber }); }); @@ -130,7 +129,7 @@ export default { <template> <div> - <div class="flash-container mt-3"></div> + <div class="flash-container js-suggestions-flash"></div> <div v-show="isRendered" ref="container" v-html="noteHtml"></div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue index 09a64502819..f8983a3d29a 100644 --- a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue @@ -58,7 +58,7 @@ export default { active: tab.isActive, }" > - <a :class="`js-${scope}-tab-${tab.scope}`" role="button" @click="onTabClick(tab);"> + <a :class="`js-${scope}-tab-${tab.scope}`" role="button" @click="onTabClick(tab)"> {{ tab.name }} <span v-if="shouldRenderBadge(tab.count)" class="badge badge-pill"> {{ tab.count }} </span> 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 31df26f7b05..b0af8399955 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -97,7 +97,7 @@ export default { v-html="note.note_html" ></div> <div v-if="hasMoreCommits" class="flex-list"> - <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded;"> + <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded"> <icon :name="toggleIcon" :size="8" class="append-right-5" /> <span>Toggle commit list</span> </div> diff --git a/app/assets/javascripts/vue_shared/components/panel_resizer.vue b/app/assets/javascripts/vue_shared/components/panel_resizer.vue index bf736a378dd..8d81940eb91 100644 --- a/app/assets/javascripts/vue_shared/components/panel_resizer.vue +++ b/app/assets/javascripts/vue_shared/components/panel_resizer.vue @@ -28,11 +28,12 @@ export default { data() { return { size: this.startSize, + isDragging: false, }; }, computed: { className() { - return `drag-${this.side}`; + return [`position-${this.side}-0`, { 'is-dragging': this.isDragging }]; }, cursorStyle() { if (this.enabled) { @@ -57,6 +58,7 @@ export default { startDrag(e) { if (this.enabled) { e.preventDefault(); + this.isDragging = true; this.startPos = e.clientX; this.currentStartSize = this.size; document.addEventListener('mousemove', this.drag); @@ -80,6 +82,7 @@ export default { }, endDrag(e) { e.preventDefault(); + this.isDragging = false; document.removeEventListener('mousemove', this.drag); this.$emit('resize-end', this.size); }, @@ -91,7 +94,7 @@ export default { <div :class="className" :style="cursorStyle" - class="drag-handle" + class="position-absolute position-top-0 position-bottom-0 drag-handle" @mousedown="startDrag" @dblclick="resetSize" ></div> diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue index 8bdb5bf22c2..fa502b9beb9 100644 --- a/app/assets/javascripts/vue_shared/components/pikaday.vue +++ b/app/assets/javascripts/vue_shared/components/pikaday.vue @@ -1,6 +1,7 @@ <script> import Pikaday from 'pikaday'; import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility'; +import { __ } from '~/locale'; export default { name: 'DatePicker', @@ -8,7 +9,7 @@ export default { label: { type: String, required: false, - default: 'Date picker', + default: __('Date picker'), }, selectedDate: { type: Date, @@ -40,6 +41,7 @@ export default { toString: date => pikadayToString(date), onSelect: this.selected.bind(this), onClose: this.toggled.bind(this), + firstDay: gon.first_day_of_week, }); this.$el.append(this.calendar.el); diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/default.vue b/app/assets/javascripts/vue_shared/components/project_avatar/default.vue index b399c232937..881b5059d2a 100644 --- a/app/assets/javascripts/vue_shared/components/project_avatar/default.vue +++ b/app/assets/javascripts/vue_shared/components/project_avatar/default.vue @@ -26,7 +26,7 @@ export default { </script> <template> - <span :class="sizeClass" class="avatar-container project-avatar"> + <span :class="sizeClass" class="avatar-container rect-avatar project-avatar"> <project-avatar-image v-if="project.avatar_url" :link-href="project.path" @@ -34,6 +34,12 @@ export default { :img-alt="project.name" :img-size="size" /> - <identicon v-else :entity-id="project.id" :entity-name="project.name" :size-class="sizeClass" /> + <identicon + v-else + :entity-id="project.id" + :entity-name="project.name" + :size-class="sizeClass" + class="rect-avatar" + /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue index 1c6c3fc4734..df19906309c 100644 --- a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue +++ b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue @@ -19,7 +19,7 @@ export default { data() { return { script: {}, - scriptSrc: 'https://www.google.com/recaptcha/api.js', + scriptSrc: 'https://www.recaptcha.net/recaptcha/api.js', }; }, diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue new file mode 100644 index 00000000000..3074ea859cc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/select2_select.vue @@ -0,0 +1,35 @@ +<script> +import $ from 'jquery'; +import 'select2/select2'; + +export default { + name: 'Select2Select', + props: { + options: { + type: Object, + required: false, + default: () => ({}), + }, + value: { + type: String, + required: false, + default: '', + }, + }, + + mounted() { + $(this.$refs.dropdownInput) + .val(this.value) + .select2(this.options) + .on('change', event => this.$emit('input', event.target.value)); + }, + + beforeDestroy() { + $(this.$refs.dropdownInput).select2('destroy'); + }, +}; +</script> + +<template> + <input ref="dropdownInput" type="hidden" /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue index 82067129c57..45f01a6fced 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue @@ -4,6 +4,7 @@ import datePicker from '../pikaday.vue'; import toggleSidebar from './toggle_sidebar.vue'; import collapsedCalendarIcon from './collapsed_calendar_icon.vue'; import { dateInWords } from '../../../lib/utils/datetime_utility'; +import { __ } from '~/locale'; export default { name: 'SidebarDatePicker', @@ -42,7 +43,7 @@ export default { label: { type: String, required: false, - default: 'Date picker', + default: __('Date picker'), }, selectedDate: { type: Date, @@ -134,7 +135,7 @@ export default { <button type="button" class="btn-blank btn-link btn-secondary-hover-link" - @click="newDateSelected(null);" + @click="newDateSelected(null)" > remove </button> diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.vue b/app/assets/javascripts/vue_shared/components/table_pagination.vue index 01e655d27e5..8e0b08032f7 100644 --- a/app/assets/javascripts/vue_shared/components/table_pagination.vue +++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue @@ -54,15 +54,14 @@ export default { return this.pageInfo.nextPage; }, getItems() { - const total = this.pageInfo.totalPages; - const { page } = this.pageInfo; + const { totalPages, nextPage, previousPage, page } = this.pageInfo; const items = []; if (page > 1) { items.push({ title: FIRST, first: true }); } - if (page > 1) { + if (previousPage) { items.push({ title: PREV, prev: true }); } else { items.push({ title: PREV, disabled: true, prev: true }); @@ -70,32 +69,34 @@ export default { if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true }); - const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1); - const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, total); + if (totalPages) { + const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1); + const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, totalPages); - for (let i = start; i <= end; i += 1) { - const isActive = i === page; - items.push({ title: i, active: isActive, page: true }); - } + for (let i = start; i <= end; i += 1) { + const isActive = i === page; + items.push({ title: i, active: isActive, page: true }); + } - if (total - page > PAGINATION_UI_BUTTON_LIMIT) { - items.push({ title: SPREAD, separator: true, page: true }); + if (totalPages - page > PAGINATION_UI_BUTTON_LIMIT) { + items.push({ title: SPREAD, separator: true, page: true }); + } } - if (page === total) { - items.push({ title: NEXT, disabled: true, next: true }); - } else if (total - page >= 1) { + if (nextPage) { items.push({ title: NEXT, next: true }); + } else { + items.push({ title: NEXT, disabled: true, next: true }); } - if (total - page >= 1) { + if (totalPages && totalPages - page >= 1) { items.push({ title: LAST, last: true }); } return items; }, showPagination() { - return this.pageInfo.totalPages > 1; + return this.pageInfo.nextPage || this.pageInfo.previousPage; }, }, methods: { @@ -149,9 +150,9 @@ export default { }" class="page-item" > - <a class="page-link" @click.prevent="changePage(item.title, item.disabled);"> + <button type="button" class="page-link" @click="changePage(item.title, item.disabled)"> {{ item.title }} - </a> + </button> </li> </ul> </div> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index e833a8e0483..a6c1737dcab 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -67,7 +67,9 @@ export default { // In both cases we should render the defaultAvatarUrl sanitizedSource() { let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; - if (!baseSrc.startsWith('data:') && !baseSrc.includes('?')) baseSrc += `?width=${this.size}`; + // Only adds the width to the URL if its not a base64 data image + if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?')) + baseSrc += `?width=${this.size}`; return baseSrc; }, resultantSrcAttribute() { diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue new file mode 100644 index 00000000000..8eaf8386b99 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue @@ -0,0 +1,89 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { sprintf, __ } from '~/locale'; +import UserAvatarLink from './user_avatar_link.vue'; + +export default { + components: { + UserAvatarLink, + GlButton, + }, + props: { + items: { + type: Array, + required: true, + }, + breakpoint: { + type: Number, + required: false, + default: 10, + }, + imgSize: { + type: Number, + required: false, + default: 20, + }, + emptyText: { + type: String, + required: false, + default: __('None'), + }, + }, + data() { + return { + isExpanded: false, + }; + }, + computed: { + visibleItems() { + if (!this.hasHiddenItems) { + return this.items; + } + + return this.items.slice(0, this.breakpoint); + }, + hasHiddenItems() { + return this.hasBreakpoint && !this.isExpanded && this.items.length > this.breakpoint; + }, + hasBreakpoint() { + return this.breakpoint > 0 && this.items.length > this.breakpoint; + }, + expandText() { + if (!this.hasHiddenItems) { + return ''; + } + + const count = this.items.length - this.breakpoint; + + return sprintf(__('%{count} more'), { count }); + }, + }, + methods: { + expand() { + this.isExpanded = true; + }, + collapse() { + this.isExpanded = false; + }, + }, +}; +</script> + +<template> + <div v-if="!items.length">{{ emptyText }}</div> + <div v-else> + <user-avatar-link + v-for="item in visibleItems" + :key="item.id" + :link-href="item.web_url" + :img-src="item.avatar_url" + :img-alt="item.name" + :tooltip-text="item.name" + :img-size="imgSize" + /> + <template v-if="hasBreakpoint"> + <gl-button v-if="hasHiddenItems" variant="link" @click="expand"> {{ expandText }} </gl-button> + <gl-button v-else variant="link" @click="collapse"> {{ __('show less') }} </gl-button> + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index d24fe1b547e..f9773622001 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -28,10 +28,10 @@ export default { }, computed: { statusHtml() { - if (this.user.status.emoji && this.user.status.message) { - return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message}`; - } else if (this.user.status.message) { - return this.user.status.message; + if (this.user.status.emoji && this.user.status.message_html) { + return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message_html}`; + } else if (this.user.status.message_html) { + return this.user.status.message_html; } return ''; }, diff --git a/app/assets/javascripts/vue_shared/mixins/is_ee.js b/app/assets/javascripts/vue_shared/mixins/is_ee.js new file mode 100644 index 00000000000..8e00d93ef18 --- /dev/null +++ b/app/assets/javascripts/vue_shared/mixins/is_ee.js @@ -0,0 +1,10 @@ +import Vue from 'vue'; +import { isEE } from '~/lib/utils/common_utils'; + +Vue.mixin({ + computed: { + isEE() { + return isEE(); + }, + }, +}); diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js new file mode 100644 index 00000000000..455ae832234 --- /dev/null +++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js @@ -0,0 +1,155 @@ +import _ from 'underscore'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import tooltip from '~/vue_shared/directives/tooltip'; +import icon from '~/vue_shared/components/icon.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +const mixins = { + data() { + return { + removeDisabled: false, + }; + }, + props: { + idKey: { + type: Number, + required: true, + }, + displayReference: { + type: String, + required: true, + }, + pathIdSeparator: { + type: String, + required: true, + }, + eventNamespace: { + type: String, + required: false, + default: '', + }, + confidential: { + type: Boolean, + required: false, + default: false, + }, + title: { + type: String, + required: false, + default: '', + }, + path: { + type: String, + required: false, + default: '', + }, + state: { + type: String, + required: false, + default: '', + }, + createdAt: { + type: String, + required: false, + default: '', + }, + closedAt: { + type: String, + required: false, + default: '', + }, + milestone: { + type: Object, + required: false, + default: () => ({}), + }, + dueDate: { + type: String, + required: false, + default: '', + }, + assignees: { + type: Array, + required: false, + default: () => [], + }, + weight: { + type: Number, + required: false, + default: 0, + }, + canRemove: { + type: Boolean, + required: false, + default: false, + }, + }, + components: { + icon, + }, + directives: { + tooltip, + }, + mixins: [timeagoMixin], + computed: { + hasState() { + return this.state && this.state.length > 0; + }, + isOpen() { + return this.state === 'opened'; + }, + isClosed() { + return this.state === 'closed'; + }, + hasTitle() { + return this.title.length > 0; + }, + hasMilestone() { + return !_.isEmpty(this.milestone); + }, + iconName() { + return this.isOpen ? 'issue-open-m' : 'issue-close'; + }, + iconClass() { + return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed'; + }, + computedLinkElementType() { + return this.path.length > 0 ? 'a' : 'span'; + }, + computedPath() { + return this.path.length ? this.path : null; + }, + itemPath() { + return this.displayReference.split(this.pathIdSeparator)[0]; + }, + itemId() { + return this.displayReference.split(this.pathIdSeparator).pop(); + }, + createdAtInWords() { + return this.createdAt ? this.timeFormated(this.createdAt) : ''; + }, + createdAtTimestamp() { + return this.createdAt ? formatDate(new Date(this.createdAt)) : ''; + }, + closedAtInWords() { + return this.closedAt ? this.timeFormated(this.closedAt) : ''; + }, + closedAtTimestamp() { + return this.closedAt ? formatDate(new Date(this.closedAt)) : ''; + }, + }, + methods: { + onRemoveRequest() { + let namespacePrefix = ''; + if (this.eventNamespace && this.eventNamespace.length > 0) { + namespacePrefix = `${this.eventNamespace}`; + } + + this.$emit(`${namespacePrefix}RemoveRequest`, this.idKey); + + this.removeDisabled = true; + }, + }, +}; + +export default mixins; diff --git a/app/assets/javascripts/vuex_shared/modules/modal/actions.js b/app/assets/javascripts/vuex_shared/modules/modal/actions.js new file mode 100644 index 00000000000..7b209909f69 --- /dev/null +++ b/app/assets/javascripts/vuex_shared/modules/modal/actions.js @@ -0,0 +1,20 @@ +import * as types from './mutation_types'; + +export const open = ({ commit }, data) => { + commit(types.OPEN, data); +}; + +export const close = ({ commit }) => { + commit(types.CLOSE); +}; + +export const show = ({ commit }) => { + commit(types.SHOW); +}; + +export const hide = ({ commit }) => { + commit(types.HIDE); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/vuex_shared/modules/modal/index.js b/app/assets/javascripts/vuex_shared/modules/modal/index.js new file mode 100644 index 00000000000..c349d875c24 --- /dev/null +++ b/app/assets/javascripts/vuex_shared/modules/modal/index.js @@ -0,0 +1,10 @@ +import state from './state'; +import mutations from './mutations'; +import * as actions from './actions'; + +export default () => ({ + namespaced: true, + state: state(), + mutations, + actions, +}); diff --git a/app/assets/javascripts/vuex_shared/modules/modal/mutation_types.js b/app/assets/javascripts/vuex_shared/modules/modal/mutation_types.js new file mode 100644 index 00000000000..f8259736009 --- /dev/null +++ b/app/assets/javascripts/vuex_shared/modules/modal/mutation_types.js @@ -0,0 +1,4 @@ +export const HIDE = 'HIDE'; +export const SHOW = 'SHOW'; +export const OPEN = 'OPEN'; +export const CLOSE = 'CLOSE'; diff --git a/app/assets/javascripts/vuex_shared/modules/modal/mutations.js b/app/assets/javascripts/vuex_shared/modules/modal/mutations.js new file mode 100644 index 00000000000..9e96ae8b5a9 --- /dev/null +++ b/app/assets/javascripts/vuex_shared/modules/modal/mutations.js @@ -0,0 +1,18 @@ +import * as types from './mutation_types'; + +export default { + [types.SHOW](state) { + state.isVisible = true; + }, + [types.HIDE](state) { + state.isVisible = false; + }, + [types.OPEN](state, data) { + state.data = data; + state.isVisible = true; + }, + [types.CLOSE](state) { + state.data = null; + state.isVisible = false; + }, +}; diff --git a/app/assets/javascripts/vuex_shared/modules/modal/state.js b/app/assets/javascripts/vuex_shared/modules/modal/state.js new file mode 100644 index 00000000000..5d0955aa9b0 --- /dev/null +++ b/app/assets/javascripts/vuex_shared/modules/modal/state.js @@ -0,0 +1,4 @@ +export default () => ({ + isVisible: false, + data: null, +}); diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index bdf20866197..83ad8766cb5 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -40,16 +40,6 @@ @import "components/**/*"; /* - * Code highlight - */ -@import "highlight/dark"; -@import "highlight/monokai"; -@import "highlight/solarized_dark"; -@import "highlight/solarized_light"; -@import "highlight/white"; -@import "highlight/none"; - -/* * Styles for JS behaviors. */ @import "behaviors"; diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index f0671e36130..c8357f7751c 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -70,6 +70,17 @@ h6, margin-bottom: 10px; } +/* Our adjustments to hx & .hx above add unnecessary margins to modal-title + and page-title in modals, so we set them to 0 in order to have properly + formatted modal headers. */ +.modal-header { + .modal-title, + .page-title { + margin-top: 0; + margin-bottom: 0; + } +} + h5, .h5 { font-size: $gl-font-size; @@ -134,7 +145,8 @@ table { pointer-events: none; } -.popover { +.popover, +.popover-header { font-size: 14px; } @@ -142,7 +154,9 @@ table { @include media-breakpoint-up($breakpoint) { $infix: breakpoint-infix($breakpoint, $grid-breakpoints); - .d#{$infix}-table-header-group { display: table-header-group !important; } + .d#{$infix}-table-header-group { + display: table-header-group !important; + } } } @@ -211,7 +225,7 @@ h3.popover-header { } .info-well { - background: $theme-gray-50; + background: $gray-50; color: $gl-text-color; border: 1px solid $border-color; border-radius: 4px; diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss new file mode 100644 index 00000000000..edf7b26ebaa --- /dev/null +++ b/app/assets/stylesheets/components/related_items_list.scss @@ -0,0 +1,380 @@ +$item-path-max-width: 160px; +$item-milestone-max-width: 120px; +$item-weight-max-width: 48px; + +.related-items-list { + padding: $gl-padding-4; + + &, + .list-item:last-child { + margin-bottom: 0; + } +} + +.item-body { + display: flex; + position: relative; + align-items: center; + padding: $gl-padding-8; + line-height: $gl-line-height; + + .item-contents { + display: flex; + align-items: center; + flex-wrap: wrap; + flex-grow: 1; + } + + .issue-token-state-icon-open, + .issue-token-state-icon-closed, + .confidential-icon, + .item-milestone .icon, + .item-weight .board-card-info-icon { + min-width: $gl-padding; + cursor: help; + } + + .issue-token-state-icon-open, + .issue-token-state-icon-closed { + margin-right: $gl-padding-4; + } + + .confidential-icon { + align-self: baseline; + color: $orange-600; + margin-right: $gl-padding-4; + } + + .item-title { + flex-basis: 100%; + margin-bottom: $gl-padding-8; + font-size: $gl-font-size-small; + + &.mr-title { + font-weight: $gl-font-weight-bold; + } + + .sortable-link { + max-width: 85%; + } + + .issue-token-state-icon-open, + .issue-token-state-icon-closed { + display: none; + } + } + + .item-meta { + display: flex; + flex-wrap: wrap; + flex-basis: 100%; + font-size: $gl-font-size-small; + color: $gl-text-color-secondary; + + .item-meta-child { + order: 0; + display: flex; + flex-wrap: wrap; + flex-basis: 100%; + + .item-due-date, + .item-weight { + margin-left: $gl-padding-8; + } + + .item-milestone, + .item-weight { + cursor: help; + } + + .item-milestone { + text-decoration: none; + max-width: $item-milestone-max-width; + } + + .item-due-date { + margin-right: 0; + } + + .item-weight { + margin-right: 0; + max-width: $item-weight-max-width; + } + } + + .item-path-id .path-id-text, + .item-milestone .milestone-title, + .item-due-date, + .item-weight .board-card-info-text { + color: $gl-text-color-secondary; + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + .item-path-id { + margin-top: $gl-padding-4; + font-size: $gl-font-size-xs; + white-space: nowrap; + + .path-id-text { + font-weight: $gl-font-weight-bold; + max-width: $item-path-max-width; + } + + .issue-token-state-icon-open, + .issue-token-state-icon-closed { + display: block; + } + + &:not(.mr-item-path) { + order: 1; + } + } + + .item-milestone .ic-clock { + color: $gl-text-color-tertiary; + margin-right: $gl-padding-4; + } + + .item-assignees { + order: 2; + align-self: flex-end; + align-items: center; + margin-left: auto; + + .user-avatar-link { + margin-right: -$gl-padding-4; + + &:nth-of-type(1) { + z-index: 2; + } + + &:nth-of-type(2) { + z-index: 1; + } + + &:last-child { + margin-right: 0; + } + } + + .avatar { + height: $gl-padding; + width: $gl-padding; + margin-right: 0; + vertical-align: bottom; + } + + .avatar-counter { + height: $gl-padding; + border: 1px solid transparent; + background-color: $gl-text-color-tertiary; + font-weight: $gl-font-weight-bold; + padding: 0 $gl-padding-4; + line-height: $gl-padding; + } + } + } + + .btn-item-remove { + position: absolute; + right: 0; + top: $gl-padding-4 / 2; + padding: $gl-padding-4; + margin-right: $gl-padding-4 / 2; + line-height: 0; + border-color: transparent; + color: $gl-text-color-secondary; + + &:hover { + color: $gl-text-color; + } + } +} + +.mr-status-wrapper, +.mr-ci-status { + line-height: 0; +} + +@include media-breakpoint-up(sm) { + .item-body { + .item-contents .item-title { + .mr-title-link, + .sortable-link { + max-width: 90%; + } + } + } +} + +/* Small devices (landscape phones, 768px and up) */ +@include media-breakpoint-up(md) { + .item-body { + .item-contents { + min-width: 0; + + .item-title { + flex-basis: unset; + // 95% because we compensate + // for remove button which is + // positioned absolutely + width: 95%; + margin-bottom: $gl-padding-4; + + .mr-title-link, + .sortable-link { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + max-width: 100%; + } + } + + .item-meta { + .item-path-id { + order: 0; + margin-top: 0; + } + + .item-meta-child { + flex-basis: unset; + margin-left: auto; + margin-right: $gl-padding-4; + + ~ .item-assignees { + margin-left: $gl-padding-4; + } + } + + .item-assignees { + margin-bottom: 0; + margin-left: 0; + order: 2; + } + } + } + + .btn-item-remove { + order: 1; + } + } +} + +/* Medium devices (desktops, 992px and up) */ +@include media-breakpoint-up(lg) { + .item-body { + padding: $gl-padding; + + .item-title { + font-size: $gl-font-size; + } + + .item-meta .item-path-id { + font-size: inherit; // Base size given to `item-meta` is `$gl-font-size-small` + } + + .issue-token-state-icon-open, + .issue-token-state-icon-closed { + margin-right: $gl-padding-4; + } + } +} + +/* Large devices (large desktops, 1200px and up) */ +@include media-breakpoint-up(xl) { + .item-body { + padding: $gl-padding-8; + padding-left: $gl-padding; + + .item-contents { + flex-wrap: nowrap; + overflow: hidden; + + .item-title { + display: flex; + margin-bottom: 0; + min-width: 0; + width: auto; + flex-basis: unset; + font-weight: $gl-font-weight-normal; + + .mr-title-link, + .sortable-link { + display: block; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + .issue-token-state-icon-open, + .issue-token-state-icon-closed { + display: block; + margin-right: $gl-padding-8; + } + + .confidential-icon { + align-self: auto; + margin-top: 0; + } + } + + .item-meta { + margin-top: 0; + justify-content: flex-end; + flex: 1; + flex-wrap: nowrap; + + .item-path-id { + order: 0; + margin-top: 0; + margin-left: $gl-padding-8; + margin-right: auto; + + .issue-token-state-icon-open, + .issue-token-state-icon-closed { + display: none; + } + } + + .item-meta-child { + margin-left: $gl-padding-8; + flex-wrap: nowrap; + } + + .item-assignees { + flex-grow: 0; + margin-top: 0; + margin-right: $gl-padding-4; + + .avatar { + height: $gl-padding-24; + width: $gl-padding-24; + } + + .avatar-counter { + height: $gl-padding-24; + min-width: $gl-padding-24; + line-height: $gl-padding-24; + border-radius: $gl-padding-24; + } + } + } + } + + .btn-item-remove { + position: relative; + align-self: center; + top: initial; + right: 0; + margin-right: 0; + padding: $btn-sm-side-margin; + + &:hover { + border-color: $border-color; + } + } + } +} diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 834e7ffce81..216877a4461 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -31,7 +31,6 @@ @import 'framework/logo'; @import 'framework/markdown_area'; @import 'framework/media_object'; -@import 'framework/mobile'; @import 'framework/modal'; @import 'framework/pagination'; @import 'framework/panels'; @@ -66,3 +65,5 @@ @import 'framework/terms'; @import 'framework/read_more'; @import 'framework/flex_grid'; +@import 'framework/system_messages'; +@import "framework/spinner"; diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 549a8730301..257d788873c 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -27,7 +27,7 @@ &.flipOutY, &.bounceIn, &.bounceOut { - @include webkit-prefix(animation-duration, .75s); + @include webkit-prefix(animation-duration, 0.75s); } &.short { @@ -63,56 +63,44 @@ // // Pass in any number of transitions @mixin transition($transitions...) { - $unfoldedTransitions: (); + $unfolded-transitions: (); @each $transition in $transitions { - $unfoldedTransitions: append($unfoldedTransitions, unfoldTransition($transition), comma); + $unfolded-transitions: append($unfolded-transitions, unfold-transition($transition), comma); } - transition: $unfoldedTransitions; + transition: $unfolded-transitions; } -@mixin disableAllAnimation { +@mixin disable-all-animation { /*CSS transitions*/ - -o-transition-property: none !important; - -moz-transition-property: none !important; - -ms-transition-property: none !important; - -webkit-transition-property: none !important; transition-property: none !important; /*CSS transforms*/ - -o-transform: none !important; - -moz-transform: none !important; - -ms-transform: none !important; - -webkit-transform: none !important; transform: none !important; /*CSS animations*/ - -webkit-animation: none !important; - -moz-animation: none !important; - -o-animation: none !important; - -ms-animation: none !important; animation: none !important; } -@function unfoldTransition ($transition) { +@function unfold-transition ($transition) { // Default values $property: all; $duration: $general-hover-transition-duration; $easing: $general-hover-transition-curve; // Browser default is ease, which is what we want $delay: null; // Browser default is 0, which is what we want - $defaultProperties: ($property, $duration, $easing, $delay); + $default-properties: ($property, $duration, $easing, $delay); // Grab transition properties if they exist - $unfoldedTransition: (); - @for $i from 1 through length($defaultProperties) { + $unfolded-transition: (); + @for $i from 1 through length($default-properties) { $p: null; @if $i <= length($transition) { $p: nth($transition, $i); } @else { - $p: nth($defaultProperties, $i); + $p: nth($default-properties, $i); } - $unfoldedTransition: append($unfoldedTransition, $p); + $unfolded-transition: append($unfolded-transition, $p); } - @return $unfoldedTransition; + @return $unfolded-transition; } .btn { @@ -202,9 +190,9 @@ a { } } - [class^="skeleton-line-"] { + [class^='skeleton-line-'] { position: relative; - background-color: $theme-gray-100; + background-color: $gray-100; height: 10px; overflow: hidden; @@ -218,13 +206,11 @@ a { animation: blockTextShine 1s linear infinite forwards; background-repeat: no-repeat; background-size: cover; - background-image: linear-gradient( - to right, - $theme-gray-100 0%, - $theme-gray-50 20%, - $theme-gray-100 40%, - $theme-gray-100 100% - ); + background-image: linear-gradient(to right, + $gray-100 0%, + $gray-50 20%, + $gray-100 40%, + $gray-100 100%); height: 10px; } } @@ -260,3 +246,25 @@ $skeleton-line-widths: ( .slide-down-leave-to { transform: translateY(-30%); } + +@keyframes spin { + 0% { transform: rotate(0deg);} + 100% { transform: rotate(360deg);} +} + +/** COMMON ANIMATION CLASSES **/ +.transform-origin-center { @include webkit-prefix(transform-origin, 50% 50%); } +.animate-n-spin { @include webkit-prefix(animation-name, spin); } +.animate-c-infinite { @include webkit-prefix(animation-iteration-count, infinite); } +.animate-t-linear { @include webkit-prefix(animation-timing-function, linear); } +.animate-d-1 { @include webkit-prefix(animation-duration, 1s); } +.animate-d-2 { @include webkit-prefix(animation-duration, 2s); } + +/** COMPOSITE ANIMATION CLASSES **/ +.gl-spinner { + @include webkit-prefix(animation-name, spin); + @include webkit-prefix(animation-iteration-count, infinite); + @include webkit-prefix(animation-timing-function, linear); + @include webkit-prefix(animation-duration, 1s); + transform-origin: 50% 50%; +} diff --git a/app/assets/stylesheets/framework/asciidoctor.scss b/app/assets/stylesheets/framework/asciidoctor.scss index 62493c32833..1586265d40e 100644 --- a/app/assets/stylesheets/framework/asciidoctor.scss +++ b/app/assets/stylesheets/framework/asciidoctor.scss @@ -1,7 +1,7 @@ .admonitionblock td.icon { width: 1%; - [class^="fa icon-"] { + [class^='fa icon-'] { @extend .fa-2x; } diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index e132aa4c216..af79a4d9392 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -45,7 +45,6 @@ display: inline-block; margin-left: 2px; flex-shrink: 0; - -webkit-flex-shrink: 0; &.s16 { margin-right: 4px; } &.s24 { margin-right: 4px; } @@ -74,20 +73,48 @@ background-color: $gray-darker; // Sizes - &.s16 { font-size: 12px; line-height: 1.33; } - &.s24 { font-size: 13px; line-height: 1.8; } - &.s26 { font-size: 20px; line-height: 1.33; } - &.s32 { font-size: 20px; line-height: 30px; } - &.s40 { font-size: 16px; line-height: 38px; } - &.s48 { font-size: 20px; line-height: 46px; } - &.s60 { font-size: 32px; line-height: 58px; } - &.s64 { font-size: 32px; line-height: 64px; } - &.s70 { font-size: 34px; line-height: 70px; } - &.s90 { font-size: 36px; line-height: 88px; } - &.s100 { font-size: 36px; line-height: 98px; } - &.s110 { font-size: 40px; line-height: 108px; font-weight: $gl-font-weight-normal; } - &.s140 { font-size: 72px; line-height: 138px; } - &.s160 { font-size: 96px; line-height: 158px; } + &.s16 { font-size: 12px; + line-height: 1.33; } + + &.s24 { font-size: 13px; + line-height: 1.8; } + + &.s26 { font-size: 20px; + line-height: 1.33; } + + &.s32 { font-size: 20px; + line-height: 30px; } + + &.s40 { font-size: 16px; + line-height: 38px; } + + &.s48 { font-size: 20px; + line-height: 46px; } + + &.s60 { font-size: 32px; + line-height: 58px; } + + &.s64 { font-size: 32px; + line-height: 64px; } + + &.s70 { font-size: 34px; + line-height: 70px; } + + &.s90 { font-size: 36px; + line-height: 88px; } + + &.s100 { font-size: 36px; + line-height: 98px; } + + &.s110 { font-size: 40px; + line-height: 108px; + font-weight: $gl-font-weight-normal; } + + &.s140 { font-size: 72px; + line-height: 138px; } + + &.s160 { font-size: 96px; + line-height: 158px; } // Background colors &.bg1 { background-color: $identicon-red; } @@ -120,8 +147,35 @@ align-self: center; } - &.s40 { min-width: 40px; min-height: 40px; } - &.s64 { min-width: 64px; min-height: 64px; } + &.s40 { min-width: 40px; + min-height: 40px; } + + &.s64 { min-width: 64px; + min-height: 64px; } +} + +.rect-avatar { + border-radius: $border-radius-small; + &.s16 { border-radius: $border-radius-small; } + &.s18 { border-radius: $border-radius-small; } + &.s19 { border-radius: $border-radius-small; } + &.s20 { border-radius: $border-radius-small; } + &.s24 { border-radius: $border-radius-default; } + &.s26 { border-radius: $border-radius-default; } + &.s32 { border-radius: $border-radius-default; } + &.s36 { border-radius: $border-radius-default; } + &.s40 { border-radius: $border-radius-default; } + &.s46 { border-radius: $border-radius-default; } + &.s48 { border-radius: $border-radius-large; } + &.s60 { border-radius: $border-radius-large; } + &.s64 { border-radius: $border-radius-large; } + &.s70 { border-radius: $border-radius-large; } + &.s90 { border-radius: $border-radius-large; } + &.s96 { border-radius: $border-radius-large; } + &.s100 { border-radius: $border-radius-large; } + &.s110 { border-radius: $border-radius-large; } + &.s140 { border-radius: $border-radius-large; } + &.s160 { border-radius: $border-radius-large; } } .avatar-counter { diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index 7a95db5976d..648e1944388 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -15,7 +15,7 @@ margin-top: 3px; padding: $gl-padding; z-index: 300; - width: 300px; + width: $award-emoji-width; font-size: 14px; background-color: $white-light; border: 1px solid $border-white-light; @@ -23,9 +23,9 @@ box-shadow: 0 6px 12px $award-emoji-menu-shadow; pointer-events: none; opacity: 0; - transform: scale(.2); + transform: scale(0.2); transform-origin: 0 -45px; - transition: .3s cubic-bezier(.67, .06, .19, 1.44); + transition: 0.3s cubic-bezier(0.67, 0.06, 0.19, 1.44); transition-property: transform, opacity; &.is-rendered { @@ -55,10 +55,14 @@ transform: none; } } + + @include media-breakpoint-down(xs) { + width: $award-emoji-width-xs; + } } .emoji-search { - background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAFu0lEQVRIia1WTahkVxH+quqce7vf6zdvJpHoIlkYJ2SiJiIokmQjgoGgIAaEIYuYXWICgojiwkmC4taFwhjcyIDusogEIwwiSSCKPwsdwzAg0SjJ9Izzk5n3+nXfe8+pqizOvd395scfsJqi6dPnnDr11Vc/NJ1OwUTosqJLCmYCHCAC2mSHs+ojZv6AO46Y+20AhIneJsafhPhXVZSXDk7qi+aOLhtQNuBmQtcarAKjTXpn2+l3u2yPunvZSABRucjcAV/eMZuM48/Go/g1d19kc4wq+e8MZjWkbI/P5t2P3RFFbv7SQdyBlBUx8N8OTuqjMcof+N94yMPrY2DMm/ytnb32J0QrY+6AqsHM4Q64O9SKDmerKDD3Oy/tNL9vk342CC8RuU6n0ymCMHb22scu7zQngtASOjUHE1BX4UUAv4b7Ow6qiXCXuz/UdvogAAweDY943/b4cAz0ZlYHXeMsnT07RVb7wMUr8ykI4H5HVkMd5Rcb4/jNURVOL5qErAaAUUdCCIJ5kx5q2nw8m39ImEAAsjpE6PStB0YfMcd1wqqG3Xn7A3PfZyyKnNjaqD4fmE/fCNKshirIyY1xvI+Av6g5QIAIIWX7cJPssboSiBBEeKmsZne0Sb8kzAUWNYyq8NvbDo0fZ6beqxuLmqOOMr/lwOh+YXpXtbjERGja9JyZ9+HxpXKb9Gj5oywRESbj+Cj1ENG1QViTGBl1FbC1We1tbVRfHWIoQkhqH9xbpE92XUbb6VJZ1R4crjRz1JWcDMJvLdoMcyAEhjuwHo8Bfndg3mbszhOY+adVlMtD3po51OwzIQiEaams7oeJhxRw1FFOVpFRRUYIhMBAFRnjOsC8IFHHUA4TQQhgAqpAiIFfGbxkIqj54ayGbL7UoOqHCniAEKHLNr26l+D9wQJzeUwMAnfHvEnLECzZRwRV++d60ptjW9VLZeolEJG6GwCCE0CFVNB+Ay0NEqoQYG4YYFu7B8IEVRt3uRzy/osIoLV9QZimWXGHUMFdmI6M64DUF2Je88R9VZqCSP+QlcF5k+4tCzSsXaqjINuK6UyE0+s/mk6/qFq8oAIL9pqMLhkGsNrOyoOIlszust3aJv0U9+kFdwjTGwWl1YdF+KWlQSZ0Se/psj8yGVdg5tJyfH96EBWmLtoEMwMzMFt031NzGWLLzKhC+KV7H5ZeeaMOPxemma2x68puc0LN3+/u6LJiePS6MKHvn4wu6cPzJj0hsioeMfDrEvjv5r6W9gBvjKJujuKzQ0URIZj75NylvT+mbHfXQa4rwAMaVRTMm/SFyzvNy0yF6+4AM+1ubcSnqkAIUjQKl1RKSbE5jt+vovx1MBqF0WW7/d1Z80ab9BtmuJ3Xk5cJKds9TZt/uLPXvtiTrQ+dIwqfAejUvM1os6FNikXKUHfQ+ekUsXT5u85enJ0CaBSkkGEo1syUQ+DfMdE/4GA1uzupf9zdbzhOmLsF4efHVXjaHHAzmDtGdQRd/Nc5wAEJjNki3XfhyvwVNz80xANrht3LsENY9cBBdN1L9GUyyvFRFZ42t75sBvCQRykbRlU4tT2pPxoCvzx09d4GmPs200M6wKdWSDGK8mppYSWdhAlt0qeaLv+IadXU9/Evq4FAZ8ej+LmtcTxaRX4NWI0Uag5Vg1p5MYg8BnlhXIdPHDow+vTWZvVMVttXDLqkTzZdPj6Qii6cP1cSvIdl3iQkNYyi9HH0I22y+93tY3DcQkTZgQtM+POoCr8x97eylkmtrgKuztrvXJ21x/aNKuqIkZ/fntRfCdcTfhUTAIhRzoDojJD0aSNLLwMzmpT7+JaLtyf1MwDo6qz9djFaUq3t9MlFmy/c1OCSceY9fMsVaL9mvH9ocXdkdWxv1scAePG0THAhMOaLdOw/Gvxfxb1w4eCapyIENUcV5M3/u8FitAxZ25P6GAHT3UX39Srw+QOb1ZffA98Dl2Wy1BYkAAAAAElFTkSuQmCC"); + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAFu0lEQVRIia1WTahkVxH+quqce7vf6zdvJpHoIlkYJ2SiJiIokmQjgoGgIAaEIYuYXWICgojiwkmC4taFwhjcyIDusogEIwwiSSCKPwsdwzAg0SjJ9Izzk5n3+nXfe8+pqizOvd395scfsJqi6dPnnDr11Vc/NJ1OwUTosqJLCmYCHCAC2mSHs+ojZv6AO46Y+20AhIneJsafhPhXVZSXDk7qi+aOLhtQNuBmQtcarAKjTXpn2+l3u2yPunvZSABRucjcAV/eMZuM48/Go/g1d19kc4wq+e8MZjWkbI/P5t2P3RFFbv7SQdyBlBUx8N8OTuqjMcof+N94yMPrY2DMm/ytnb32J0QrY+6AqsHM4Q64O9SKDmerKDD3Oy/tNL9vk342CC8RuU6n0ymCMHb22scu7zQngtASOjUHE1BX4UUAv4b7Ow6qiXCXuz/UdvogAAweDY943/b4cAz0ZlYHXeMsnT07RVb7wMUr8ykI4H5HVkMd5Rcb4/jNURVOL5qErAaAUUdCCIJ5kx5q2nw8m39ImEAAsjpE6PStB0YfMcd1wqqG3Xn7A3PfZyyKnNjaqD4fmE/fCNKshirIyY1xvI+Av6g5QIAIIWX7cJPssboSiBBEeKmsZne0Sb8kzAUWNYyq8NvbDo0fZ6beqxuLmqOOMr/lwOh+YXpXtbjERGja9JyZ9+HxpXKb9Gj5oywRESbj+Cj1ENG1QViTGBl1FbC1We1tbVRfHWIoQkhqH9xbpE92XUbb6VJZ1R4crjRz1JWcDMJvLdoMcyAEhjuwHo8Bfndg3mbszhOY+adVlMtD3po51OwzIQiEaams7oeJhxRw1FFOVpFRRUYIhMBAFRnjOsC8IFHHUA4TQQhgAqpAiIFfGbxkIqj54ayGbL7UoOqHCniAEKHLNr26l+D9wQJzeUwMAnfHvEnLECzZRwRV++d60ptjW9VLZeolEJG6GwCCE0CFVNB+Ay0NEqoQYG4YYFu7B8IEVRt3uRzy/osIoLV9QZimWXGHUMFdmI6M64DUF2Je88R9VZqCSP+QlcF5k+4tCzSsXaqjINuK6UyE0+s/mk6/qFq8oAIL9pqMLhkGsNrOyoOIlszust3aJv0U9+kFdwjTGwWl1YdF+KWlQSZ0Se/psj8yGVdg5tJyfH96EBWmLtoEMwMzMFt031NzGWLLzKhC+KV7H5ZeeaMOPxemma2x68puc0LN3+/u6LJiePS6MKHvn4wu6cPzJj0hsioeMfDrEvjv5r6W9gBvjKJujuKzQ0URIZj75NylvT+mbHfXQa4rwAMaVRTMm/SFyzvNy0yF6+4AM+1ubcSnqkAIUjQKl1RKSbE5jt+vovx1MBqF0WW7/d1Z80ab9BtmuJ3Xk5cJKds9TZt/uLPXvtiTrQ+dIwqfAejUvM1os6FNikXKUHfQ+ekUsXT5u85enJ0CaBSkkGEo1syUQ+DfMdE/4GA1uzupf9zdbzhOmLsF4efHVXjaHHAzmDtGdQRd/Nc5wAEJjNki3XfhyvwVNz80xANrht3LsENY9cBBdN1L9GUyyvFRFZ42t75sBvCQRykbRlU4tT2pPxoCvzx09d4GmPs200M6wKdWSDGK8mppYSWdhAlt0qeaLv+IadXU9/Evq4FAZ8ej+LmtcTxaRX4NWI0Uag5Vg1p5MYg8BnlhXIdPHDow+vTWZvVMVttXDLqkTzZdPj6Qii6cP1cSvIdl3iQkNYyi9HH0I22y+93tY3DcQkTZgQtM+POoCr8x97eylkmtrgKuztrvXJ21x/aNKuqIkZ/fntRfCdcTfhUTAIhRzoDojJD0aSNLLwMzmpT7+JaLtyf1MwDo6qz9djFaUq3t9MlFmy/c1OCSceY9fMsVaL9mvH9ocXdkdWxv1scAePG0THAhMOaLdOw/Gvxfxb1w4eCapyIENUcV5M3/u8FitAxZ25P6GAHT3UX39Srw+QOb1ZffA98Dl2Wy1BYkAAAAAElFTkSuQmCC'); background-repeat: no-repeat; background-position: right 5px center; background-size: 16px; @@ -86,7 +90,7 @@ background: none; border: 0; border-radius: $border-radius-base; - transition: transform .15s cubic-bezier(.3, 0, .2, 2); + transition: transform 0.15s cubic-bezier(0.3, 0, 0.2, 2); &:hover { background-color: transparent; @@ -176,7 +180,7 @@ &.user-authored { cursor: default; background-color: $gray-light; - border-color: $theme-gray-200; + border-color: $gray-200; color: $gl-text-color-disabled; gl-emoji { @@ -229,10 +233,10 @@ height: $default-icon-size; width: $default-icon-size; border-radius: 50%; + } - path { - fill: $border-gray-normal; - } + path { + fill: $border-gray-normal; } } @@ -243,6 +247,10 @@ left: 10px; bottom: 6px; opacity: 0; + + path { + fill: $award-emoji-positive-add-lines; + } } .award-control-text { diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 43b7c26b272..e6c55252b24 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -86,11 +86,8 @@ } .block-controls { - display: -webkit-flex; display: flex; - -webkit-justify-content: flex-end; justify-content: flex-end; - -webkit-flex: 1; flex: 1; .control { @@ -153,7 +150,7 @@ display: inline-block; margin-left: 5px; font-size: 18px; - color: color("gray"); + color: color('gray'); } p { @@ -228,7 +225,6 @@ } .group-info { - h1 { display: inline; font-weight: $gl-font-weight-normal; diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index a4a9276c580..a4af84f8d27 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -42,6 +42,10 @@ color: $text; border-color: $border; + &.btn-border-color { + border-color: $border-color; + } + > .icon { color: $text; } @@ -162,7 +166,8 @@ @include btn-outline($white-light, $green-600, $green-500, $green-500, $white-light, $green-600, $green-600, $green-700); } - &.btn-remove { + &.btn-remove, + &.btn-danger { @include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700); } @@ -390,8 +395,6 @@ cursor: default; &:active { - -moz-box-shadow: inset 0 0 0 $white-light; - -webkit-box-shadow: inset 0 0 0 $white-light; box-shadow: inset 0 0 0 $white-light; } } @@ -483,7 +486,7 @@ // All disabled buttons, regardless of color, type, etc %disabled { background-color: $gray-light !important; - border-color: $theme-gray-200 !important; + border-color: $gray-200 !important; color: $gl-text-color-disabled !important; opacity: 1 !important; cursor: default !important; diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index e037b02a30c..aad5150c0b5 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -48,7 +48,20 @@ color: $brand-info; } -.hint { font-style: italic; color: $gl-gray-400; } +.text-break-word { + word-break: break-all; +} + +.text-underline, +.text-underline:hover { + text-decoration: underline; +} + +.hint { + font-style: italic; + color: $gl-gray-400; +} + .light { color: $gl-text-color; } .slead { @@ -89,6 +102,10 @@ hr { .str-truncated { @include str-truncated; + &-30 { + @include str-truncated(30%); + } + &-60 { @include str-truncated(60%); } @@ -145,13 +162,14 @@ p.time { text-shadow: none; } -.thin_area { +.thin-area { height: 150px; } // Fix issue with notes & lists creating a bunch of bottom borders. li.note { img { max-width: 100%; } + .note-title { li { border-bottom: 0 !important; @@ -322,7 +340,7 @@ img.emoji { .disabled-content { pointer-events: none; - opacity: .5; + opacity: 0.5; } .break-word { @@ -381,13 +399,35 @@ img.emoji { .inline { display: inline-block; } .center { text-align: center; } .vertical-align-middle { vertical-align: middle; } +.vertical-align-sub { vertical-align: sub; } .flex-align-self-center { align-self: center; } .flex-grow { flex-grow: 1; } .flex-no-shrink { flex-shrink: 0; } -.mw-460 { max-width: 460px; } .ws-initial { white-space: initial; } +.ws-normal { white-space: normal; } +.overflow-auto { overflow: auto; } + +.d-flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +/** COMMON SIZING CLASSES **/ +.w-0 { width: 0; } + +.h-12em { height: 12em; } + +.mw-460 { max-width: 460px; } +.mw-6em { max-width: 6em; } + .min-height-0 { min-height: 0; } +.w-3 { width: #{3 * $grid-size}; } + +.h-3 { width: #{3 * $grid-size}; } + +/** COMMON SPACING CLASSES **/ .gl-pl-0 { padding-left: 0; } .gl-pl-1 { padding-left: #{0.5 * $grid-size}; } .gl-pl-2 { padding-left: $grid-size; } @@ -401,3 +441,36 @@ img.emoji { .gl-pr-3 { padding-right: #{2 * $grid-size}; } .gl-pr-4 { padding-right: #{3 * $grid-size}; } .gl-pr-5 { padding-right: #{4 * $grid-size}; } + +/** + * Removes browser specific clear icon from input fields in + * Internet Explorer 10, Internet Explorer 11, and Microsoft Edge. + * This is intended for elements which add a customized clear icon. + * + * see also https://developer.mozilla.org/en-US/docs/Web/CSS/::-ms-clear + */ +.ms-no-clear ::-ms-clear { + display: none; +} + +/** COMMON POSITIONING CLASSES */ +.position-bottom-0 { bottom: 0; } +.position-left-0 { left: 0; } +.position-right-0 { right: 0; } +.position-top-0 { top: 0; } + +.drag-handle { + width: 4px; + + &:hover { + background-color: $white-normal; + } + + &.is-dragging { + background-color: $gray-600; + } +} + +.cursor-pointer { + cursor: pointer; +} diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 8b6a7017c47..3238b01c6c0 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -5,7 +5,7 @@ padding-left: $contextual-sidebar-collapsed-width; } - @include media-breakpoint-up(lg) { + @include media-breakpoint-up(xl) { padding-left: $contextual-sidebar-width; } @@ -15,7 +15,7 @@ } .page-with-icon-sidebar { - @include media-breakpoint-up(sm) { + @include media-breakpoint-up(md) { padding-left: $contextual-sidebar-collapsed-width; } } @@ -71,6 +71,44 @@ } } +@mixin collapse-contextual-sidebar-content { + .context-header { + height: 60px; + width: $contextual-sidebar-collapsed-width; + + a { + padding: 10px 4px; + } + } + + .sidebar-top-level-items > li { + .sidebar-sub-level-items { + &:not(.flyout-list) { + display: none; + } + } + } + + .nav-icon-container { + margin-right: 0; + } + + .toggle-sidebar-button { + padding: 16px; + width: $contextual-sidebar-collapsed-width - 1px; + + .collapse-text, + .icon-angle-double-left { + display: none; + } + + .icon-angle-double-right { + display: block; + margin: 0; + } + } +} + .nav-sidebar { transition: width $sidebar-transition-duration, left $sidebar-transition-duration; position: fixed; @@ -89,7 +127,7 @@ } } - &.sidebar-collapsed-desktop { + @mixin collapse-contextual-sidebar { width: $contextual-sidebar-collapsed-width; .nav-sidebar-inner-scroll { @@ -115,6 +153,10 @@ } } + &.sidebar-collapsed-desktop { + @include collapse-contextual-sidebar; + } + &.sidebar-expanded-mobile { left: 0; } @@ -150,7 +192,7 @@ } } - @include media-breakpoint-down(xs) { + @include media-breakpoint-down(sm) { left: (-$contextual-sidebar-width); } @@ -167,16 +209,19 @@ height: 16px; width: 16px; } + + @media (min-width: map-get($grid-breakpoints, md)) and (max-width: map-get($grid-breakpoints, xl) - 1px) { + &:not(.sidebar-expanded-mobile) { + @include collapse-contextual-sidebar; + @include collapse-contextual-sidebar-content; + } + } } .nav-sidebar-inner-scroll { height: 100%; width: 100%; overflow: auto; - - @include media-breakpoint-up(sm) { - overflow: hidden; - } } .with-performance-bar .nav-sidebar { @@ -346,53 +391,13 @@ } } -.toggle-sidebar-button { - @include media-breakpoint-down(xs) { - display: none; - } -} - .collapse-text { white-space: nowrap; overflow: hidden; } .sidebar-collapsed-desktop { - .context-header { - height: 60px; - width: $contextual-sidebar-collapsed-width; - - a { - padding: 10px 4px; - } - } - - .sidebar-top-level-items > li { - .sidebar-sub-level-items { - &:not(.flyout-list) { - display: none; - } - } - } - - .nav-icon-container { - margin-right: 0; - } - - .toggle-sidebar-button { - padding: 16px; - width: $contextual-sidebar-collapsed-width - 1px; - - .collapse-text, - .icon-angle-double-left { - display: none; - } - - .icon-angle-double-right { - display: block; - margin: 0; - } - } + @include collapse-contextual-sidebar-content; } .fly-out-top-item { @@ -428,16 +433,14 @@ color: $gl-text-color-secondary; } - @include media-breakpoint-down(xs) { + @include media-breakpoint-down(sm) { display: flex; align-items: center; i { font-size: 18px; } - } - @include media-breakpoint-down(xs) { + .breadcrumbs-links { padding-left: $gl-padding; border-left: 1px solid $gl-text-color-quaternary; @@ -445,21 +448,25 @@ } } -@include media-breakpoint-down(xs) { +@include media-breakpoint-down(sm) { .close-nav-button { display: flex; } -} -.mobile-overlay { - display: none; + .toggle-sidebar-button { + display: none; + } - &.mobile-nav-open { - display: block; - position: fixed; - background-color: $black-transparent; - height: 100%; - width: 100%; - z-index: 300; + .mobile-overlay { + display: none; + + &.mobile-nav-open { + display: block; + position: fixed; + background-color: $black-transparent; + height: 100%; + width: 100%; + z-index: 300; + } } } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index afcb230797a..b90db135b4a 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -32,6 +32,10 @@ max-height: $dropdown-max-height; overflow-y: auto; + &.dropdown-extended-height { + max-height: 400px; + } + @include media-breakpoint-down(xs) { width: 100%; } @@ -125,7 +129,7 @@ @extend .dropdown-toggle; padding-right: 25px; position: relative; - width: 163px; + width: 160px; text-overflow: ellipsis; overflow: hidden; diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss index be85e03430e..13c5541da92 100644 --- a/app/assets/stylesheets/framework/emojis.scss +++ b/app/assets/stylesheets/framework/emojis.scss @@ -2,7 +2,7 @@ gl-emoji { font-style: normal; display: inline-flex; vertical-align: middle; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; font-size: 1.4em; line-height: 1em; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 037a5adfb7e..6108eaa1ad0 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -4,6 +4,7 @@ */ .file-holder { border: 1px solid $border-color; + border-top: 0; border-radius: $border-radius-default; &.file-holder-no-border { @@ -51,6 +52,7 @@ position: absolute; top: 5px; right: 15px; + margin-left: auto; .btn { padding: 0 10px; @@ -324,10 +326,12 @@ span.idiff { &, .file-holder & { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; background-color: $gray-light; border-bottom: 1px solid $border-color; + border-top: 1px solid $border-color; padding: 5px $gl-padding; margin: 0; border-radius: $border-radius-default $border-radius-default 0 0; @@ -365,16 +369,12 @@ span.idiff { margin: 0 10px 0 0; } - .file-actions { - white-space: nowrap; - - .btn { - padding: 0 10px; - font-size: 13px; - line-height: 28px; - display: inline-block; - float: none; - } + .file-actions .btn { + padding: 0 10px; + font-size: 13px; + line-height: 28px; + display: inline-block; + float: none; } @include media-breakpoint-down(xs) { diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index f48b3ddc912..e5b529ae11d 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -50,19 +50,15 @@ } .filtered-search-wrapper { - display: -webkit-flex; display: flex; @include media-breakpoint-down(xs) { - -webkit-flex-direction: column; flex-direction: column; } .tokens-container { - display: -webkit-flex; display: flex; flex: 1; - -webkit-flex: 1; padding-left: 12px; position: relative; margin-bottom: 0; @@ -82,21 +78,18 @@ .input-token:only-child, .input-token:last-child { flex: 1; - -webkit-flex: 1; max-width: inherit; } } .filtered-search-token, .filtered-search-term { - display: -webkit-flex; display: flex; flex-shrink: 0; margin-top: 4px; margin-bottom: 4px; .selectable { - display: -webkit-flex; display: flex; } @@ -176,7 +169,6 @@ } .scroll-container { - display: -webkit-flex; display: flex; overflow-x: auto; white-space: nowrap; @@ -186,7 +178,6 @@ .filtered-search-box { position: relative; flex: 1; - display: -webkit-flex; display: flex; width: 100%; min-width: 0; @@ -194,7 +185,6 @@ background-color: $white-light; @include media-breakpoint-down(xs) { - -webkit-flex: 1 1 auto; flex: 1 1 auto; margin-bottom: 10px; } @@ -349,7 +339,6 @@ } .filter-dropdown-container { - display: -webkit-flex; display: flex; .dropdown-toggle { diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index 4da2243981e..3b1d1d67509 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -36,6 +36,15 @@ label { } } +.label-wrapper { + display: block; + margin: 0; +} + +.form-label { + @extend label; +} + .form-control-label { @extend .col-md-2; } @@ -147,8 +156,6 @@ label { .select-control { padding-left: 10px; padding-right: 10px; - -webkit-appearance: none; - -moz-appearance: none; appearance: none; &::-ms-expand { @@ -169,7 +176,8 @@ label { font-weight: $gl-font-weight-normal; } -.form-control::-webkit-input-placeholder { + +.form-control::placeholder { color: $gl-text-color-tertiary; } @@ -252,7 +260,7 @@ label { right: 0.8em; top: 50%; transform: translateY(-50%); - color: $theme-gray-600; + color: $gray-600; } } diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss index 50d4298d418..6943bfbc3d0 100644 --- a/app/assets/stylesheets/framework/gfm.scss +++ b/app/assets/stylesheets/framework/gfm.scss @@ -32,7 +32,7 @@ height: $chip-size; background: $white-light; background-image: linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%), - linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%); + linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%); background-size: $bg-size $bg-size; background-position: 0 0, $bg-pos $bg-pos; diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss index 0ef50e139f2..418eafa153c 100644 --- a/app/assets/stylesheets/framework/gitlab_theme.scss +++ b/app/assets/stylesheets/framework/gitlab_theme.scss @@ -282,31 +282,31 @@ body { &.ui-dark { @include gitlab-theme( - $theme-gray-200, - $theme-gray-500, - $theme-gray-700, - $theme-gray-800, - $theme-gray-900, + $gray-200, + $gray-500, + $gray-700, + $gray-800, + $gray-900, $white-light ); } &.ui-light { @include gitlab-theme( - $theme-gray-700, - $theme-gray-800, - $theme-gray-700, - $theme-gray-700, - $theme-gray-100, - $theme-gray-700 + $gray-700, + $gray-800, + $gray-700, + $gray-700, + $gray-100, + $gray-700 ); .navbar-gitlab { - background-color: $theme-gray-100; + background-color: $gray-100; box-shadow: 0 1px 0 0 $border-color; .logo-text svg { - fill: $theme-gray-900; + fill: $gray-900; } .navbar-sub-nav, @@ -315,7 +315,7 @@ body { > a:hover, > a:focus, > button:hover { - color: $theme-gray-900; + color: $gray-900; } &.active > a, @@ -329,8 +329,8 @@ body { .container-fluid { .navbar-toggler, .navbar-toggler:hover { - color: $theme-gray-700; - border-left: 1px solid $theme-gray-200; + color: $gray-700; + border-left: 1px solid $gray-200; } } } @@ -348,7 +348,7 @@ body { .search-input-wrap { .search-icon { - fill: $theme-gray-200; + fill: $gray-200; } .search-input { @@ -359,16 +359,16 @@ body { .nav-sidebar li.active { > a { - color: $theme-gray-900; + color: $gray-900; } svg { - fill: $theme-gray-900; + fill: $gray-900; } } .sidebar-top-level-items > li.active .badge.badge-pill { - color: $theme-gray-900; + color: $gray-900; } } } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 7d283dcfb71..f5ed6621c55 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -39,7 +39,6 @@ .header-content { width: 100%; - display: -webkit-flex; display: flex; justify-content: space-between; position: relative; @@ -47,11 +46,8 @@ padding-left: 0; .title-container { - display: -webkit-flex; display: flex; - -webkit-align-items: stretch; align-items: stretch; - -webkit-flex: 1 1 auto; flex: 1 1 auto; padding-top: 0; overflow: visible; @@ -60,7 +56,6 @@ .title { padding-right: 0; color: currentColor; - display: -webkit-flex; display: flex; position: relative; margin: 0; @@ -85,7 +80,6 @@ } a { - display: -webkit-flex; display: flex; align-items: center; padding: 2px 8px; @@ -173,7 +167,6 @@ .navbar-nav { @include media-breakpoint-down(xs) { - display: -webkit-flex; display: flex; padding-right: 10px; flex-direction: row; @@ -258,7 +251,6 @@ > li { > a, > button { - display: -webkit-flex; display: flex; align-items: center; justify-content: center; @@ -294,7 +286,6 @@ } .navbar-sub-nav { - display: -webkit-flex; display: flex; margin: 0 0 0 6px; @@ -326,14 +317,12 @@ } .breadcrumbs { - display: -webkit-flex; display: flex; min-height: $breadcrumb-min-height; color: $gl-text-color; } .breadcrumbs-container { - display: -webkit-flex; display: flex; width: 100%; position: relative; @@ -344,7 +333,6 @@ } .breadcrumbs-links { - -webkit-flex: 1; flex: 1; min-width: 0; align-self: center; @@ -379,7 +367,6 @@ } .breadcrumbs-list { - display: -webkit-flex; display: flex; margin-bottom: 0; line-height: 16px; @@ -430,7 +417,6 @@ } .breadcrumbs-extra { - display: -webkit-flex; display: flex; flex: 0 0 auto; margin-left: auto; @@ -565,15 +551,14 @@ } .navbar-empty { + justify-content: center; height: $header-height; background: $white-light; border-bottom: 1px solid $white-normal; - .mx-auto { - .tanuki-logo, - img { - height: 36px; - } + .tanuki-logo, + .brand-header-logo { + max-height: 100%; } } @@ -596,4 +581,16 @@ .emoji-menu-toggle-button { @include emoji-menu-toggle-button; } + + .input-group { + height: 34px; + } +} + +.nav-links > li > a { + .badge.badge-pill { + @include media-breakpoint-down(xs) { display: none; } + } + + @include media-breakpoint-down(xs) { margin-right: 3px; } } diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 8db7d63266e..49b9b7014ae 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -87,8 +87,8 @@ display: flex; align-items: center; justify-content: center; - border: $border-size solid $theme-gray-400; + border: $border-size solid $gray-400; border-radius: 50%; padding: $gl-padding-8 - $border-size; - color: $theme-gray-700; + color: $gray-700; } diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss index a20920e2503..d78c707192f 100644 --- a/app/assets/stylesheets/framework/images.scss +++ b/app/assets/stylesheets/framework/images.scss @@ -38,7 +38,10 @@ svg { fill: currentColor; +} +.square, +svg { $svg-sizes: 8 10 12 14 16 18 24 32 48 72; @each $svg-size in $svg-sizes { &.s#{$svg-size} { diff --git a/app/assets/stylesheets/framework/issue_box.scss b/app/assets/stylesheets/framework/issue_box.scss index a66604e56ff..e51f230a680 100644 --- a/app/assets/stylesheets/framework/issue_box.scss +++ b/app/assets/stylesheets/framework/issue_box.scss @@ -45,9 +45,4 @@ &.status-box-upcoming { background: $gl-text-color-secondary; } - - &.status-box-milestone { - color: $gl-text-color; - background: $gray-darker; - } } diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 9218df9b40f..97cb9d90ff0 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -40,6 +40,14 @@ body { .content { margin: 0; + + @include media-breakpoint-down(xs) { margin-top: 20px; } + } + + @include media-breakpoint-down(xs) { + .container .title { + padding-left: 15px !important; + } } } diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index d9d4a210f5f..1a74e06a75d 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -15,7 +15,7 @@ word-wrap: break-word; &::after { - content: " "; + content: ' '; display: table; clear: both; } @@ -167,7 +167,7 @@ ul.content-list { } .no-comments { - opacity: .5; + opacity: 0.5; } } @@ -196,8 +196,6 @@ ul.content-list { // Content list using flexbox .flex-list { .flex-row { - display: -webkit-flex; - display: -ms-flexbox; display: flex; align-items: center; white-space: nowrap; diff --git a/app/assets/stylesheets/framework/logo.scss b/app/assets/stylesheets/framework/logo.scss index 429cfbe7235..c5feefb8c54 100644 --- a/app/assets/stylesheets/framework/logo.scss +++ b/app/assets/stylesheets/framework/logo.scss @@ -9,7 +9,6 @@ } .tanuki-logo { - .tanuki-left-ear, .tanuki-right-ear, .tanuki-nose { @@ -34,7 +33,9 @@ .tanuki-left-cheek { @include include-keyframes(animate-tanuki-left-cheek) { - 0%, 10%, 100% { + 0%, + 10%, + 100% { fill: lighten($tanuki-yellow, 25%); } @@ -46,11 +47,13 @@ .tanuki-left-eye { @include include-keyframes(animate-tanuki-left-eye) { - 10%, 80% { + 10%, + 80% { fill: $tanuki-orange; } - 20%, 90% { + 20%, + 90% { fill: lighten($tanuki-orange, 25%); } } @@ -58,11 +61,13 @@ .tanuki-left-ear { @include include-keyframes(animate-tanuki-left-ear) { - 10%, 80% { + 10%, + 80% { fill: $tanuki-red; } - 20%, 90% { + 20%, + 90% { fill: lighten($tanuki-red, 25%); } } @@ -70,11 +75,13 @@ .tanuki-nose { @include include-keyframes(animate-tanuki-nose) { - 20%, 70% { + 20%, + 70% { fill: $tanuki-red; } - 30%, 80% { + 30%, + 80% { fill: lighten($tanuki-red, 25%); } } @@ -82,11 +89,13 @@ .tanuki-right-eye { @include include-keyframes(animate-tanuki-right-eye) { - 30%, 60% { + 30%, + 60% { fill: $tanuki-orange; } - 40%, 70% { + 40%, + 70% { fill: lighten($tanuki-orange, 25%); } } @@ -94,11 +103,13 @@ .tanuki-right-ear { @include include-keyframes(animate-tanuki-right-ear) { - 30%, 60% { + 30%, + 60% { fill: $tanuki-red; } - 40%, 70% { + 40%, + 70% { fill: lighten($tanuki-red, 25%); } } diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 5609a2086e6..d6c4e68f68f 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -149,16 +149,8 @@ margin: 10px 0; } - // Border around images in issue and MR comments. - img:not(.emoji) { - border: 1px solid $white-normal; - padding: 5px; - margin: 5px 0; - // Ensure that image does not exceed viewport - max-height: calc(100vh - 100px); - } - table { + table:not(.js-syntax-highlight) { @include markdown-table; } } @@ -173,7 +165,7 @@ svg { width: 14px; height: 14px; - margin-top: 3px; + vertical-align: middle; fill: $gl-text-color-secondary; } @@ -228,7 +220,7 @@ .cur { .avatar { - @include disableAllAnimation; + @include disable-all-animation; border: 1px solid $white-light; } } @@ -280,6 +272,8 @@ .md-suggestion-diff { display: table !important; border: 1px solid $border-color !important; + width: 100% !important; + font-family: $monospace-font !important; } .md-suggestion-header { @@ -307,4 +301,8 @@ overflow: hidden; text-overflow: ellipsis; } + + .referenced-users { + margin-right: 0; + } } diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 9837b1a6bd0..ba3b0906e28 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -26,20 +26,11 @@ */ @mixin markdown-table { width: auto; - display: inline-block; + display: block; overflow-x: auto; border: 0; border-color: $gl-gray-100; - @supports (width: fit-content) { - display: block; - width: fit-content; - } - - tbody { - background-color: $white-light; - } - tr { th { border-bottom: solid 2px $gl-gray-100; @@ -117,11 +108,6 @@ } } -@mixin dark-diff-match-line { - color: $dark-diff-match-bg; - background: $dark-diff-match-color; -} - @mixin webkit-prefix($property, $value) { #{'-webkit-' + $property}: $value; #{$property}: $value; @@ -129,16 +115,13 @@ /* http://phrappe.com/css/conditional-css-for-webkit-based-browsers/ */ @mixin on-webkit-only { + /* stylelint-disable-next-line media-feature-name-no-vendor-prefix */ @media screen and (-webkit-min-device-pixel-ratio: 0) { @content; } } @mixin keyframes($animation-name) { - @-webkit-keyframes #{$animation-name} { - @content; - } - @keyframes #{$animation-name} { @content; } @@ -178,12 +161,10 @@ width: 43px; height: 30px; transition-duration: 0.3s; - -webkit-transform: translateZ(0); - background: linear-gradient( - to $gradient-direction, - $gradient-color 45%, - rgba($gradient-color, 0.4) - ); + transform: translateZ(0); + background: linear-gradient(to $gradient-direction, + $gradient-color 45%, + rgba($gradient-color, 0.4)); &.scrolling { visibility: visible; @@ -279,7 +260,6 @@ border: 1px solid $border-color; color: $gl-text-color; position: sticky; - position: -webkit-sticky; top: $header-height; padding: $grid-size; diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss deleted file mode 100644 index 3bb046d0e51..00000000000 --- a/app/assets/stylesheets/framework/mobile.scss +++ /dev/null @@ -1,88 +0,0 @@ -/** Common mobile (screen XS, SM) styles **/ -@include media-breakpoint-down(xs) { - .container .content { - margin-top: 20px; - } - - .nav-links > li > a { - padding: 10px; - font-size: 12px; - margin-right: 3px; - - .badge.badge-pill { - display: none; - } - } - - .referenced-users { - margin-right: 0; - } - - .issues-details-filters:not(.filtered-search-block), - .dash-projects-filters, - .check-all-holder { - display: none; - } - - .rss-btn { - display: none; - } - - .project-home-links { - display: none; - } - - .project-home-panel { - padding-left: 0 !important; - - .project-repo-buttons, - .git-clone-holder { - display: none; - } - } - - .group-buttons { - display: none; - } - - .container .title { - padding-left: 15px !important; - } - - .nav-links, - .nav-links { - li a { - font-size: 14px; - padding: 19px 10px; - } - } - - .activity-filter-block { - display: none; - } - - .projects-search-form { - .btn { - display: none; - } - } -} - -@include media-breakpoint-down(sm) { - .issues-filters { - .milestone-filter { - display: none; - } - } - - .page-title { - .note-created-ago, - .new-issue-link { - display: none; - } - } - - aside:not(.right-sidebar) { - display: none; - } -} diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 95291b4a9ad..3703b7568c8 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -29,10 +29,6 @@ padding-right: 28px; } } - - .page-title { - margin-top: 0; - } } .modal-body { @@ -105,3 +101,42 @@ body.modal-open { margin: 0; } } + +.issues-import-modal, +.issues-export-modal { + .modal-header { + justify-content: flex-start; + + .import-export-svg-container { + flex-grow: 1; + height: 56px; + padding: $gl-btn-padding $gl-btn-padding 0; + text-align: right; + + .illustration { + height: inherit; + width: initial; + } + } + } + + .modal-body { + padding: 0; + + .modal-subheader { + justify-content: flex-start; + align-items: center; + border-bottom: 1px solid $modal-border-color; + padding: 14px; + } + + .modal-text { + padding: $gl-padding-24 $gl-padding; + min-height: $modal-body-height; + } + } + + .checkmark { + color: $green-400; + } +} diff --git a/app/assets/stylesheets/framework/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss index 29a9c076cdf..6bd44ee19bd 100644 --- a/app/assets/stylesheets/framework/responsive_tables.scss +++ b/app/assets/stylesheets/framework/responsive_tables.scss @@ -39,7 +39,7 @@ .table-section { white-space: nowrap; - $section-widths: 5 10 15 20 25 30 40 50 60 100; + $section-widths: 5 10 15 20 25 30 40 50 60 70 80 100; @each $width in $section-widths { &.section-#{$width} { flex: 0 0 #{$width + '%'}; diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index a68f1e4e570..ac673eafdc7 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -32,7 +32,7 @@ } &::after { - content: "\f078"; + content: '\f078'; position: absolute; z-index: 1; text-align: center; @@ -57,6 +57,16 @@ color: $gl-text-color; } } + + &.is-invalid { + ~ .invalid-feedback { + display: block; + } + + .select2-choices { + border-color: $red-500; + } + } } .select2-drop, @@ -67,10 +77,18 @@ min-width: 175px; color: $gl-text-color; z-index: 999; + + .modal-open & { + z-index: $zindex-modal + 200; + } } .select2-drop-mask { z-index: 998; + + .modal-open & { + z-index: $zindex-modal + 100; + } } .select2-drop.select2-drop-above.select2-drop-active { diff --git a/app/assets/stylesheets/framework/spinner.scss b/app/assets/stylesheets/framework/spinner.scss new file mode 100644 index 00000000000..91fe75075dc --- /dev/null +++ b/app/assets/stylesheets/framework/spinner.scss @@ -0,0 +1,51 @@ +@mixin spinner-color($color) { + border-color: rgba($color, 0.25); + border-top-color: $color; +} + +@mixin spinner-size($size, $border-width) { + width: $size; + height: $size; + border-width: $border-width; + @include webkit-prefix(transform-origin, 50% 50% calc((#{$size} / 2) + #{$border-width})); +} + +@keyframes spinner-rotate { + 0% { + transform: rotate(0); + } + + 100% { + transform: rotate(360deg); + } +} + +.spinner { + border-radius: 50%; + position: relative; + margin: 0 auto; + animation-name: spinner-rotate; + animation-duration: 0.6s; + animation-timing-function: linear; + animation-iteration-count: infinite; + border-style: solid; + display: inline-flex; + @include spinner-size(16px, 2px); + @include spinner-color($orange-600); + + &.spinner-md { + @include spinner-size(32px, 3px); + } + + &.spinner-lg { + @include spinner-size(64px, 4px); + } + + &.spinner-dark { + @include spinner-color($gray-700); + } + + &.spinner-light { + @include spinner-color($white); + } +} diff --git a/app/assets/stylesheets/framework/stacked_progress_bar.scss b/app/assets/stylesheets/framework/stacked_progress_bar.scss index 29a2d5881f7..2255d3be75b 100644 --- a/app/assets/stylesheets/framework/stacked_progress_bar.scss +++ b/app/assets/stylesheets/framework/stacked_progress_bar.scss @@ -3,7 +3,7 @@ height: 16px; border-radius: 10px; overflow: hidden; - background-color: $theme-gray-100; + background-color: $gray-100; .status-unavailable, .status-green, @@ -24,7 +24,7 @@ .status-unavailable { padding: 0 10px; - color: $theme-gray-700; + color: $gray-700; } .status-green { @@ -36,11 +36,11 @@ } .status-neutral { - background-color: $theme-gray-200; + background-color: $gray-200; color: $gl-gray-dark; &:hover { - background-color: $theme-gray-300; + background-color: $gray-300; } } diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss new file mode 100644 index 00000000000..3d66136938f --- /dev/null +++ b/app/assets/stylesheets/framework/system_messages.scss @@ -0,0 +1,110 @@ +.header-message, +.footer-message { + padding: 0 15px; + border: 1px solid transparent; + border-radius: 0; + position: fixed; + left: 0; + width: 100%; + text-align: center; + margin: 0; + z-index: 1000; + + p { + @include str-truncated(100%); + margin-top: 0; + margin-bottom: 0; + } +} + +.header-message { + top: 0; + height: $system-header-height; + line-height: $system-header-height; +} + +.footer-message { + bottom: 0; + height: $system-footer-height; + line-height: $system-footer-height; +} + +.with-performance-bar { + .header-message { + top: $performance-bar-height; + } +} + +// System Header +.with-system-header { + // main navigation + // login page + .navbar-gitlab, + .fixed-top { + top: $system-header-height; + } + + // left sidebar eg: project page + // right sidebar eg: MR page + .nav-sidebar, + .right-sidebar { + top: $system-header-height + $header-height; + } + + .content-wrapper { + margin-top: $system-header-height + $header-height; + } + + // Performance Bar + // System Header + &.with-performance-bar { + // main navigation + header.navbar-gitlab { + top: $performance-bar-height + $system-header-height; + } + + .layout-page { + margin-top: $header-height + $performance-bar-height + $system-header-height; + } + + // left sidebar eg: project page + // right sidebar eg: MR page + .nav-sidebar, + .right-sidebar { + top: $header-height + $performance-bar-height + $system-header-height; + } + } +} + +// System Footer +.with-system-footer { + // left sidebar eg: project page + // right sidebar eg: mr page + .nav-sidebar, + .right-sidebar, + // navless pages' footer eg: login page + // navless pages' footer border eg: login page + &.devise-layout-html body .footer-container, + &.devise-layout-html body hr.footer-fixed { + bottom: $system-footer-height; + } +} + +.fullscreen-layout { + .header-message, + .footer-message { + position: static; + top: auto; + bottom: auto; + } + + .content-wrapper { + .with-system-header & { + margin-top: 0; + } + + .with-system-footer & { + margin-top: 0; + } + } +} diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss index 6954e6599b1..295a5b5ee7a 100644 --- a/app/assets/stylesheets/framework/tables.scss +++ b/app/assets/stylesheets/framework/tables.scss @@ -50,6 +50,14 @@ table { border-color: $white-normal; } } + + .thead-white { + th { + background-color: $white-light; + color: $gl-text-color-secondary; + border-top: 0; + } + } } &.responsive-table { @@ -153,3 +161,4 @@ table { border-top: 0; } } + diff --git a/app/assets/stylesheets/framework/terms.scss b/app/assets/stylesheets/framework/terms.scss index 3f4be8829d7..b07d6023127 100644 --- a/app/assets/stylesheets/framework/terms.scss +++ b/app/assets/stylesheets/framework/terms.scss @@ -13,7 +13,6 @@ .card { .card-header { - display: -webkit-flex; display: flex; align-items: center; justify-content: space-between; diff --git a/app/assets/stylesheets/framework/toggle.scss b/app/assets/stylesheets/framework/toggle.scss index 8258da07e4d..5f8ac3b7e37 100644 --- a/app/assets/stylesheets/framework/toggle.scss +++ b/app/assets/stylesheets/framework/toggle.scss @@ -34,7 +34,7 @@ background: $gl-gray-400; border-radius: 12px; padding: 3px; - transition: all .4s ease; + transition: all 0.4s ease; &::selection, &::before::selection, @@ -52,7 +52,7 @@ left: 0; border-radius: 9px; background: $feature-toggle-color; - transition: all .2s ease; + transition: all 0.2s ease; &, .toggle-icon-svg { @@ -135,12 +135,18 @@ } @keyframes animate-enabled { - 0%, 35% { opacity: 0; } + 0%, + + 35% { opacity: 0; } + 100% { opacity: 1; } } @keyframes animate-disabled { - 0%, 35% { opacity: 0; } + 0%, + + 35% { opacity: 0; } + 100% { opacity: 1; } } } diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 0c81dc2e156..55ce0d7004e 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -2,7 +2,7 @@ color: $gl-text-color; word-wrap: break-word; - [dir="auto"] { + [dir='auto'] { text-align: initial; } @@ -49,13 +49,6 @@ word-wrap: normal; } - // Multi-line code blocks should scroll horizontally - pre { - code { - white-space: pre; - } - } - kbd { display: inline-block; padding: 3px 5px; @@ -143,11 +136,12 @@ margin: 0 0 16px; } - table { + table:not(.js-syntax-highlight) { @extend .table; @extend .table-bordered; margin: 16px 0; color: $gl-text-color; + border: 0; th { background: $label-gray-bg; @@ -165,6 +159,10 @@ overflow-x: auto; border-radius: 2px; + // Multi-line code blocks should scroll horizontally + code { + white-space: pre; + } &.plain-readme { background: none; @@ -226,8 +224,8 @@ } } - a[href*="/uploads/"], - a[href*="storage.googleapis.com/google-code-attachments/"] { + a[href*='/uploads/'], + a[href*='storage.googleapis.com/google-code-attachments/'] { &::before { margin-right: 4px; @@ -235,7 +233,7 @@ font-size: inherit; text-rendering: auto; -webkit-font-smoothing: antialiased; - content: "\f0c6"; + content: '\f0c6'; } &:hover::before { @@ -302,11 +300,10 @@ body { } .page-title-empty { - margin-top: 0; + margin: 12px 0; line-height: 1.3; font-size: 1.25em; font-weight: $gl-font-weight-bold; - margin: 12px 0; } h1, @@ -374,6 +371,16 @@ code { .md:not(.use-csslab) { @include md-typography; + + &:not(.wiki) { + img:not(.emoji) { + border: 1px solid $white-normal; + padding: 5px; + margin: 5px 0; + // Ensure that image does not exceed viewport + max-height: calc(100vh - 100px); + } + } } /** @@ -416,6 +423,7 @@ h4 { /** * form text input i.e. search bar, comments, forms, etc. */ +/* stylelint-disable selector-no-vendor-prefix */ input, textarea { &::-webkit-input-placeholder { @@ -440,5 +448,6 @@ textarea { color: $gl-text-color-tertiary; } } +/* stylelint-enable */ .lh-100 { line-height: 1; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 343c09b4a3e..0333b9445c5 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -32,13 +32,22 @@ $gray-dark: darken($gray-light, $darken-dark-factor); $gray-darker: #eee; $gray-darkest: #c4c4c4; -$gl-gray-100: #dddddd; -$gl-gray-200: #cccccc; -$gl-gray-350: #aaaaaa; -$gl-gray-400: #999999; -$gl-gray-500: #777777; -$gl-gray-600: #666666; -$gl-gray-700: #555555; +$black: #000; +$black-transparent: rgba(0, 0, 0, 0.3); +$almost-black: #242424; + +$t-gray-a-02: rgba($black, 0.02); +$t-gray-a-04: rgba($black, 0.04); +$t-gray-a-06: rgba($black, 0.06); +$t-gray-a-08: rgba($black, 0.08); + +$gl-gray-100: #ddd; +$gl-gray-200: #ccc; +$gl-gray-350: #aaa; +$gl-gray-400: #999; +$gl-gray-500: #777; +$gl-gray-600: #666; +$gl-gray-700: #555; $green-50: #f1fdf6; $green-100: #dcf5e7; @@ -88,6 +97,18 @@ $red-800: #8b2615; $red-900: #711e11; $red-950: #4b140b; +$gray-50: #fafafa; +$gray-100: #f2f2f2; +$gray-200: #dfdfdf; +$gray-300: #ccc; +$gray-400: #bababa; +$gray-500: #a7a7a7; +$gray-600: #919191; +$gray-700: #707070; +$gray-800: #4f4f4f; +$gray-900: #2e2e2e; +$gray-950: #1f1f1f; + // GitLab themes $indigo-50: #f7f7ff; @@ -102,18 +123,6 @@ $indigo-800: #393982; $indigo-900: #292961; $indigo-950: #1a1a40; -$theme-gray-50: #fafafa; -$theme-gray-100: #f2f2f2; -$theme-gray-200: #dfdfdf; -$theme-gray-300: #cccccc; -$theme-gray-400: #bababa; -$theme-gray-500: #a7a7a7; -$theme-gray-600: #919191; -$theme-gray-700: #707070; -$theme-gray-800: #4f4f4f; -$theme-gray-900: #2e2e2e; -$theme-gray-950: #1f1f1f; - $theme-blue-50: #f4f8fc; $theme-blue-100: #e6edf5; $theme-blue-200: #c8d7e6; @@ -170,11 +179,6 @@ $theme-light-red-500: #c24b38; $theme-light-red-600: #b03927; $theme-light-red-700: #a62e21; -$black: #000; -$black-transparent: rgba(0, 0, 0, 0.3); -$shadow-color: rgba($black, 0.1); -$almost-black: #242424; - $border-white-light: darken($white-light, $darken-border-factor); $border-white-normal: darken($white-normal, $darken-border-factor); @@ -187,6 +191,7 @@ $border-gray-dark: darken($white-normal, $darken-border-factor); * UI elements */ $border-color: #e5e5e5; +$shadow-color: $t-gray-a-08; $well-expand-item: #e8f2f7; $well-inner-border: #eef0f2; $well-light-border: #f1f1f1; @@ -198,7 +203,6 @@ $well-light-text-color: #5b6169; $gl-font-size: 14px; $gl-font-size-xs: 11px; $gl-font-size-small: 12px; -$gl-font-size-medium: 20px; $gl-font-size-large: 16px; $gl-font-weight-normal: 400; $gl-font-weight-bold: 600; @@ -239,6 +243,7 @@ $gl-padding-8: 8px; $gl-padding: 16px; $gl-padding-24: 24px; $gl-padding-32: 32px; +$gl-padding-50: 50px; $gl-col-padding: 15px; $gl-input-padding: 10px; $gl-vert-padding: 6px; @@ -246,7 +251,7 @@ $gl-padding-top: 10px; $gl-sidebar-padding: 22px; $gl-bar-padding: 3px; $input-horizontal-padding: 12px; -$browserScrollbarSize: 10px; +$browser-scrollbar-size: 10px; /* * Misc @@ -260,6 +265,7 @@ $container-text-max-width: 540px; $gl-avatar-size: 40px; $border-radius-default: 4px; $border-radius-small: 2px; +$border-radius-large: 8px; $default-icon-size: 18px; $layout-link-gray: #7e7c7c; $btn-side-margin: 10px; @@ -271,11 +277,13 @@ $general-hover-transition-duration: 100ms; $general-hover-transition-curve: linear; $highlight-changes-color: rgb(235, 255, 232); $performance-bar-height: 35px; +$system-header-height: 35px; +$system-footer-height: $system-header-height; $flash-height: 52px; $context-header-height: 60px; $breadcrumb-min-height: 48px; -$project-title-row-height: 64px; -$project-avatar-mobile-size: 24px; +$home-panel-title-row-height: 64px; +$home-panel-avatar-mobile-size: 24px; $gl-line-height: 16px; $gl-line-height-24: 24px; $gl-line-height-14: 14px; @@ -315,8 +323,8 @@ $line-select-yellow: #fcf8e7; $line-select-yellow-dark: #f0e2bd; $dark-diff-match-bg: rgba(255, 255, 255, 0.3); $dark-diff-match-color: rgba(255, 255, 255, 0.1); -$diff-image-info-color: gray; -$diff-view-modes-color: gray; +$diff-image-info-color: #808080; +$diff-view-modes-color: #808080; $diff-view-modes-border: #c1c1c1; $diff-jagged-border-gradient-color: darken($white-normal, 8%); @@ -335,6 +343,7 @@ $regular-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-San $dropdown-width: 300px; $dropdown-min-height: 40px; $dropdown-max-height: 312px; +$dropdown-max-height-lg: 445px; $dropdown-vertical-offset: 4px; $dropdown-empty-row-bg: rgba(#000, 0.04); $dropdown-shadow-color: rgba(#000, 0.1); @@ -400,6 +409,8 @@ $status-icon-size: 22px; $award-emoji-menu-shadow: rgba(0, 0, 0, 0.175); $award-emoji-positive-add-bg: #fed159; $award-emoji-positive-add-lines: #bb9c13; +$award-emoji-width: 376px; +$award-emoji-width-xs: 300px; /* * Search Box @@ -486,6 +497,7 @@ $builds-trace-bg: #111; */ $commit-max-width-marker-color: rgba(0, 0, 0, 0); $commit-message-text-area-bg: rgba(0, 0, 0, 0); +$commit-stat-summary-height: 36px; /* * Common @@ -652,6 +664,7 @@ $border-color-settings: #e1e1e1; Modals */ $modal-body-height: 134px; +$modal-border-color: #e9ecef; $priority-label-empty-state-width: 114px; @@ -659,3 +672,14 @@ $priority-label-empty-state-width: 114px; Issues Analytics */ $issues-analytics-popover-boarder-color: rgba(0, 0, 0, 0.15); + +/* +Merge Requests +*/ +$mr-tabs-height: 51px; +$mr-version-controls-height: 56px; + +/* +Compare Branches +*/ +$compare-branches-sticky-header-height: 68px; diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index 069f45bff49..79d52932719 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -5,7 +5,7 @@ $secondary: $gray-light; $input-disabled-bg: $gray-light; -$input-border-color: $theme-gray-200; +$input-border-color: $gray-200; $input-color: $gl-text-color; $font-family-sans-serif: $regular-font; $font-family-monospace: $monospace-font; @@ -20,7 +20,7 @@ $warning: $orange-500; $danger: $red-500; $zindex-modal-backdrop: 1040; $nav-divider-margin-y: ($grid-size / 2); -$dropdown-divider-bg: $theme-gray-200; +$dropdown-divider-bg: $gray-200; $dropdown-item-padding-y: 8px; $dropdown-item-padding-x: 12px; $popover-max-width: 300px; @@ -34,3 +34,14 @@ $h3-font-size: 14px * 1.75; $h4-font-size: 14px * 1.5; $h5-font-size: 14px * 1.25; $h6-font-size: 14px; +$spacer: $grid-size; +$spacers: ( + 0: 0, + 1: ($spacer * 0.5), + 2: ($spacer), + 3: ($spacer * 2), + 4: ($spacer * 3), + 5: ($spacer * 4), + 6: ($spacer * 8) +); +$pagination-color: $gl-text-color; diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss new file mode 100644 index 00000000000..ac3214a07d9 --- /dev/null +++ b/app/assets/stylesheets/highlight/common.scss @@ -0,0 +1,18 @@ +@import '../framework/variables'; + +@mixin diff-background($background, $idiff, $border) { + background: $background; + + &.line_content span.idiff { + background: $idiff; + } + + &.diff-line-num { + border-color: $border; + } +} + +@mixin dark-diff-match-line { + color: $dark-diff-match-bg; + background: $dark-diff-match-color; +} diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss deleted file mode 100644 index 604f806dc58..00000000000 --- a/app/assets/stylesheets/highlight/dark.scss +++ /dev/null @@ -1,259 +0,0 @@ -/* https://github.com/MozMorris/tomorrow-pygments */ - -/* -* Dark syntax colors -*/ -$dark-new-bg: rgba(51, 255, 51, 0.1); -$dark-new-idiff: rgba(51, 255, 51, 0.2); -$dark-old-bg: rgba(255, 51, 51, 0.2); -$dark-old-idiff: rgba(255, 51, 51, 0.25); -$dark-border: #808080; -$dark-code-border: #666; -$dark-main-bg: #1d1f21; -$dark-main-color: #1d1f21; -$dark-line-color: #c5c8c6; -$dark-line-num-color: rgba(255, 255, 255, 0.3); -$dark-line-num-color-new: #627165; -$dark-line-num-color-old: #806565; -$dark-diff-not-empty-bg: #557; -$dark-highlight-bg: #ffe792; -$dark-highlight-color: $black; -$dark-pre-hll-bg: #373b41; -$dark-hll-bg: #373b41; -$dark-over-bg: #9f9ab5; -$dark-expanded-bg: #3e3e3e; -$dark-c: #969896; -$dark-err: #c66; -$dark-k: #b294bb; -$dark-l: #de935f; -$dark-n: #c5c8c6; -$dark-o: #8abeb7; -$dark-p: #c5c8c6; -$dark-cm: #969896; -$dark-cp: #969896; -$dark-c1: #969896; -$dark-cs: #969896; -$dark-gd: #c66; -$dark-gh: #c5c8c6; -$dark-gi: #b5bd68; -$dark-gp: #969896; -$dark-gu: #8abeb7; -$dark-kc: #b294bb; -$dark-kd: #b294bb; -$dark-kn: #8abeb7; -$dark-kp: #b294bb; -$dark-kr: #b294bb; -$dark-kt: #f0c674; -$dark-ld: #b5bd68; -$dark-m: #de935f; -$dark-s: #b5bd68; -$dark-na: #81a2be; -$dark-nb: #c5c8c6; -$dark-nc: #f0c674; -$dark-no: #c66; -$dark-nd: #8abeb7; -$dark-ni: #c5c8c6; -$dark-ne: #c66; -$dark-nf: #81a2be; -$dark-nl: #c5c8c6; -$dark-nn: #f0c674; -$dark-nx: #81a2be; -$dark-py: #c5c8c6; -$dark-nt: #8abeb7; -$dark-nv: #c66; -$dark-ow: #8abeb7; -$dark-w: #c5c8c6; -$dark-mf: #de935f; -$dark-mh: #de935f; -$dark-mi: #de935f; -$dark-mo: #de935f; -$dark-sb: #b5bd68; -$dark-sc: #c5c8c6; -$dark-sd: #969896; -$dark-s2: #b5bd68; -$dark-se: #de935f; -$dark-sh: #b5bd68; -$dark-si: #de935f; -$dark-sx: #b5bd68; -$dark-sr: #b5bd68; -$dark-s1: #b5bd68; -$dark-ss: #b5bd68; -$dark-bp: #c5c8c6; -$dark-vc: #c66; -$dark-vg: #c66; -$dark-vi: #c66; -$dark-il: #de935f; - -.code.dark { - // Line numbers - .line-numbers, - .diff-line-num { - background-color: $dark-main-bg; - } - - .diff-line-num, - .diff-line-num a { - color: $dark-line-num-color; - } - - // Code itself - pre.code, - .diff-line-num { - border-color: $dark-code-border; - } - - &, - pre.code, - .line_holder .line_content { - background-color: $dark-main-bg; - color: $dark-line-color; - } - - // Diff line - .line_holder { - &.match .line_content, - &.old-nonewline .line_content, - &.new-nonewline .line_content { - @include dark-diff-match-line; - } - - td.diff-line-num.hll:not(.empty-cell), - td.line_content.hll:not(.empty-cell) { - background-color: $dark-diff-not-empty-bg; - border-color: darken($dark-diff-not-empty-bg, 15%); - } - - .diff-line-num.new, - .line_content.new { - @include diff_background($dark-new-bg, $dark-new-idiff, $dark-border); - - &::before, - a { - color: $dark-line-num-color-new; - } - } - - .diff-line-num.old, - .line_content.old { - @include diff_background($dark-old-bg, $dark-old-idiff, $dark-border); - - &::before, - a { - color: $dark-line-num-color-old; - } - } - - .diff-line-num { - &.is-over, - &.hll:not(.empty-cell).is-over { - background-color: $dark-over-bg; - border-color: darken($dark-over-bg, 5%); - - a { - color: darken($dark-over-bg, 15%); - } - } - } - - .line_content.match { - @include dark-diff-match-line; - } - - &:not(.diff-expanded) + .diff-expanded, - &.diff-expanded + .line_holder:not(.diff-expanded) { - > .diff-line-num, - > .line_content { - border-top: 1px solid $black; - } - } - - &.diff-expanded { - > .diff-line-num, - > .line_content { - background: $dark-expanded-bg; - border-color: $dark-expanded-bg; - } - } - } - - // highlight line via anchor - pre .hll { - background-color: $dark-pre-hll-bg !important; - } - - // Search result highlight - span.highlight_word { - background-color: $dark-highlight-bg !important; - color: $dark-highlight-color !important; - } - - // Links to URLs, emails, or dependencies - .line a { - color: $dark-na; - } - - .hll { background-color: $dark-hll-bg; } - .c { color: $dark-c; } /* Comment */ - .err { color: $dark-err; } /* Error */ - .k { color: $dark-k; } /* Keyword */ - .l { color: $dark-l; } /* Literal */ - .n { color: $dark-n; } /* Name */ - .o { color: $dark-o; } /* Operator */ - .p { color: $dark-p; } /* Punctuation */ - .cm { color: $dark-cm; } /* Comment.Multiline */ - .cp { color: $dark-cp; } /* Comment.Preproc */ - .c1 { color: $dark-c1; } /* Comment.Single */ - .cs { color: $dark-cs; } /* Comment.Special */ - .gd { color: $dark-gd; } /* Generic.Deleted */ - .ge { font-style: italic; } /* Generic.Emph */ - .gh { color: $dark-gh; font-weight: $gl-font-weight-bold; } /* Generic.Heading */ - .gi { color: $dark-gi; } /* Generic.Inserted */ - .gp { color: $dark-gp; font-weight: $gl-font-weight-bold; } /* Generic.Prompt */ - .gs { font-weight: $gl-font-weight-bold; } /* Generic.Strong */ - .gu { color: $dark-gu; font-weight: $gl-font-weight-bold; } /* Generic.Subheading */ - .kc { color: $dark-kc; } /* Keyword.Constant */ - .kd { color: $dark-kd; } /* Keyword.Declaration */ - .kn { color: $dark-kn; } /* Keyword.Namespace */ - .kp { color: $dark-kp; } /* Keyword.Pseudo */ - .kr { color: $dark-kr; } /* Keyword.Reserved */ - .kt { color: $dark-kt; } /* Keyword.Type */ - .ld { color: $dark-ld; } /* Literal.Date */ - .m { color: $dark-m; } /* Literal.Number */ - .s { color: $dark-s; } /* Literal.String */ - .na { color: $dark-na; } /* Name.Attribute */ - .nb { color: $dark-nb; } /* Name.Builtin */ - .nc { color: $dark-nc; } /* Name.Class */ - .no { color: $dark-no; } /* Name.Constant */ - .nd { color: $dark-nd; } /* Name.Decorator */ - .ni { color: $dark-ni; } /* Name.Entity */ - .ne { color: $dark-ne; } /* Name.Exception */ - .nf { color: $dark-nf; } /* Name.Function */ - .nl { color: $dark-nl; } /* Name.Label */ - .nn { color: $dark-nn; } /* Name.Namespace */ - .nx { color: $dark-nx; } /* Name.Other */ - .py { color: $dark-py; } /* Name.Property */ - .nt { color: $dark-nt; } /* Name.Tag */ - .nv { color: $dark-nv; } /* Name.Variable */ - .ow { color: $dark-ow; } /* Operator.Word */ - .w { color: $dark-w; } /* Text.Whitespace */ - .mf { color: $dark-mf; } /* Literal.Number.Float */ - .mh { color: $dark-mh; } /* Literal.Number.Hex */ - .mi { color: $dark-mi; } /* Literal.Number.Integer */ - .mo { color: $dark-mo; } /* Literal.Number.Oct */ - .sb { color: $dark-sb; } /* Literal.String.Backtick */ - .sc { color: $dark-sc; } /* Literal.String.Char */ - .sd { color: $dark-sd; } /* Literal.String.Doc */ - .s2 { color: $dark-s2; } /* Literal.String.Double */ - .se { color: $dark-se; } /* Literal.String.Escape */ - .sh { color: $dark-sh; } /* Literal.String.Heredoc */ - .si { color: $dark-si; } /* Literal.String.Interpol */ - .sx { color: $dark-sx; } /* Literal.String.Other */ - .sr { color: $dark-sr; } /* Literal.String.Regex */ - .s1 { color: $dark-s1; } /* Literal.String.Single */ - .ss { color: $dark-ss; } /* Literal.String.Symbol */ - .bp { color: $dark-bp; } /* Name.Builtin.Pseudo */ - .vc { color: $dark-vc; } /* Name.Variable.Class */ - .vg { color: $dark-vg; } /* Name.Variable.Global */ - .vi { color: $dark-vi; } /* Name.Variable.Instance */ - .il { color: $dark-il; } /* Literal.Number.Integer.Long */ -} diff --git a/app/assets/stylesheets/highlight/embedded.scss b/app/assets/stylesheets/highlight/embedded.scss index 44c8a1d39ec..74364ee4ddb 100644 --- a/app/assets/stylesheets/highlight/embedded.scss +++ b/app/assets/stylesheets/highlight/embedded.scss @@ -1,3 +1,3 @@ .code { - @import "white_base"; + @import 'white_base'; } diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss deleted file mode 100644 index 8e2720511da..00000000000 --- a/app/assets/stylesheets/highlight/monokai.scss +++ /dev/null @@ -1,257 +0,0 @@ -/* https://github.com/richleland/pygments-css/blob/master/monokai.css */ - -/* -* Monokai Colors -*/ -$monokai-bg: #272822; -$monokai-border: #555; -$monokai-text-color: #f8f8f2; -$monokai-line-num-color: rgba(255, 255, 255, 0.3); -$monokai-line-num-color-new: #707565; -$monokai-line-num-color-old: #7e736f; -$monokai-line-empty-bg: #49483e; -$monokai-line-empty-border: darken($monokai-line-empty-bg, 15%); -$monokai-diff-border: #808080; -$monokai-highlight-bg: #ffe792; -$monokai-over-bg: #9f9ab5; -$monokai-expanded-bg: #3e3e3e; - -$monokai-new-bg: rgba(166, 226, 46, 0.1); -$monokai-new-idiff: rgba(166, 226, 46, 0.15); - -$monokai-old-bg: rgba(254, 147, 140, 0.15); -$monokai-old-idiff: rgba(254, 147, 140, 0.2); - -$monokai-hll: #49483e; -$monokai-c: #75715e; -$monokai-err-color: #960050; -$monokai-err-bg: #1e0010; -$monokai-k: #66d9ef; -$monokai-l: #ae81ff; -$monokai-n: #f8f8f2; -$monokai-o: #f92672; -$monokai-p: #f8f8f2; -$monokai-cm: #75715e; -$monokai-cp: #75715e; -$monokai-c1: #75715e; -$monokai-cs: #75715e; -$monokai-kc: #66d9ef; -$monokai-kd: #66d9ef; -$monokai-kn: #f92672; -$monokai-kp: #66d9ef; -$monokai-kr: #66d9ef; -$monokai-kt: #66d9ef; -$monokai-ld: #e6db74; -$monokai-m: #ae81ff; -$monokai-s: #e6db74; -$monokai-na: #a6e22e; -$monokai-nb: #f8f8f2; -$monokai-nc: #a6e22e; -$monokai-no: #66d9ef; -$monokai-nd: #a6e22e; -$monokai-ni: #f8f8f2; -$monokai-ne: #a6e22e; -$monokai-nf: #a6e22e; -$monokai-nl: #f8f8f2; -$monokai-nn: #f8f8f2; -$monokai-nx: #a6e22e; -$monokai-py: #f8f8f2; -$monokai-nt: #f92672; -$monokai-nv: #f8f8f2; -$monokai-ow: #f92672; -$monokai-w: #f8f8f2; -$monokai-mf: #ae81ff; -$monokai-mh: #ae81ff; -$monokai-mi: #ae81ff; -$monokai-mo: #ae81ff; -$monokai-sb: #e6db74; -$monokai-sc: #e6db74; -$monokai-sd: #e6db74; -$monokai-s2: #e6db74; -$monokai-se: #ae81ff; -$monokai-sh: #e6db74; -$monokai-si: #e6db74; -$monokai-sx: #e6db74; -$monokai-sr: #e6db74; -$monokai-s1: #e6db74; -$monokai-ss: #e6db74; -$monokai-bp: #f8f8f2; -$monokai-vc: #f8f8f2; -$monokai-vg: #f8f8f2; -$monokai-vi: #f8f8f2; -$monokai-il: #ae81ff; -$monokai-gu: #75715e; -$monokai-gd: #f92672; -$monokai-gi: #a6e22e; - -.code.monokai { - // Line numbers - .line-numbers, - .diff-line-num { - background-color: $monokai-bg; - } - - .diff-line-num, - .diff-line-num a { - color: $monokai-line-num-color; - } - - // Code itself - pre.code, - .diff-line-num { - border-color: $monokai-border; - } - - &, - pre.code, - .line_holder .line_content { - background-color: $monokai-bg; - color: $monokai-text-color; - } - - // Diff line - .line_holder { - &.match .line_content, - &.old-nonewline .line_content, - &.new-nonewline .line_content { - @include dark-diff-match-line; - } - - td.diff-line-num.hll:not(.empty-cell), - td.line_content.hll:not(.empty-cell) { - background-color: $monokai-line-empty-bg; - border-color: $monokai-line-empty-border; - } - - .diff-line-num.new, - .line_content.new { - @include diff_background($monokai-new-bg, $monokai-new-idiff, $monokai-diff-border); - - &::before, - a { - color: $monokai-line-num-color-new; - } - } - - .diff-line-num.old, - .line_content.old { - @include diff_background($monokai-old-bg, $monokai-old-idiff, $monokai-diff-border); - - &::before, - a { - color: $monokai-line-num-color-old; - } - } - - .diff-line-num { - &.is-over, - &.hll:not(.empty-cell).is-over { - background-color: $monokai-over-bg; - border-color: darken($monokai-over-bg, 5%); - - a { - color: darken($monokai-over-bg, 15%); - } - } - } - - .line_content.match { - @include dark-diff-match-line; - } - - &:not(.diff-expanded) + .diff-expanded, - &.diff-expanded + .line_holder:not(.diff-expanded) { - > .diff-line-num, - > .line_content { - border-top: 1px solid $black; - } - } - - &.diff-expanded { - > .diff-line-num, - > .line_content { - background: $monokai-expanded-bg; - border-color: $monokai-expanded-bg; - } - } - } - - // highlight line via anchor - pre .hll { - background-color: $monokai-hll !important; - } - - // Search result highlight - span.highlight_word { - background-color: $monokai-highlight-bg !important; - color: $black !important; - } - - // Links to URLs, emails, or dependencies - .line a { - color: $monokai-k; - } - - .hll { background-color: $monokai-hll; } - .c { color: $monokai-c; } /* Comment */ - .err { color: $monokai-err-color; background-color: $monokai-err-bg; } /* Error */ - .k { color: $monokai-k; } /* Keyword */ - .l { color: $monokai-l; } /* Literal */ - .n { color: $monokai-n; } /* Name */ - .o { color: $monokai-o; } /* Operator */ - .p { color: $monokai-p; } /* Punctuation */ - .cm { color: $monokai-cm; } /* Comment.Multiline */ - .cp { color: $monokai-cp; } /* Comment.Preproc */ - .c1 { color: $monokai-c1; } /* Comment.Single */ - .cs { color: $monokai-cs; } /* Comment.Special */ - .ge { font-style: italic; } /* Generic.Emph */ - .gs { font-weight: $gl-font-weight-bold; } /* Generic.Strong */ - .kc { color: $monokai-kc; } /* Keyword.Constant */ - .kd { color: $monokai-kd; } /* Keyword.Declaration */ - .kn { color: $monokai-kn; } /* Keyword.Namespace */ - .kp { color: $monokai-kp; } /* Keyword.Pseudo */ - .kr { color: $monokai-kr; } /* Keyword.Reserved */ - .kt { color: $monokai-kt; } /* Keyword.Type */ - .ld { color: $monokai-ld; } /* Literal.Date */ - .m { color: $monokai-m; } /* Literal.Number */ - .s { color: $monokai-s; } /* Literal.String */ - .na { color: $monokai-na; } /* Name.Attribute */ - .nb { color: $monokai-nb; } /* Name.Builtin */ - .nc { color: $monokai-nc; } /* Name.Class */ - .no { color: $monokai-no; } /* Name.Constant */ - .nd { color: $monokai-nd; } /* Name.Decorator */ - .ni { color: $monokai-ni; } /* Name.Entity */ - .ne { color: $monokai-ne; } /* Name.Exception */ - .nf { color: $monokai-nf; } /* Name.Function */ - .nl { color: $monokai-nl; } /* Name.Label */ - .nn { color: $monokai-nn; } /* Name.Namespace */ - .nx { color: $monokai-nx; } /* Name.Other */ - .py { color: $monokai-py; } /* Name.Property */ - .nt { color: $monokai-nt; } /* Name.Tag */ - .nv { color: $monokai-nv; } /* Name.Variable */ - .ow { color: $monokai-ow; } /* Operator.Word */ - .w { color: $monokai-w; } /* Text.Whitespace */ - .mf { color: $monokai-mf; } /* Literal.Number.Float */ - .mh { color: $monokai-mh; } /* Literal.Number.Hex */ - .mi { color: $monokai-mi; } /* Literal.Number.Integer */ - .mo { color: $monokai-mo; } /* Literal.Number.Oct */ - .sb { color: $monokai-sb; } /* Literal.String.Backtick */ - .sc { color: $monokai-sc; } /* Literal.String.Char */ - .sd { color: $monokai-sd; } /* Literal.String.Doc */ - .s2 { color: $monokai-s2; } /* Literal.String.Double */ - .se { color: $monokai-se; } /* Literal.String.Escape */ - .sh { color: $monokai-sh; } /* Literal.String.Heredoc */ - .si { color: $monokai-si; } /* Literal.String.Interpol */ - .sx { color: $monokai-sx; } /* Literal.String.Other */ - .sr { color: $monokai-sr; } /* Literal.String.Regex */ - .s1 { color: $monokai-s1; } /* Literal.String.Single */ - .ss { color: $monokai-ss; } /* Literal.String.Symbol */ - .bp { color: $monokai-bp; } /* Name.Builtin.Pseudo */ - .vc { color: $monokai-vc; } /* Name.Variable.Class */ - .vg { color: $monokai-vg; } /* Name.Variable.Global */ - .vi { color: $monokai-vi; } /* Name.Variable.Instance */ - .il { color: $monokai-il; } /* Literal.Number.Integer.Long */ - .gu { color: $monokai-gu; } /* Generic.Subheading & Diff Unified/Comment? */ - .gd { color: $monokai-gd; } /* Generic.Deleted & Diff Deleted */ - .gi { color: $monokai-gi; } /* Generic.Inserted & Diff Inserted */ -} diff --git a/app/assets/stylesheets/highlight/none.scss b/app/assets/stylesheets/highlight/none.scss deleted file mode 100644 index 7d692a87e33..00000000000 --- a/app/assets/stylesheets/highlight/none.scss +++ /dev/null @@ -1,242 +0,0 @@ -/* -* None Syntax Colors -*/ - - - -@mixin matchLine { - color: $black-transparent; - background-color: $white-normal; -} - -.code.none { - // Line numbers - .line-numbers, - .diff-line-num { - background-color: $gray-light; - } - - .diff-line-num, - .diff-line-num a { - color: $black-transparent; - } - - // Code itself - pre.code, - .diff-line-num { - border-color: $white-normal; - } - - &, - pre.code, - .line_holder .line_content { - background-color: $white-light; - color: $gl-text-color; - } - -// Diff line - - $none-over-bg: #ded7fc; - $none-expanded-border: #e0e0e0; - $none-expanded-bg: #f7f7f7; - - .line_holder { - - &.match .line_content, - .new-nonewline.line_content, - .old-nonewline.line_content { - @include matchLine; - } - - .diff-line-num { - &.old { - background-color: $line-number-old; - border-color: $line-removed-dark; - - a { - color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%); - } - } - - &.new { - background-color: $line-number-new; - border-color: $line-added-dark; - - a { - color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%); - } - } - - &.is-over, - &.hll:not(.empty-cell).is-over { - background-color: $none-over-bg; - border-color: darken($none-over-bg, 5%); - - a { - color: darken($none-over-bg, 15%); - } - } - - &.hll:not(.empty-cell) { - background-color: $line-number-select; - border-color: $line-select-yellow-dark; - } - } - - &:not(.diff-expanded) + .diff-expanded, - &.diff-expanded + .line_holder:not(.diff-expanded) { - > .diff-line-num, - > .line_content { - border-top: 1px solid $none-expanded-border; - } - } - - &.diff-expanded { - > .diff-line-num, - > .line_content { - background: $none-expanded-bg; - border-color: $none-expanded-bg; - } - } - - .line_content { - &.old { - background-color: $line-removed; - - &::before { - color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%); - } - - span.idiff { - background-color: $line-removed-dark; - } - } - - &.new { - background-color: $line-added; - - &::before { - color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%); - } - - span.idiff { - background-color: $line-added-dark; - } - } - - &.match { - @include matchLine; - } - - &.hll:not(.empty-cell) { - background-color: $line-select-yellow; - } - } - } - - // highlight line via anchor - pre .hll { - background-color: $white-normal; - } - - // Search result highlight - span.highlight_word { - background-color: $white-normal; - } - - // Links to URLs, emails, or dependencies - .line a { - color: $gl-text-color; - text-decoration: underline; - } - - .hll { background-color: $white-light; } - - .gd { - color: $gl-text-color; - background-color: $white-light; - - .x { - color: $gl-text-color; - background-color: $white-light; - } - } - - .gi { - color: $gl-text-color; - background-color: $white-light; - - .x { - color: $gl-text-color; - background-color: $white-light; - } - } - - .c { color: $gl-text-color; } /* Comment */ - .err { color: $gl-text-color; } /* Error */ - .g { color: $gl-text-color; } /* Generic */ - .k { color: $gl-text-color; } /* Keyword */ - .l { color: $gl-text-color; } /* Literal */ - .n { color: $gl-text-color; } /* Name */ - .o { color: $gl-text-color; } /* Operator */ - .x { color: $gl-text-color; } /* Other */ - .p { color: $gl-text-color; } /* Punctuation */ - .cm { color: $gl-text-color; } /* Comment.Multiline */ - .cp { color: $gl-text-color; } /* Comment.Preproc */ - .c1 { color: $gl-text-color; } /* Comment.Single */ - .cs { color: $gl-text-color; } /* Comment.Special */ - .ge { color: $gl-text-color; } /* Generic.Emph */ - .gr { color: $gl-text-color; } /* Generic.Error */ - .gh { color: $gl-text-color; } /* Generic.Heading */ - .go { color: $gl-text-color; } /* Generic.Output */ - .gp { color: $gl-text-color; } /* Generic.Prompt */ - .gs { color: $gl-text-color; } /* Generic.Strong */ - .gu { color: $gl-text-color; } /* Generic.Subheading */ - .gt { color: $gl-text-color; } /* Generic.Traceback */ - .kc { color: $gl-text-color; } /* Keyword.Constant */ - .kd { color: $gl-text-color; } /* Keyword.Declaration */ - .kn { color: $gl-text-color; } /* Keyword.Namespace */ - .kp { color: $gl-text-color; } /* Keyword.Pseudo */ - .kr { color: $gl-text-color; } /* Keyword.Reserved */ - .kt { color: $gl-text-color; } /* Keyword.Type */ - .ld { color: $gl-text-color; } /* Literal.Date */ - .m { color: $gl-text-color; } /* Literal.Number */ - .s { color: $gl-text-color; } /* Literal.String */ - .na { color: $gl-text-color; } /* Name.Attribute */ - .nb { color: $gl-text-color; } /* Name.Builtin */ - .nc { color: $gl-text-color; } /* Name.Class */ - .no { color: $gl-text-color; } /* Name.Constant */ - .nd { color: $gl-text-color; } /* Name.Decorator */ - .ni { color: $gl-text-color; } /* Name.Entity */ - .ne { color: $gl-text-color; } /* Name.Exception */ - .nf { color: $gl-text-color; } /* Name.Function */ - .nl { color: $gl-text-color; } /* Name.Label */ - .nn { color: $gl-text-color; } /* Name.Namespace */ - .nx { color: $gl-text-color; } /* Name.Other */ - .py { color: $gl-text-color; } /* Name.Property */ - .nt { color: $gl-text-color; } /* Name.Tag */ - .nv { color: $gl-text-color; } /* Name.Variable */ - .ow { color: $gl-text-color; } /* Operator.Word */ - .w { color: $gl-text-color; } /* Text.Whitespace */ - .mf { color: $gl-text-color; } /* Literal.Number.Float */ - .mh { color: $gl-text-color; } /* Literal.Number.Hex */ - .mi { color: $gl-text-color; } /* Literal.Number.Integer */ - .mo { color: $gl-text-color; } /* Literal.Number.Oct */ - .sb { color: $gl-text-color; } /* Literal.String.Backtick */ - .sc { color: $gl-text-color; } /* Literal.String.Char */ - .sd { color: $gl-text-color; } /* Literal.String.Doc */ - .s2 { color: $gl-text-color; } /* Literal.String.Double */ - .se { color: $gl-text-color; } /* Literal.String.Escape */ - .sh { color: $gl-text-color; } /* Literal.String.Heredoc */ - .si { color: $gl-text-color; } /* Literal.String.Interpol */ - .sx { color: $gl-text-color; } /* Literal.String.Other */ - .sr { color: $gl-text-color; } /* Literal.String.Regex */ - .s1 { color: $gl-text-color; } /* Literal.String.Single */ - .ss { color: $gl-text-color; } /* Literal.String.Symbol */ - .bp { color: $gl-text-color; } /* Name.Builtin.Pseudo */ - .vc { color: $gl-text-color; } /* Name.Variable.Class */ - .vg { color: $gl-text-color; } /* Name.Variable.Global */ - .vi { color: $gl-text-color; } /* Name.Variable.Instance */ - .il { color: $gl-text-color; } /* Literal.Number.Integer.Long */ - -} diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss deleted file mode 100644 index cd1f0f6650f..00000000000 --- a/app/assets/stylesheets/highlight/solarized_dark.scss +++ /dev/null @@ -1,284 +0,0 @@ -/* https://gist.github.com/qguv/7936275 */ - -/* -* Solarized dark colors -*/ -$solarized-dark-new-bg: rgba(133, 153, 0, 0.15); -$solarized-dark-new-idiff: rgba(133, 153, 0, 0.25); -$solarized-dark-old-bg: rgba(220, 50, 47, 0.3); -$solarized-dark-old-idiff: rgba(220, 50, 47, 0.25); -$solarized-dark-border: #113b46; -$solarized-dark-pre-bg: #002b36; -$solarized-dark-pre-color: #93a1a1; -$solarized-dark-pre-border: #113b46; -$solarized-dark-line-bg: #002b36; -$solarized-dark-line-color: rgba(255, 255, 255, 0.3); -$solarized-dark-line-color-new: #5a766c; -$solarized-dark-line-color-old: #7a6c71; -$solarized-dark-highlight: #094554; -$solarized-dark-hll-bg: #174652; -$solarized-dark-over-bg: #9f9ab5; -$solarized-dark-expanded-bg: #010d10; -$solarized-dark-c: #586e75; -$solarized-dark-err: #93a1a1; -$solarized-dark-g: #93a1a1; -$solarized-dark-k: #859900; -$solarized-dark-l: #93a1a1; -$solarized-dark-n: #93a1a1; -$solarized-dark-o: #859900; -$solarized-dark-x: #cb4b16; -$solarized-dark-p: #93a1a1; -$solarized-dark-cm: #586e75; -$solarized-dark-cp: #859900; -$solarized-dark-c1: #586e75; -$solarized-dark-cs: #859900; -$solarized-dark-gd: #2aa198; -$solarized-dark-ge: #93a1a1; -$solarized-dark-gr: #dc322f; -$solarized-dark-gh: #cb4b16; -$solarized-dark-gi: #859900; -$solarized-dark-go: #93a1a1; -$solarized-dark-gp: #93a1a1; -$solarized-dark-gs: #93a1a1; -$solarized-dark-gu: #cb4b16; -$solarized-dark-gt: #93a1a1; -$solarized-dark-kc: #cb4b16; -$solarized-dark-kd: #268bd2; -$solarized-dark-kn: #859900; -$solarized-dark-kp: #859900; -$solarized-dark-kr: #268bd2; -$solarized-dark-kt: #dc322f; -$solarized-dark-ld: #93a1a1; -$solarized-dark-m: #2aa198; -$solarized-dark-s: #2aa198; -$solarized-dark-na: #93a1a1; -$solarized-dark-nb: #b58900; -$solarized-dark-nc: #268bd2; -$solarized-dark-no: #cb4b16; -$solarized-dark-nd: #268bd2; -$solarized-dark-ni: #cb4b16; -$solarized-dark-ne: #cb4b16; -$solarized-dark-nf: #268bd2; -$solarized-dark-nl: #93a1a1; -$solarized-dark-nn: #93a1a1; -$solarized-dark-nx: #93a1a1; -$solarized-dark-py: #93a1a1; -$solarized-dark-nt: #268bd2; -$solarized-dark-nv: #268bd2; -$solarized-dark-ow: #859900; -$solarized-dark-w: #93a1a1; -$solarized-dark-mf: #2aa198; -$solarized-dark-mh: #2aa198; -$solarized-dark-mi: #2aa198; -$solarized-dark-mo: #2aa198; -$solarized-dark-sb: #586e75; -$solarized-dark-sc: #2aa198; -$solarized-dark-sd: #93a1a1; -$solarized-dark-s2: #2aa198; -$solarized-dark-se: #cb4b16; -$solarized-dark-sh: #93a1a1; -$solarized-dark-si: #2aa198; -$solarized-dark-sx: #2aa198; -$solarized-dark-sr: #dc322f; -$solarized-dark-s1: #2aa198; -$solarized-dark-ss: #2aa198; -$solarized-dark-bp: #268bd2; -$solarized-dark-vc: #268bd2; -$solarized-dark-vg: #268bd2; -$solarized-dark-vi: #268bd2; -$solarized-dark-il: #2aa198; - -.code.solarized-dark { - // Line numbers - .line-numbers, - .diff-line-num { - background-color: $solarized-dark-line-bg; - } - - .diff-line-num, - .diff-line-num a { - color: $solarized-dark-line-color; - } - - // Code itself - pre.code, - .diff-line-num { - border-color: $solarized-dark-pre-border; - } - - &, - pre.code, - .line_holder .line_content { - background-color: $solarized-dark-pre-bg; - color: $solarized-dark-pre-color; - } - - // Diff line - .line_holder { - &.match .line_content, - &.old-nonewline .line_content, - &.new-nonewline .line_content { - @include dark-diff-match-line; - } - - td.diff-line-num.hll:not(.empty-cell), - td.line_content.hll:not(.empty-cell) { - background-color: $solarized-dark-hll-bg; - border-color: darken($solarized-dark-hll-bg, 15%); - } - - .diff-line-num.new, - .line_content.new { - @include diff_background($solarized-dark-new-bg, $solarized-dark-new-idiff, $solarized-dark-border); - - &::before, - a { - color: $solarized-dark-line-color-new; - } - } - - .diff-line-num.old, - .line_content.old { - @include diff_background($solarized-dark-old-bg, $solarized-dark-old-idiff, $solarized-dark-border); - - &::before, - a { - color: $solarized-dark-line-color-old; - } - } - - .diff-line-num { - &.is-over, - &.hll:not(.empty-cell).is-over { - background-color: $solarized-dark-over-bg; - border-color: darken($solarized-dark-over-bg, 5%); - - a { - color: darken($solarized-dark-over-bg, 15%); - } - } - } - - .line_content.match { - @include dark-diff-match-line; - } - - &:not(.diff-expanded) + .diff-expanded, - &.diff-expanded + .line_holder:not(.diff-expanded) { - > .diff-line-num, - > .line_content { - border-top: 1px solid $black; - } - } - - &.diff-expanded { - > .diff-line-num, - > .line_content { - background: $solarized-dark-expanded-bg; - border-color: $solarized-dark-expanded-bg; - } - } - } - - // highlight line via anchor - pre .hll { - background-color: $solarized-dark-hll-bg !important; - } - - // Search result highlight - span.highlight_word { - background-color: $solarized-dark-highlight !important; - } - - // Links to URLs, emails, or dependencies - .line a { - color: $solarized-dark-kd; - } - - /* Solarized Dark - - For use with Jekyll and Pygments - - http://ethanschoonover.com/solarized - - SOLARIZED HEX ROLE - --------- -------- ------------------------------------------ - base03 #002b36 background - base01 #586e75 comments / secondary content - base1 #93a1a1 body text / default code / primary content - orange #cb4b16 constants - red #dc322f regex, special keywords - blue #268bd2 reserved keywords - cyan #2aa198 strings, numbers - green #859900 operators, other keywords - */ - - .c { color: $solarized-dark-c; } /* Comment */ - .err { color: $solarized-dark-err; } /* Error */ - .g { color: $solarized-dark-g; } /* Generic */ - .k { color: $solarized-dark-k; } /* Keyword */ - .l { color: $solarized-dark-l; } /* Literal */ - .n { color: $solarized-dark-n; } /* Name */ - .o { color: $solarized-dark-o; } /* Operator */ - .x { color: $solarized-dark-x; } /* Other */ - .p { color: $solarized-dark-p; } /* Punctuation */ - .cm { color: $solarized-dark-cm; } /* Comment.Multiline */ - .cp { color: $solarized-dark-cp; } /* Comment.Preproc */ - .c1 { color: $solarized-dark-c1; } /* Comment.Single */ - .cs { color: $solarized-dark-cs; } /* Comment.Special */ - .gd { color: $solarized-dark-gd; } /* Generic.Deleted */ - .ge { color: $solarized-dark-ge; font-style: italic; } /* Generic.Emph */ - .gr { color: $solarized-dark-gr; } /* Generic.Error */ - .gh { color: $solarized-dark-gh; } /* Generic.Heading */ - .gi { color: $solarized-dark-gi; } /* Generic.Inserted */ - .go { color: $solarized-dark-go; } /* Generic.Output */ - .gp { color: $solarized-dark-gp; } /* Generic.Prompt */ - .gs { color: $solarized-dark-gs; font-weight: $gl-font-weight-bold; } /* Generic.Strong */ - .gu { color: $solarized-dark-gu; } /* Generic.Subheading */ - .gt { color: $solarized-dark-gt; } /* Generic.Traceback */ - .kc { color: $solarized-dark-kc; } /* Keyword.Constant */ - .kd { color: $solarized-dark-kd; } /* Keyword.Declaration */ - .kn { color: $solarized-dark-kn; } /* Keyword.Namespace */ - .kp { color: $solarized-dark-kp; } /* Keyword.Pseudo */ - .kr { color: $solarized-dark-kr; } /* Keyword.Reserved */ - .kt { color: $solarized-dark-kt; } /* Keyword.Type */ - .ld { color: $solarized-dark-ld; } /* Literal.Date */ - .m { color: $solarized-dark-m; } /* Literal.Number */ - .s { color: $solarized-dark-s; } /* Literal.String */ - .na { color: $solarized-dark-na; } /* Name.Attribute */ - .nb { color: $solarized-dark-nb; } /* Name.Builtin */ - .nc { color: $solarized-dark-nc; } /* Name.Class */ - .no { color: $solarized-dark-no; } /* Name.Constant */ - .nd { color: $solarized-dark-nd; } /* Name.Decorator */ - .ni { color: $solarized-dark-ni; } /* Name.Entity */ - .ne { color: $solarized-dark-ne; } /* Name.Exception */ - .nf { color: $solarized-dark-nf; } /* Name.Function */ - .nl { color: $solarized-dark-nl; } /* Name.Label */ - .nn { color: $solarized-dark-nn; } /* Name.Namespace */ - .nx { color: $solarized-dark-nx; } /* Name.Other */ - .py { color: $solarized-dark-py; } /* Name.Property */ - .nt { color: $solarized-dark-nt; } /* Name.Tag */ - .nv { color: $solarized-dark-nv; } /* Name.Variable */ - .ow { color: $solarized-dark-ow; } /* Operator.Word */ - .w { color: $solarized-dark-w; } /* Text.Whitespace */ - .mf { color: $solarized-dark-mf; } /* Literal.Number.Float */ - .mh { color: $solarized-dark-mh; } /* Literal.Number.Hex */ - .mi { color: $solarized-dark-mi; } /* Literal.Number.Integer */ - .mo { color: $solarized-dark-mo; } /* Literal.Number.Oct */ - .sb { color: $solarized-dark-sb; } /* Literal.String.Backtick */ - .sc { color: $solarized-dark-sc; } /* Literal.String.Char */ - .sd { color: $solarized-dark-sd; } /* Literal.String.Doc */ - .s2 { color: $solarized-dark-s2; } /* Literal.String.Double */ - .se { color: $solarized-dark-se; } /* Literal.String.Escape */ - .sh { color: $solarized-dark-sh; } /* Literal.String.Heredoc */ - .si { color: $solarized-dark-si; } /* Literal.String.Interpol */ - .sx { color: $solarized-dark-sx; } /* Literal.String.Other */ - .sr { color: $solarized-dark-sr; } /* Literal.String.Regex */ - .s1 { color: $solarized-dark-s1; } /* Literal.String.Single */ - .ss { color: $solarized-dark-ss; } /* Literal.String.Symbol */ - .bp { color: $solarized-dark-bp; } /* Name.Builtin.Pseudo */ - .vc { color: $solarized-dark-vc; } /* Name.Variable.Class */ - .vg { color: $solarized-dark-vg; } /* Name.Variable.Global */ - .vi { color: $solarized-dark-vi; } /* Name.Variable.Instance */ - .il { color: $solarized-dark-il; } /* Literal.Number.Integer.Long */ -} diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss deleted file mode 100644 index 09c3ea36414..00000000000 --- a/app/assets/stylesheets/highlight/solarized_light.scss +++ /dev/null @@ -1,292 +0,0 @@ -/* https://gist.github.com/qguv/7936275 */ - -/* -* Solarized light syntax colors -*/ -$solarized-light-matchline-bg: rgba(255, 255, 255, 0.4); -$solarized-light-new-bg: rgba(133, 153, 0, 0.2); -$solarized-light-new-idiff: rgba(133, 153, 0, 0.25); -$solarized-light-old-bg: rgba(220, 50, 47, 0.2); -$solarized-light-old-idiff: rgba(220, 50, 47, 0.25); -$solarized-light-border: #c5d0d4; -$solarized-light-pre-bg: #002b36; -$solarized-light-pre-bg: #fdf6e3; -$solarized-light-pre-color: #586e75; -$solarized-light-line-bg: #fdf6e3; -$solarized-light-line-color: rgba(0, 0, 0, 0.3); -$solarized-light-line-color-new: #a1a080; -$solarized-light-line-color-old: #ad9186; -$solarized-light-highlight: #eee8d5; -$solarized-light-hll-bg: #ddd8c5; -$solarized-light-over-bg: #ded7fc; -$solarized-light-expanded-border: #d2cdbd; -$solarized-light-expanded-bg: #ece6d4; -$solarized-light-c: #93a1a1; -$solarized-light-err: #586e75; -$solarized-light-g: #586e75; -$solarized-light-k: #859900; -$solarized-light-l: #586e75; -$solarized-light-n: #586e75; -$solarized-light-o: #859900; -$solarized-light-x: #cb4b16; -$solarized-light-p: #586e75; -$solarized-light-cm: #93a1a1; -$solarized-light-cp: #859900; -$solarized-light-c1: #93a1a1; -$solarized-light-cs: #859900; -$solarized-light-gd: #2aa198; -$solarized-light-ge: #586e75; -$solarized-light-gr: #dc322f; -$solarized-light-gh: #cb4b16; -$solarized-light-gi: #859900; -$solarized-light-go: #586e75; -$solarized-light-gp: #586e75; -$solarized-light-gs: #586e75; -$solarized-light-gu: #cb4b16; -$solarized-light-gt: #586e75; -$solarized-light-kc: #cb4b16; -$solarized-light-kd: #268bd2; -$solarized-light-kn: #859900; -$solarized-light-kp: #859900; -$solarized-light-kr: #268bd2; -$solarized-light-kt: #dc322f; -$solarized-light-ld: #586e75; -$solarized-light-m: #2aa198; -$solarized-light-s: #2aa198; -$solarized-light-na: #586e75; -$solarized-light-nb: #b58900; -$solarized-light-nc: #268bd2; -$solarized-light-no: #cb4b16; -$solarized-light-nd: #268bd2; -$solarized-light-ni: #cb4b16; -$solarized-light-ne: #cb4b16; -$solarized-light-nf: #268bd2; -$solarized-light-nl: #586e75; -$solarized-light-nn: #586e75; -$solarized-light-nx: #586e75; -$solarized-light-py: #586e75; -$solarized-light-nt: #268bd2; -$solarized-light-nv: #268bd2; -$solarized-light-ow: #859900; -$solarized-light-w: #586e75; -$solarized-light-mf: #2aa198; -$solarized-light-mh: #2aa198; -$solarized-light-mi: #2aa198; -$solarized-light-mo: #2aa198; -$solarized-light-sb: #93a1a1; -$solarized-light-sc: #2aa198; -$solarized-light-sd: #586e75; -$solarized-light-s2: #2aa198; -$solarized-light-se: #cb4b16; -$solarized-light-sh: #586e75; -$solarized-light-si: #2aa198; -$solarized-light-sx: #2aa198; -$solarized-light-sr: #dc322f; -$solarized-light-s1: #2aa198; -$solarized-light-ss: #2aa198; -$solarized-light-bp: #268bd2; -$solarized-light-vc: #268bd2; -$solarized-light-vg: #268bd2; -$solarized-light-vi: #268bd2; -$solarized-light-il: #2aa198; - -@mixin matchLine { - color: $black-transparent; - background: $solarized-light-matchline-bg; -} - -.code.solarized-light { - // Line numbers - .line-numbers, - .diff-line-num { - background-color: $solarized-light-line-bg; - } - - .diff-line-num, - .diff-line-num a { - color: $solarized-light-line-color; - } - - // Code itself - pre.code, - .diff-line-num { - border-color: $solarized-light-border; - } - - &, - pre.code, - .line_holder .line_content { - background-color: $solarized-light-pre-bg; - color: $solarized-light-pre-color; - } - - // Diff line - .line_holder { - &.match .line_content, - &.old-nonewline .line_content, - &.new-nonewline .line_content { - @include matchLine; - } - - td.diff-line-num.hll:not(.empty-cell), - td.line_content.hll:not(.empty-cell) { - background-color: $solarized-light-hll-bg; - border-color: darken($solarized-light-hll-bg, 15%); - } - - .diff-line-num.new, - .line_content.new { - @include diff_background($solarized-light-new-bg, - $solarized-light-new-idiff, $solarized-light-border); - - &::before, - a { - color: $solarized-light-line-color-new; - } - } - - .diff-line-num.old, - .line_content.old { - @include diff_background($solarized-light-old-bg, $solarized-light-old-idiff, $solarized-light-border); - - &::before, - a { - color: $solarized-light-line-color-old; - } - } - - .diff-line-num { - &.is-over, - &.hll:not(.empty-cell).is-over { - background-color: $solarized-light-over-bg; - border-color: darken($solarized-light-over-bg, 5%); - - a { - color: darken($solarized-light-over-bg, 15%); - } - } - } - - .line_content.match { - @include matchLine; - } - - &:not(.diff-expanded) + .diff-expanded, - &.diff-expanded + .line_holder:not(.diff-expanded) { - > .diff-line-num, - > .line_content { - border-top: 1px solid $solarized-light-expanded-border; - } - } - - &.diff-expanded { - > .diff-line-num, - > .line_content { - background: $solarized-light-expanded-bg; - border-color: $solarized-light-expanded-bg; - } - } - } - - // highlight line via anchor - pre .hll { - background-color: $solarized-light-hll-bg !important; - } - - // Search result highlight - span.highlight_word { - background-color: $solarized-light-highlight !important; - } - - // Links to URLs, emails, or dependencies - .line a { - color: $solarized-light-kd; - } - - /* Solarized Light - - For use with Jekyll and Pygments - - http://ethanschoonover.com/solarized - - SOLARIZED HEX ROLE - --------- -------- ------------------------------------------ - base01 #586e75 body text / default code / primary content - base1 #93a1a1 comments / secondary content - base3 #fdf6e3 background - orange #cb4b16 constants - red #dc322f regex, special keywords - blue #268bd2 reserved keywords - cyan #2aa198 strings, numbers - green #859900 operators, other keywords - */ - - .c { color: $solarized-light-c; } /* Comment */ - .err { color: $solarized-light-err; } /* Error */ - .g { color: $solarized-light-g; } /* Generic */ - .k { color: $solarized-light-k; } /* Keyword */ - .l { color: $solarized-light-l; } /* Literal */ - .n { color: $solarized-light-n; } /* Name */ - .o { color: $solarized-light-o; } /* Operator */ - .x { color: $solarized-light-x; } /* Other */ - .p { color: $solarized-light-p; } /* Punctuation */ - .cm { color: $solarized-light-cm; } /* Comment.Multiline */ - .cp { color: $solarized-light-cp; } /* Comment.Preproc */ - .c1 { color: $solarized-light-c1; } /* Comment.Single */ - .cs { color: $solarized-light-cs; } /* Comment.Special */ - .gd { color: $solarized-light-gd; } /* Generic.Deleted */ - .ge { color: $solarized-light-ge; font-style: italic; } /* Generic.Emph */ - .gr { color: $solarized-light-gr; } /* Generic.Error */ - .gh { color: $solarized-light-gh; } /* Generic.Heading */ - .gi { color: $solarized-light-gi; } /* Generic.Inserted */ - .go { color: $solarized-light-go; } /* Generic.Output */ - .gp { color: $solarized-light-gp; } /* Generic.Prompt */ - .gs { color: $solarized-light-gs; font-weight: $gl-font-weight-bold; } /* Generic.Strong */ - .gu { color: $solarized-light-gu; } /* Generic.Subheading */ - .gt { color: $solarized-light-gt; } /* Generic.Traceback */ - .kc { color: $solarized-light-kc; } /* Keyword.Constant */ - .kd { color: $solarized-light-kd; } /* Keyword.Declaration */ - .kn { color: $solarized-light-kn; } /* Keyword.Namespace */ - .kp { color: $solarized-light-kp; } /* Keyword.Pseudo */ - .kr { color: $solarized-light-kr; } /* Keyword.Reserved */ - .kt { color: $solarized-light-kt; } /* Keyword.Type */ - .ld { color: $solarized-light-ld; } /* Literal.Date */ - .m { color: $solarized-light-m; } /* Literal.Number */ - .s { color: $solarized-light-s; } /* Literal.String */ - .na { color: $solarized-light-na; } /* Name.Attribute */ - .nb { color: $solarized-light-nb; } /* Name.Builtin */ - .nc { color: $solarized-light-nc; } /* Name.Class */ - .no { color: $solarized-light-no; } /* Name.Constant */ - .nd { color: $solarized-light-nd; } /* Name.Decorator */ - .ni { color: $solarized-light-ni; } /* Name.Entity */ - .ne { color: $solarized-light-ne; } /* Name.Exception */ - .nf { color: $solarized-light-nf; } /* Name.Function */ - .nl { color: $solarized-light-nl; } /* Name.Label */ - .nn { color: $solarized-light-nn; } /* Name.Namespace */ - .nx { color: $solarized-light-nx; } /* Name.Other */ - .py { color: $solarized-light-py; } /* Name.Property */ - .nt { color: $solarized-light-nt; } /* Name.Tag */ - .nv { color: $solarized-light-nv; } /* Name.Variable */ - .ow { color: $solarized-light-ow; } /* Operator.Word */ - .w { color: $solarized-light-w; } /* Text.Whitespace */ - .mf { color: $solarized-light-mf; } /* Literal.Number.Float */ - .mh { color: $solarized-light-mh; } /* Literal.Number.Hex */ - .mi { color: $solarized-light-mi; } /* Literal.Number.Integer */ - .mo { color: $solarized-light-mo; } /* Literal.Number.Oct */ - .sb { color: $solarized-light-sb; } /* Literal.String.Backtick */ - .sc { color: $solarized-light-sc; } /* Literal.String.Char */ - .sd { color: $solarized-light-sd; } /* Literal.String.Doc */ - .s2 { color: $solarized-light-s2; } /* Literal.String.Double */ - .se { color: $solarized-light-se; } /* Literal.String.Escape */ - .sh { color: $solarized-light-sh; } /* Literal.String.Heredoc */ - .si { color: $solarized-light-si; } /* Literal.String.Interpol */ - .sx { color: $solarized-light-sx; } /* Literal.String.Other */ - .sr { color: $solarized-light-sr; } /* Literal.String.Regex */ - .s1 { color: $solarized-light-s1; } /* Literal.String.Single */ - .ss { color: $solarized-light-ss; } /* Literal.String.Symbol */ - .bp { color: $solarized-light-bp; } /* Name.Builtin.Pseudo */ - .vc { color: $solarized-light-vc; } /* Name.Variable.Class */ - .vg { color: $solarized-light-vg; } /* Name.Variable.Global */ - .vi { color: $solarized-light-vi; } /* Name.Variable.Instance */ - .il { color: $solarized-light-il; } /* Literal.Number.Integer.Long */ -} diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss new file mode 100644 index 00000000000..16893dd047e --- /dev/null +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -0,0 +1,261 @@ +/* https://github.com/MozMorris/tomorrow-pygments */ + +@import "../common"; + +/* +* Dark syntax colors +*/ +$dark-new-bg: rgba(51, 255, 51, 0.1); +$dark-new-idiff: rgba(51, 255, 51, 0.2); +$dark-old-bg: rgba(255, 51, 51, 0.2); +$dark-old-idiff: rgba(255, 51, 51, 0.25); +$dark-border: #808080; +$dark-code-border: #666; +$dark-main-bg: #1d1f21; +$dark-main-color: #1d1f21; +$dark-line-color: #c5c8c6; +$dark-line-num-color: rgba(255, 255, 255, 0.3); +$dark-line-num-color-new: #627165; +$dark-line-num-color-old: #806565; +$dark-diff-not-empty-bg: #557; +$dark-highlight-bg: #ffe792; +$dark-highlight-color: $black; +$dark-pre-hll-bg: #373b41; +$dark-hll-bg: #373b41; +$dark-over-bg: #9f9ab5; +$dark-expanded-bg: #3e3e3e; +$dark-c: #969896; +$dark-err: #c66; +$dark-k: #b294bb; +$dark-l: #de935f; +$dark-n: #c5c8c6; +$dark-o: #8abeb7; +$dark-p: #c5c8c6; +$dark-cm: #969896; +$dark-cp: #969896; +$dark-c1: #969896; +$dark-cs: #969896; +$dark-gd: #c66; +$dark-gh: #c5c8c6; +$dark-gi: #b5bd68; +$dark-gp: #969896; +$dark-gu: #8abeb7; +$dark-kc: #b294bb; +$dark-kd: #b294bb; +$dark-kn: #8abeb7; +$dark-kp: #b294bb; +$dark-kr: #b294bb; +$dark-kt: #f0c674; +$dark-ld: #b5bd68; +$dark-m: #de935f; +$dark-s: #b5bd68; +$dark-na: #81a2be; +$dark-nb: #c5c8c6; +$dark-nc: #f0c674; +$dark-no: #c66; +$dark-nd: #8abeb7; +$dark-ni: #c5c8c6; +$dark-ne: #c66; +$dark-nf: #81a2be; +$dark-nl: #c5c8c6; +$dark-nn: #f0c674; +$dark-nx: #81a2be; +$dark-py: #c5c8c6; +$dark-nt: #8abeb7; +$dark-nv: #c66; +$dark-ow: #8abeb7; +$dark-w: #c5c8c6; +$dark-mf: #de935f; +$dark-mh: #de935f; +$dark-mi: #de935f; +$dark-mo: #de935f; +$dark-sb: #b5bd68; +$dark-sc: #c5c8c6; +$dark-sd: #969896; +$dark-s2: #b5bd68; +$dark-se: #de935f; +$dark-sh: #b5bd68; +$dark-si: #de935f; +$dark-sx: #b5bd68; +$dark-sr: #b5bd68; +$dark-s1: #b5bd68; +$dark-ss: #b5bd68; +$dark-bp: #c5c8c6; +$dark-vc: #c66; +$dark-vg: #c66; +$dark-vi: #c66; +$dark-il: #de935f; + +.code.dark { + // Line numbers + .line-numbers, + .diff-line-num { + background-color: $dark-main-bg; + } + + .diff-line-num, + .diff-line-num a { + color: $dark-line-num-color; + } + + // Code itself + pre.code, + .diff-line-num { + border-color: $dark-code-border; + } + + &, + pre.code, + .line_holder .line_content { + background-color: $dark-main-bg; + color: $dark-line-color; + } + + // Diff line + .line_holder { + &.match .line_content, + &.old-nonewline .line_content, + &.new-nonewline .line_content { + @include dark-diff-match-line; + } + + td.diff-line-num.hll:not(.empty-cell), + td.line_content.hll:not(.empty-cell) { + background-color: $dark-diff-not-empty-bg; + border-color: darken($dark-diff-not-empty-bg, 15%); + } + + .diff-line-num.new, + .line_content.new { + @include diff-background($dark-new-bg, $dark-new-idiff, $dark-border); + + &::before, + a { + color: $dark-line-num-color-new; + } + } + + .diff-line-num.old, + .line_content.old { + @include diff-background($dark-old-bg, $dark-old-idiff, $dark-border); + + &::before, + a { + color: $dark-line-num-color-old; + } + } + + .diff-line-num { + &.is-over, + &.hll:not(.empty-cell).is-over { + background-color: $dark-over-bg; + border-color: darken($dark-over-bg, 5%); + + a { + color: darken($dark-over-bg, 15%); + } + } + } + + .line_content.match { + @include dark-diff-match-line; + } + + &:not(.diff-expanded) + .diff-expanded, + &.diff-expanded + .line_holder:not(.diff-expanded) { + > .diff-line-num, + > .line_content { + border-top: 1px solid $black; + } + } + + &.diff-expanded { + > .diff-line-num, + > .line_content { + background: $dark-expanded-bg; + border-color: $dark-expanded-bg; + } + } + } + + // highlight line via anchor + pre .hll { + background-color: $dark-pre-hll-bg !important; + } + + // Search result highlight + span.highlight_word { + background-color: $dark-highlight-bg !important; + color: $dark-highlight-color !important; + } + + // Links to URLs, emails, or dependencies + .line a { + color: $dark-na; + } + + .hll { background-color: $dark-hll-bg; } + .c { color: $dark-c; } /* Comment */ + .err { color: $dark-err; } /* Error */ + .k { color: $dark-k; } /* Keyword */ + .l { color: $dark-l; } /* Literal */ + .n { color: $dark-n; } /* Name */ + .o { color: $dark-o; } /* Operator */ + .p { color: $dark-p; } /* Punctuation */ + .cm { color: $dark-cm; } /* Comment.Multiline */ + .cp { color: $dark-cp; } /* Comment.Preproc */ + .c1 { color: $dark-c1; } /* Comment.Single */ + .cs { color: $dark-cs; } /* Comment.Special */ + .gd { color: $dark-gd; } /* Generic.Deleted */ + .ge { font-style: italic; } /* Generic.Emph */ + .gh { color: $dark-gh; font-weight: $gl-font-weight-bold; } /* Generic.Heading */ + .gi { color: $dark-gi; } /* Generic.Inserted */ + .gp { color: $dark-gp; font-weight: $gl-font-weight-bold; } /* Generic.Prompt */ + .gs { font-weight: $gl-font-weight-bold; } /* Generic.Strong */ + .gu { color: $dark-gu; font-weight: $gl-font-weight-bold; } /* Generic.Subheading */ + .kc { color: $dark-kc; } /* Keyword.Constant */ + .kd { color: $dark-kd; } /* Keyword.Declaration */ + .kn { color: $dark-kn; } /* Keyword.Namespace */ + .kp { color: $dark-kp; } /* Keyword.Pseudo */ + .kr { color: $dark-kr; } /* Keyword.Reserved */ + .kt { color: $dark-kt; } /* Keyword.Type */ + .ld { color: $dark-ld; } /* Literal.Date */ + .m { color: $dark-m; } /* Literal.Number */ + .s { color: $dark-s; } /* Literal.String */ + .na { color: $dark-na; } /* Name.Attribute */ + .nb { color: $dark-nb; } /* Name.Builtin */ + .nc { color: $dark-nc; } /* Name.Class */ + .no { color: $dark-no; } /* Name.Constant */ + .nd { color: $dark-nd; } /* Name.Decorator */ + .ni { color: $dark-ni; } /* Name.Entity */ + .ne { color: $dark-ne; } /* Name.Exception */ + .nf { color: $dark-nf; } /* Name.Function */ + .nl { color: $dark-nl; } /* Name.Label */ + .nn { color: $dark-nn; } /* Name.Namespace */ + .nx { color: $dark-nx; } /* Name.Other */ + .py { color: $dark-py; } /* Name.Property */ + .nt { color: $dark-nt; } /* Name.Tag */ + .nv { color: $dark-nv; } /* Name.Variable */ + .ow { color: $dark-ow; } /* Operator.Word */ + .w { color: $dark-w; } /* Text.Whitespace */ + .mf { color: $dark-mf; } /* Literal.Number.Float */ + .mh { color: $dark-mh; } /* Literal.Number.Hex */ + .mi { color: $dark-mi; } /* Literal.Number.Integer */ + .mo { color: $dark-mo; } /* Literal.Number.Oct */ + .sb { color: $dark-sb; } /* Literal.String.Backtick */ + .sc { color: $dark-sc; } /* Literal.String.Char */ + .sd { color: $dark-sd; } /* Literal.String.Doc */ + .s2 { color: $dark-s2; } /* Literal.String.Double */ + .se { color: $dark-se; } /* Literal.String.Escape */ + .sh { color: $dark-sh; } /* Literal.String.Heredoc */ + .si { color: $dark-si; } /* Literal.String.Interpol */ + .sx { color: $dark-sx; } /* Literal.String.Other */ + .sr { color: $dark-sr; } /* Literal.String.Regex */ + .s1 { color: $dark-s1; } /* Literal.String.Single */ + .ss { color: $dark-ss; } /* Literal.String.Symbol */ + .bp { color: $dark-bp; } /* Name.Builtin.Pseudo */ + .vc { color: $dark-vc; } /* Name.Variable.Class */ + .vg { color: $dark-vg; } /* Name.Variable.Global */ + .vi { color: $dark-vi; } /* Name.Variable.Instance */ + .il { color: $dark-il; } /* Literal.Number.Integer.Long */ +} diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss new file mode 100644 index 00000000000..37fe61b925c --- /dev/null +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -0,0 +1,259 @@ +/* https://github.com/richleland/pygments-css/blob/master/monokai.css */ + +@import "../common"; + +/* +* Monokai Colors +*/ +$monokai-bg: #272822; +$monokai-border: #555; +$monokai-text-color: #f8f8f2; +$monokai-line-num-color: rgba(255, 255, 255, 0.3); +$monokai-line-num-color-new: #707565; +$monokai-line-num-color-old: #7e736f; +$monokai-line-empty-bg: #49483e; +$monokai-line-empty-border: darken($monokai-line-empty-bg, 15%); +$monokai-diff-border: #808080; +$monokai-highlight-bg: #ffe792; +$monokai-over-bg: #9f9ab5; +$monokai-expanded-bg: #3e3e3e; + +$monokai-new-bg: rgba(166, 226, 46, 0.1); +$monokai-new-idiff: rgba(166, 226, 46, 0.15); + +$monokai-old-bg: rgba(254, 147, 140, 0.15); +$monokai-old-idiff: rgba(254, 147, 140, 0.2); + +$monokai-hll: #49483e; +$monokai-c: #75715e; +$monokai-err-color: #960050; +$monokai-err-bg: #1e0010; +$monokai-k: #66d9ef; +$monokai-l: #ae81ff; +$monokai-n: #f8f8f2; +$monokai-o: #f92672; +$monokai-p: #f8f8f2; +$monokai-cm: #75715e; +$monokai-cp: #75715e; +$monokai-c1: #75715e; +$monokai-cs: #75715e; +$monokai-kc: #66d9ef; +$monokai-kd: #66d9ef; +$monokai-kn: #f92672; +$monokai-kp: #66d9ef; +$monokai-kr: #66d9ef; +$monokai-kt: #66d9ef; +$monokai-ld: #e6db74; +$monokai-m: #ae81ff; +$monokai-s: #e6db74; +$monokai-na: #a6e22e; +$monokai-nb: #f8f8f2; +$monokai-nc: #a6e22e; +$monokai-no: #66d9ef; +$monokai-nd: #a6e22e; +$monokai-ni: #f8f8f2; +$monokai-ne: #a6e22e; +$monokai-nf: #a6e22e; +$monokai-nl: #f8f8f2; +$monokai-nn: #f8f8f2; +$monokai-nx: #a6e22e; +$monokai-py: #f8f8f2; +$monokai-nt: #f92672; +$monokai-nv: #f8f8f2; +$monokai-ow: #f92672; +$monokai-w: #f8f8f2; +$monokai-mf: #ae81ff; +$monokai-mh: #ae81ff; +$monokai-mi: #ae81ff; +$monokai-mo: #ae81ff; +$monokai-sb: #e6db74; +$monokai-sc: #e6db74; +$monokai-sd: #e6db74; +$monokai-s2: #e6db74; +$monokai-se: #ae81ff; +$monokai-sh: #e6db74; +$monokai-si: #e6db74; +$monokai-sx: #e6db74; +$monokai-sr: #e6db74; +$monokai-s1: #e6db74; +$monokai-ss: #e6db74; +$monokai-bp: #f8f8f2; +$monokai-vc: #f8f8f2; +$monokai-vg: #f8f8f2; +$monokai-vi: #f8f8f2; +$monokai-il: #ae81ff; +$monokai-gu: #75715e; +$monokai-gd: #f92672; +$monokai-gi: #a6e22e; + +.code.monokai { + // Line numbers + .line-numbers, + .diff-line-num { + background-color: $monokai-bg; + } + + .diff-line-num, + .diff-line-num a { + color: $monokai-line-num-color; + } + + // Code itself + pre.code, + .diff-line-num { + border-color: $monokai-border; + } + + &, + pre.code, + .line_holder .line_content { + background-color: $monokai-bg; + color: $monokai-text-color; + } + + // Diff line + .line_holder { + &.match .line_content, + &.old-nonewline .line_content, + &.new-nonewline .line_content { + @include dark-diff-match-line; + } + + td.diff-line-num.hll:not(.empty-cell), + td.line_content.hll:not(.empty-cell) { + background-color: $monokai-line-empty-bg; + border-color: $monokai-line-empty-border; + } + + .diff-line-num.new, + .line_content.new { + @include diff-background($monokai-new-bg, $monokai-new-idiff, $monokai-diff-border); + + &::before, + a { + color: $monokai-line-num-color-new; + } + } + + .diff-line-num.old, + .line_content.old { + @include diff-background($monokai-old-bg, $monokai-old-idiff, $monokai-diff-border); + + &::before, + a { + color: $monokai-line-num-color-old; + } + } + + .diff-line-num { + &.is-over, + &.hll:not(.empty-cell).is-over { + background-color: $monokai-over-bg; + border-color: darken($monokai-over-bg, 5%); + + a { + color: darken($monokai-over-bg, 15%); + } + } + } + + .line_content.match { + @include dark-diff-match-line; + } + + &:not(.diff-expanded) + .diff-expanded, + &.diff-expanded + .line_holder:not(.diff-expanded) { + > .diff-line-num, + > .line_content { + border-top: 1px solid $black; + } + } + + &.diff-expanded { + > .diff-line-num, + > .line_content { + background: $monokai-expanded-bg; + border-color: $monokai-expanded-bg; + } + } + } + + // highlight line via anchor + pre .hll { + background-color: $monokai-hll !important; + } + + // Search result highlight + span.highlight_word { + background-color: $monokai-highlight-bg !important; + color: $black !important; + } + + // Links to URLs, emails, or dependencies + .line a { + color: $monokai-k; + } + + .hll { background-color: $monokai-hll; } + .c { color: $monokai-c; } /* Comment */ + .err { color: $monokai-err-color; background-color: $monokai-err-bg; } /* Error */ + .k { color: $monokai-k; } /* Keyword */ + .l { color: $monokai-l; } /* Literal */ + .n { color: $monokai-n; } /* Name */ + .o { color: $monokai-o; } /* Operator */ + .p { color: $monokai-p; } /* Punctuation */ + .cm { color: $monokai-cm; } /* Comment.Multiline */ + .cp { color: $monokai-cp; } /* Comment.Preproc */ + .c1 { color: $monokai-c1; } /* Comment.Single */ + .cs { color: $monokai-cs; } /* Comment.Special */ + .ge { font-style: italic; } /* Generic.Emph */ + .gs { font-weight: $gl-font-weight-bold; } /* Generic.Strong */ + .kc { color: $monokai-kc; } /* Keyword.Constant */ + .kd { color: $monokai-kd; } /* Keyword.Declaration */ + .kn { color: $monokai-kn; } /* Keyword.Namespace */ + .kp { color: $monokai-kp; } /* Keyword.Pseudo */ + .kr { color: $monokai-kr; } /* Keyword.Reserved */ + .kt { color: $monokai-kt; } /* Keyword.Type */ + .ld { color: $monokai-ld; } /* Literal.Date */ + .m { color: $monokai-m; } /* Literal.Number */ + .s { color: $monokai-s; } /* Literal.String */ + .na { color: $monokai-na; } /* Name.Attribute */ + .nb { color: $monokai-nb; } /* Name.Builtin */ + .nc { color: $monokai-nc; } /* Name.Class */ + .no { color: $monokai-no; } /* Name.Constant */ + .nd { color: $monokai-nd; } /* Name.Decorator */ + .ni { color: $monokai-ni; } /* Name.Entity */ + .ne { color: $monokai-ne; } /* Name.Exception */ + .nf { color: $monokai-nf; } /* Name.Function */ + .nl { color: $monokai-nl; } /* Name.Label */ + .nn { color: $monokai-nn; } /* Name.Namespace */ + .nx { color: $monokai-nx; } /* Name.Other */ + .py { color: $monokai-py; } /* Name.Property */ + .nt { color: $monokai-nt; } /* Name.Tag */ + .nv { color: $monokai-nv; } /* Name.Variable */ + .ow { color: $monokai-ow; } /* Operator.Word */ + .w { color: $monokai-w; } /* Text.Whitespace */ + .mf { color: $monokai-mf; } /* Literal.Number.Float */ + .mh { color: $monokai-mh; } /* Literal.Number.Hex */ + .mi { color: $monokai-mi; } /* Literal.Number.Integer */ + .mo { color: $monokai-mo; } /* Literal.Number.Oct */ + .sb { color: $monokai-sb; } /* Literal.String.Backtick */ + .sc { color: $monokai-sc; } /* Literal.String.Char */ + .sd { color: $monokai-sd; } /* Literal.String.Doc */ + .s2 { color: $monokai-s2; } /* Literal.String.Double */ + .se { color: $monokai-se; } /* Literal.String.Escape */ + .sh { color: $monokai-sh; } /* Literal.String.Heredoc */ + .si { color: $monokai-si; } /* Literal.String.Interpol */ + .sx { color: $monokai-sx; } /* Literal.String.Other */ + .sr { color: $monokai-sr; } /* Literal.String.Regex */ + .s1 { color: $monokai-s1; } /* Literal.String.Single */ + .ss { color: $monokai-ss; } /* Literal.String.Symbol */ + .bp { color: $monokai-bp; } /* Name.Builtin.Pseudo */ + .vc { color: $monokai-vc; } /* Name.Variable.Class */ + .vg { color: $monokai-vg; } /* Name.Variable.Global */ + .vi { color: $monokai-vi; } /* Name.Variable.Instance */ + .il { color: $monokai-il; } /* Literal.Number.Integer.Long */ + .gu { color: $monokai-gu; } /* Generic.Subheading & Diff Unified/Comment? */ + .gd { color: $monokai-gd; } /* Generic.Deleted & Diff Deleted */ + .gi { color: $monokai-gi; } /* Generic.Inserted & Diff Inserted */ +} diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss new file mode 100644 index 00000000000..b4217aac37a --- /dev/null +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -0,0 +1,238 @@ +/* +* None Syntax Colors +*/ + +@import "../common"; + +@mixin match-line { + color: $black-transparent; + background-color: $white-normal; +} + +.code.none { + // Line numbers + .line-numbers, + .diff-line-num { + background-color: $gray-light; + } + + .diff-line-num, + .diff-line-num a { + color: $black-transparent; + } + + // Code itself + pre.code, + .diff-line-num { + border-color: $white-normal; + } + + &, + pre.code, + .line_holder .line_content { + background-color: $white-light; + color: $gl-text-color; + } + +// Diff line + + $none-over-bg: #ded7fc; + $none-expanded-border: #e0e0e0; + $none-expanded-bg: #e0e0e0; + + .line_holder { + + &.match .line_content, + .new-nonewline.line_content, + .old-nonewline.line_content { + @include match-line; + } + + .diff-line-num { + &.old { + a { + color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%); + } + } + + &.new { + a { + color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%); + } + } + + &.is-over, + &.hll:not(.empty-cell).is-over { + background-color: $none-over-bg; + border-color: darken($none-over-bg, 5%); + + a { + color: darken($none-over-bg, 15%); + } + } + + &.hll:not(.empty-cell) { + background-color: $white-light; + border-color: $white-normal; + } + } + + &:not(.diff-expanded) + .diff-expanded, + &.diff-expanded + .line_holder:not(.diff-expanded) { + > .diff-line-num, + > .line_content { + border-top: 1px solid $none-expanded-border; + } + } + + &.diff-expanded { + > .diff-line-num, + > .line_content { + background: $none-expanded-bg; + border-color: $none-expanded-bg; + } + } + + .line_content { + &.old { + background-color: $white-normal; + + &::before { + color: $gl-text-color; + } + + span.idiff { + background-color: $white-normal; + text-decoration: underline; + } + } + + &.new { + background-color: $white-normal; + + &::before { + color: $gl-text-color; + } + + span.idiff { + background-color: $white-normal; + text-decoration: underline; + } + } + + &.match { + @include match-line; + } + + &.hll:not(.empty-cell) { + background-color: $white-normal; + } + } + } + + // highlight line via anchor + pre .hll { + background-color: $white-normal; + } + + // Search result highlight + span.highlight_word { + background-color: $white-normal; + } + + // Links to URLs, emails, or dependencies + .line a { + color: $gl-text-color; + text-decoration: underline; + } + + .hll { background-color: $white-light; } + + .gd { + color: $gl-text-color; + background-color: $white-light; + + .x { + color: $gl-text-color; + background-color: $white-light; + } + } + + .gi { + color: $gl-text-color; + background-color: $white-light; + + .x { + color: $gl-text-color; + background-color: $white-light; + } + } + + .c { color: $gl-text-color; } /* Comment */ + .err { color: $gl-text-color; } /* Error */ + .g { color: $gl-text-color; } /* Generic */ + .k { color: $gl-text-color; } /* Keyword */ + .l { color: $gl-text-color; } /* Literal */ + .n { color: $gl-text-color; } /* Name */ + .o { color: $gl-text-color; } /* Operator */ + .x { color: $gl-text-color; } /* Other */ + .p { color: $gl-text-color; } /* Punctuation */ + .cm { color: $gl-text-color; } /* Comment.Multiline */ + .cp { color: $gl-text-color; } /* Comment.Preproc */ + .c1 { color: $gl-text-color; } /* Comment.Single */ + .cs { color: $gl-text-color; } /* Comment.Special */ + .ge { color: $gl-text-color; } /* Generic.Emph */ + .gr { color: $gl-text-color; } /* Generic.Error */ + .gh { color: $gl-text-color; } /* Generic.Heading */ + .go { color: $gl-text-color; } /* Generic.Output */ + .gp { color: $gl-text-color; } /* Generic.Prompt */ + .gs { color: $gl-text-color; } /* Generic.Strong */ + .gu { color: $gl-text-color; } /* Generic.Subheading */ + .gt { color: $gl-text-color; } /* Generic.Traceback */ + .kc { color: $gl-text-color; } /* Keyword.Constant */ + .kd { color: $gl-text-color; } /* Keyword.Declaration */ + .kn { color: $gl-text-color; } /* Keyword.Namespace */ + .kp { color: $gl-text-color; } /* Keyword.Pseudo */ + .kr { color: $gl-text-color; } /* Keyword.Reserved */ + .kt { color: $gl-text-color; } /* Keyword.Type */ + .ld { color: $gl-text-color; } /* Literal.Date */ + .m { color: $gl-text-color; } /* Literal.Number */ + .s { color: $gl-text-color; } /* Literal.String */ + .na { color: $gl-text-color; } /* Name.Attribute */ + .nb { color: $gl-text-color; } /* Name.Builtin */ + .nc { color: $gl-text-color; } /* Name.Class */ + .no { color: $gl-text-color; } /* Name.Constant */ + .nd { color: $gl-text-color; } /* Name.Decorator */ + .ni { color: $gl-text-color; } /* Name.Entity */ + .ne { color: $gl-text-color; } /* Name.Exception */ + .nf { color: $gl-text-color; } /* Name.Function */ + .nl { color: $gl-text-color; } /* Name.Label */ + .nn { color: $gl-text-color; } /* Name.Namespace */ + .nx { color: $gl-text-color; } /* Name.Other */ + .py { color: $gl-text-color; } /* Name.Property */ + .nt { color: $gl-text-color; } /* Name.Tag */ + .nv { color: $gl-text-color; } /* Name.Variable */ + .ow { color: $gl-text-color; } /* Operator.Word */ + .w { color: $gl-text-color; } /* Text.Whitespace */ + .mf { color: $gl-text-color; } /* Literal.Number.Float */ + .mh { color: $gl-text-color; } /* Literal.Number.Hex */ + .mi { color: $gl-text-color; } /* Literal.Number.Integer */ + .mo { color: $gl-text-color; } /* Literal.Number.Oct */ + .sb { color: $gl-text-color; } /* Literal.String.Backtick */ + .sc { color: $gl-text-color; } /* Literal.String.Char */ + .sd { color: $gl-text-color; } /* Literal.String.Doc */ + .s2 { color: $gl-text-color; } /* Literal.String.Double */ + .se { color: $gl-text-color; } /* Literal.String.Escape */ + .sh { color: $gl-text-color; } /* Literal.String.Heredoc */ + .si { color: $gl-text-color; } /* Literal.String.Interpol */ + .sx { color: $gl-text-color; } /* Literal.String.Other */ + .sr { color: $gl-text-color; } /* Literal.String.Regex */ + .s1 { color: $gl-text-color; } /* Literal.String.Single */ + .ss { color: $gl-text-color; } /* Literal.String.Symbol */ + .bp { color: $gl-text-color; } /* Name.Builtin.Pseudo */ + .vc { color: $gl-text-color; } /* Name.Variable.Class */ + .vg { color: $gl-text-color; } /* Name.Variable.Global */ + .vi { color: $gl-text-color; } /* Name.Variable.Instance */ + .il { color: $gl-text-color; } /* Literal.Number.Integer.Long */ + +} diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss new file mode 100644 index 00000000000..a4e9eda22c9 --- /dev/null +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -0,0 +1,286 @@ +/* https://gist.github.com/qguv/7936275 */ + +@import "../common"; + +/* +* Solarized dark colors +*/ +$solarized-dark-new-bg: rgba(133, 153, 0, 0.15); +$solarized-dark-new-idiff: rgba(133, 153, 0, 0.25); +$solarized-dark-old-bg: rgba(220, 50, 47, 0.3); +$solarized-dark-old-idiff: rgba(220, 50, 47, 0.25); +$solarized-dark-border: #113b46; +$solarized-dark-pre-bg: #002b36; +$solarized-dark-pre-color: #93a1a1; +$solarized-dark-pre-border: #113b46; +$solarized-dark-line-bg: #002b36; +$solarized-dark-line-color: rgba(255, 255, 255, 0.3); +$solarized-dark-line-color-new: #5a766c; +$solarized-dark-line-color-old: #7a6c71; +$solarized-dark-highlight: #094554; +$solarized-dark-hll-bg: #174652; +$solarized-dark-over-bg: #9f9ab5; +$solarized-dark-expanded-bg: #010d10; +$solarized-dark-c: #586e75; +$solarized-dark-err: #93a1a1; +$solarized-dark-g: #93a1a1; +$solarized-dark-k: #859900; +$solarized-dark-l: #93a1a1; +$solarized-dark-n: #93a1a1; +$solarized-dark-o: #859900; +$solarized-dark-x: #cb4b16; +$solarized-dark-p: #93a1a1; +$solarized-dark-cm: #586e75; +$solarized-dark-cp: #859900; +$solarized-dark-c1: #586e75; +$solarized-dark-cs: #859900; +$solarized-dark-gd: #2aa198; +$solarized-dark-ge: #93a1a1; +$solarized-dark-gr: #dc322f; +$solarized-dark-gh: #cb4b16; +$solarized-dark-gi: #859900; +$solarized-dark-go: #93a1a1; +$solarized-dark-gp: #93a1a1; +$solarized-dark-gs: #93a1a1; +$solarized-dark-gu: #cb4b16; +$solarized-dark-gt: #93a1a1; +$solarized-dark-kc: #cb4b16; +$solarized-dark-kd: #268bd2; +$solarized-dark-kn: #859900; +$solarized-dark-kp: #859900; +$solarized-dark-kr: #268bd2; +$solarized-dark-kt: #dc322f; +$solarized-dark-ld: #93a1a1; +$solarized-dark-m: #2aa198; +$solarized-dark-s: #2aa198; +$solarized-dark-na: #93a1a1; +$solarized-dark-nb: #b58900; +$solarized-dark-nc: #268bd2; +$solarized-dark-no: #cb4b16; +$solarized-dark-nd: #268bd2; +$solarized-dark-ni: #cb4b16; +$solarized-dark-ne: #cb4b16; +$solarized-dark-nf: #268bd2; +$solarized-dark-nl: #93a1a1; +$solarized-dark-nn: #93a1a1; +$solarized-dark-nx: #93a1a1; +$solarized-dark-py: #93a1a1; +$solarized-dark-nt: #268bd2; +$solarized-dark-nv: #268bd2; +$solarized-dark-ow: #859900; +$solarized-dark-w: #93a1a1; +$solarized-dark-mf: #2aa198; +$solarized-dark-mh: #2aa198; +$solarized-dark-mi: #2aa198; +$solarized-dark-mo: #2aa198; +$solarized-dark-sb: #586e75; +$solarized-dark-sc: #2aa198; +$solarized-dark-sd: #93a1a1; +$solarized-dark-s2: #2aa198; +$solarized-dark-se: #cb4b16; +$solarized-dark-sh: #93a1a1; +$solarized-dark-si: #2aa198; +$solarized-dark-sx: #2aa198; +$solarized-dark-sr: #dc322f; +$solarized-dark-s1: #2aa198; +$solarized-dark-ss: #2aa198; +$solarized-dark-bp: #268bd2; +$solarized-dark-vc: #268bd2; +$solarized-dark-vg: #268bd2; +$solarized-dark-vi: #268bd2; +$solarized-dark-il: #2aa198; + +.code.solarized-dark { + // Line numbers + .line-numbers, + .diff-line-num { + background-color: $solarized-dark-line-bg; + } + + .diff-line-num, + .diff-line-num a { + color: $solarized-dark-line-color; + } + + // Code itself + pre.code, + .diff-line-num { + border-color: $solarized-dark-pre-border; + } + + &, + pre.code, + .line_holder .line_content { + background-color: $solarized-dark-pre-bg; + color: $solarized-dark-pre-color; + } + + // Diff line + .line_holder { + &.match .line_content, + &.old-nonewline .line_content, + &.new-nonewline .line_content { + @include dark-diff-match-line; + } + + td.diff-line-num.hll:not(.empty-cell), + td.line_content.hll:not(.empty-cell) { + background-color: $solarized-dark-hll-bg; + border-color: darken($solarized-dark-hll-bg, 15%); + } + + .diff-line-num.new, + .line_content.new { + @include diff-background($solarized-dark-new-bg, $solarized-dark-new-idiff, $solarized-dark-border); + + &::before, + a { + color: $solarized-dark-line-color-new; + } + } + + .diff-line-num.old, + .line_content.old { + @include diff-background($solarized-dark-old-bg, $solarized-dark-old-idiff, $solarized-dark-border); + + &::before, + a { + color: $solarized-dark-line-color-old; + } + } + + .diff-line-num { + &.is-over, + &.hll:not(.empty-cell).is-over { + background-color: $solarized-dark-over-bg; + border-color: darken($solarized-dark-over-bg, 5%); + + a { + color: darken($solarized-dark-over-bg, 15%); + } + } + } + + .line_content.match { + @include dark-diff-match-line; + } + + &:not(.diff-expanded) + .diff-expanded, + &.diff-expanded + .line_holder:not(.diff-expanded) { + > .diff-line-num, + > .line_content { + border-top: 1px solid $black; + } + } + + &.diff-expanded { + > .diff-line-num, + > .line_content { + background: $solarized-dark-expanded-bg; + border-color: $solarized-dark-expanded-bg; + } + } + } + + // highlight line via anchor + pre .hll { + background-color: $solarized-dark-hll-bg !important; + } + + // Search result highlight + span.highlight_word { + background-color: $solarized-dark-highlight !important; + } + + // Links to URLs, emails, or dependencies + .line a { + color: $solarized-dark-kd; + } + + /* Solarized Dark + + For use with Jekyll and Pygments + + http://ethanschoonover.com/solarized + + SOLARIZED HEX ROLE + --------- -------- ------------------------------------------ + base03 #002b36 background + base01 #586e75 comments / secondary content + base1 #93a1a1 body text / default code / primary content + orange #cb4b16 constants + red #dc322f regex, special keywords + blue #268bd2 reserved keywords + cyan #2aa198 strings, numbers + green #859900 operators, other keywords + */ + + .c { color: $solarized-dark-c; } /* Comment */ + .err { color: $solarized-dark-err; } /* Error */ + .g { color: $solarized-dark-g; } /* Generic */ + .k { color: $solarized-dark-k; } /* Keyword */ + .l { color: $solarized-dark-l; } /* Literal */ + .n { color: $solarized-dark-n; } /* Name */ + .o { color: $solarized-dark-o; } /* Operator */ + .x { color: $solarized-dark-x; } /* Other */ + .p { color: $solarized-dark-p; } /* Punctuation */ + .cm { color: $solarized-dark-cm; } /* Comment.Multiline */ + .cp { color: $solarized-dark-cp; } /* Comment.Preproc */ + .c1 { color: $solarized-dark-c1; } /* Comment.Single */ + .cs { color: $solarized-dark-cs; } /* Comment.Special */ + .gd { color: $solarized-dark-gd; } /* Generic.Deleted */ + .ge { color: $solarized-dark-ge; font-style: italic; } /* Generic.Emph */ + .gr { color: $solarized-dark-gr; } /* Generic.Error */ + .gh { color: $solarized-dark-gh; } /* Generic.Heading */ + .gi { color: $solarized-dark-gi; } /* Generic.Inserted */ + .go { color: $solarized-dark-go; } /* Generic.Output */ + .gp { color: $solarized-dark-gp; } /* Generic.Prompt */ + .gs { color: $solarized-dark-gs; font-weight: $gl-font-weight-bold; } /* Generic.Strong */ + .gu { color: $solarized-dark-gu; } /* Generic.Subheading */ + .gt { color: $solarized-dark-gt; } /* Generic.Traceback */ + .kc { color: $solarized-dark-kc; } /* Keyword.Constant */ + .kd { color: $solarized-dark-kd; } /* Keyword.Declaration */ + .kn { color: $solarized-dark-kn; } /* Keyword.Namespace */ + .kp { color: $solarized-dark-kp; } /* Keyword.Pseudo */ + .kr { color: $solarized-dark-kr; } /* Keyword.Reserved */ + .kt { color: $solarized-dark-kt; } /* Keyword.Type */ + .ld { color: $solarized-dark-ld; } /* Literal.Date */ + .m { color: $solarized-dark-m; } /* Literal.Number */ + .s { color: $solarized-dark-s; } /* Literal.String */ + .na { color: $solarized-dark-na; } /* Name.Attribute */ + .nb { color: $solarized-dark-nb; } /* Name.Builtin */ + .nc { color: $solarized-dark-nc; } /* Name.Class */ + .no { color: $solarized-dark-no; } /* Name.Constant */ + .nd { color: $solarized-dark-nd; } /* Name.Decorator */ + .ni { color: $solarized-dark-ni; } /* Name.Entity */ + .ne { color: $solarized-dark-ne; } /* Name.Exception */ + .nf { color: $solarized-dark-nf; } /* Name.Function */ + .nl { color: $solarized-dark-nl; } /* Name.Label */ + .nn { color: $solarized-dark-nn; } /* Name.Namespace */ + .nx { color: $solarized-dark-nx; } /* Name.Other */ + .py { color: $solarized-dark-py; } /* Name.Property */ + .nt { color: $solarized-dark-nt; } /* Name.Tag */ + .nv { color: $solarized-dark-nv; } /* Name.Variable */ + .ow { color: $solarized-dark-ow; } /* Operator.Word */ + .w { color: $solarized-dark-w; } /* Text.Whitespace */ + .mf { color: $solarized-dark-mf; } /* Literal.Number.Float */ + .mh { color: $solarized-dark-mh; } /* Literal.Number.Hex */ + .mi { color: $solarized-dark-mi; } /* Literal.Number.Integer */ + .mo { color: $solarized-dark-mo; } /* Literal.Number.Oct */ + .sb { color: $solarized-dark-sb; } /* Literal.String.Backtick */ + .sc { color: $solarized-dark-sc; } /* Literal.String.Char */ + .sd { color: $solarized-dark-sd; } /* Literal.String.Doc */ + .s2 { color: $solarized-dark-s2; } /* Literal.String.Double */ + .se { color: $solarized-dark-se; } /* Literal.String.Escape */ + .sh { color: $solarized-dark-sh; } /* Literal.String.Heredoc */ + .si { color: $solarized-dark-si; } /* Literal.String.Interpol */ + .sx { color: $solarized-dark-sx; } /* Literal.String.Other */ + .sr { color: $solarized-dark-sr; } /* Literal.String.Regex */ + .s1 { color: $solarized-dark-s1; } /* Literal.String.Single */ + .ss { color: $solarized-dark-ss; } /* Literal.String.Symbol */ + .bp { color: $solarized-dark-bp; } /* Name.Builtin.Pseudo */ + .vc { color: $solarized-dark-vc; } /* Name.Variable.Class */ + .vg { color: $solarized-dark-vg; } /* Name.Variable.Global */ + .vi { color: $solarized-dark-vi; } /* Name.Variable.Instance */ + .il { color: $solarized-dark-il; } /* Literal.Number.Integer.Long */ +} diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss new file mode 100644 index 00000000000..b604d1ccb6c --- /dev/null +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -0,0 +1,294 @@ +/* https://gist.github.com/qguv/7936275 */ + +@import "../common"; + +/* +* Solarized light syntax colors +*/ +$solarized-light-matchline-bg: rgba(255, 255, 255, 0.4); +$solarized-light-new-bg: rgba(133, 153, 0, 0.2); +$solarized-light-new-idiff: rgba(133, 153, 0, 0.25); +$solarized-light-old-bg: rgba(220, 50, 47, 0.2); +$solarized-light-old-idiff: rgba(220, 50, 47, 0.25); +$solarized-light-border: #c5d0d4; +$solarized-light-pre-bg: #002b36; +$solarized-light-pre-bg: #fdf6e3; +$solarized-light-pre-color: #586e75; +$solarized-light-line-bg: #fdf6e3; +$solarized-light-line-color: rgba(0, 0, 0, 0.3); +$solarized-light-line-color-new: #a1a080; +$solarized-light-line-color-old: #ad9186; +$solarized-light-highlight: #eee8d5; +$solarized-light-hll-bg: #ddd8c5; +$solarized-light-over-bg: #ded7fc; +$solarized-light-expanded-border: #d2cdbd; +$solarized-light-expanded-bg: #ece6d4; +$solarized-light-c: #93a1a1; +$solarized-light-err: #586e75; +$solarized-light-g: #586e75; +$solarized-light-k: #859900; +$solarized-light-l: #586e75; +$solarized-light-n: #586e75; +$solarized-light-o: #859900; +$solarized-light-x: #cb4b16; +$solarized-light-p: #586e75; +$solarized-light-cm: #93a1a1; +$solarized-light-cp: #859900; +$solarized-light-c1: #93a1a1; +$solarized-light-cs: #859900; +$solarized-light-gd: #2aa198; +$solarized-light-ge: #586e75; +$solarized-light-gr: #dc322f; +$solarized-light-gh: #cb4b16; +$solarized-light-gi: #859900; +$solarized-light-go: #586e75; +$solarized-light-gp: #586e75; +$solarized-light-gs: #586e75; +$solarized-light-gu: #cb4b16; +$solarized-light-gt: #586e75; +$solarized-light-kc: #cb4b16; +$solarized-light-kd: #268bd2; +$solarized-light-kn: #859900; +$solarized-light-kp: #859900; +$solarized-light-kr: #268bd2; +$solarized-light-kt: #dc322f; +$solarized-light-ld: #586e75; +$solarized-light-m: #2aa198; +$solarized-light-s: #2aa198; +$solarized-light-na: #586e75; +$solarized-light-nb: #b58900; +$solarized-light-nc: #268bd2; +$solarized-light-no: #cb4b16; +$solarized-light-nd: #268bd2; +$solarized-light-ni: #cb4b16; +$solarized-light-ne: #cb4b16; +$solarized-light-nf: #268bd2; +$solarized-light-nl: #586e75; +$solarized-light-nn: #586e75; +$solarized-light-nx: #586e75; +$solarized-light-py: #586e75; +$solarized-light-nt: #268bd2; +$solarized-light-nv: #268bd2; +$solarized-light-ow: #859900; +$solarized-light-w: #586e75; +$solarized-light-mf: #2aa198; +$solarized-light-mh: #2aa198; +$solarized-light-mi: #2aa198; +$solarized-light-mo: #2aa198; +$solarized-light-sb: #93a1a1; +$solarized-light-sc: #2aa198; +$solarized-light-sd: #586e75; +$solarized-light-s2: #2aa198; +$solarized-light-se: #cb4b16; +$solarized-light-sh: #586e75; +$solarized-light-si: #2aa198; +$solarized-light-sx: #2aa198; +$solarized-light-sr: #dc322f; +$solarized-light-s1: #2aa198; +$solarized-light-ss: #2aa198; +$solarized-light-bp: #268bd2; +$solarized-light-vc: #268bd2; +$solarized-light-vg: #268bd2; +$solarized-light-vi: #268bd2; +$solarized-light-il: #2aa198; + +@mixin match-line { + color: $black-transparent; + background: $solarized-light-matchline-bg; +} + +.code.solarized-light { + // Line numbers + .line-numbers, + .diff-line-num { + background-color: $solarized-light-line-bg; + } + + .diff-line-num, + .diff-line-num a { + color: $solarized-light-line-color; + } + + // Code itself + pre.code, + .diff-line-num { + border-color: $solarized-light-border; + } + + &, + pre.code, + .line_holder .line_content { + background-color: $solarized-light-pre-bg; + color: $solarized-light-pre-color; + } + + // Diff line + .line_holder { + &.match .line_content, + &.old-nonewline .line_content, + &.new-nonewline .line_content { + @include match-line; + } + + td.diff-line-num.hll:not(.empty-cell), + td.line_content.hll:not(.empty-cell) { + background-color: $solarized-light-hll-bg; + border-color: darken($solarized-light-hll-bg, 15%); + } + + .diff-line-num.new, + .line_content.new { + @include diff-background($solarized-light-new-bg, + $solarized-light-new-idiff, $solarized-light-border); + + &::before, + a { + color: $solarized-light-line-color-new; + } + } + + .diff-line-num.old, + .line_content.old { + @include diff-background($solarized-light-old-bg, $solarized-light-old-idiff, $solarized-light-border); + + &::before, + a { + color: $solarized-light-line-color-old; + } + } + + .diff-line-num { + &.is-over, + &.hll:not(.empty-cell).is-over { + background-color: $solarized-light-over-bg; + border-color: darken($solarized-light-over-bg, 5%); + + a { + color: darken($solarized-light-over-bg, 15%); + } + } + } + + .line_content.match { + @include match-line; + } + + &:not(.diff-expanded) + .diff-expanded, + &.diff-expanded + .line_holder:not(.diff-expanded) { + > .diff-line-num, + > .line_content { + border-top: 1px solid $solarized-light-expanded-border; + } + } + + &.diff-expanded { + > .diff-line-num, + > .line_content { + background: $solarized-light-expanded-bg; + border-color: $solarized-light-expanded-bg; + } + } + } + + // highlight line via anchor + pre .hll { + background-color: $solarized-light-hll-bg !important; + } + + // Search result highlight + span.highlight_word { + background-color: $solarized-light-highlight !important; + } + + // Links to URLs, emails, or dependencies + .line a { + color: $solarized-light-kd; + } + + /* Solarized Light + + For use with Jekyll and Pygments + + http://ethanschoonover.com/solarized + + SOLARIZED HEX ROLE + --------- -------- ------------------------------------------ + base01 #586e75 body text / default code / primary content + base1 #93a1a1 comments / secondary content + base3 #fdf6e3 background + orange #cb4b16 constants + red #dc322f regex, special keywords + blue #268bd2 reserved keywords + cyan #2aa198 strings, numbers + green #859900 operators, other keywords + */ + + .c { color: $solarized-light-c; } /* Comment */ + .err { color: $solarized-light-err; } /* Error */ + .g { color: $solarized-light-g; } /* Generic */ + .k { color: $solarized-light-k; } /* Keyword */ + .l { color: $solarized-light-l; } /* Literal */ + .n { color: $solarized-light-n; } /* Name */ + .o { color: $solarized-light-o; } /* Operator */ + .x { color: $solarized-light-x; } /* Other */ + .p { color: $solarized-light-p; } /* Punctuation */ + .cm { color: $solarized-light-cm; } /* Comment.Multiline */ + .cp { color: $solarized-light-cp; } /* Comment.Preproc */ + .c1 { color: $solarized-light-c1; } /* Comment.Single */ + .cs { color: $solarized-light-cs; } /* Comment.Special */ + .gd { color: $solarized-light-gd; } /* Generic.Deleted */ + .ge { color: $solarized-light-ge; font-style: italic; } /* Generic.Emph */ + .gr { color: $solarized-light-gr; } /* Generic.Error */ + .gh { color: $solarized-light-gh; } /* Generic.Heading */ + .gi { color: $solarized-light-gi; } /* Generic.Inserted */ + .go { color: $solarized-light-go; } /* Generic.Output */ + .gp { color: $solarized-light-gp; } /* Generic.Prompt */ + .gs { color: $solarized-light-gs; font-weight: $gl-font-weight-bold; } /* Generic.Strong */ + .gu { color: $solarized-light-gu; } /* Generic.Subheading */ + .gt { color: $solarized-light-gt; } /* Generic.Traceback */ + .kc { color: $solarized-light-kc; } /* Keyword.Constant */ + .kd { color: $solarized-light-kd; } /* Keyword.Declaration */ + .kn { color: $solarized-light-kn; } /* Keyword.Namespace */ + .kp { color: $solarized-light-kp; } /* Keyword.Pseudo */ + .kr { color: $solarized-light-kr; } /* Keyword.Reserved */ + .kt { color: $solarized-light-kt; } /* Keyword.Type */ + .ld { color: $solarized-light-ld; } /* Literal.Date */ + .m { color: $solarized-light-m; } /* Literal.Number */ + .s { color: $solarized-light-s; } /* Literal.String */ + .na { color: $solarized-light-na; } /* Name.Attribute */ + .nb { color: $solarized-light-nb; } /* Name.Builtin */ + .nc { color: $solarized-light-nc; } /* Name.Class */ + .no { color: $solarized-light-no; } /* Name.Constant */ + .nd { color: $solarized-light-nd; } /* Name.Decorator */ + .ni { color: $solarized-light-ni; } /* Name.Entity */ + .ne { color: $solarized-light-ne; } /* Name.Exception */ + .nf { color: $solarized-light-nf; } /* Name.Function */ + .nl { color: $solarized-light-nl; } /* Name.Label */ + .nn { color: $solarized-light-nn; } /* Name.Namespace */ + .nx { color: $solarized-light-nx; } /* Name.Other */ + .py { color: $solarized-light-py; } /* Name.Property */ + .nt { color: $solarized-light-nt; } /* Name.Tag */ + .nv { color: $solarized-light-nv; } /* Name.Variable */ + .ow { color: $solarized-light-ow; } /* Operator.Word */ + .w { color: $solarized-light-w; } /* Text.Whitespace */ + .mf { color: $solarized-light-mf; } /* Literal.Number.Float */ + .mh { color: $solarized-light-mh; } /* Literal.Number.Hex */ + .mi { color: $solarized-light-mi; } /* Literal.Number.Integer */ + .mo { color: $solarized-light-mo; } /* Literal.Number.Oct */ + .sb { color: $solarized-light-sb; } /* Literal.String.Backtick */ + .sc { color: $solarized-light-sc; } /* Literal.String.Char */ + .sd { color: $solarized-light-sd; } /* Literal.String.Doc */ + .s2 { color: $solarized-light-s2; } /* Literal.String.Double */ + .se { color: $solarized-light-se; } /* Literal.String.Escape */ + .sh { color: $solarized-light-sh; } /* Literal.String.Heredoc */ + .si { color: $solarized-light-si; } /* Literal.String.Interpol */ + .sx { color: $solarized-light-sx; } /* Literal.String.Other */ + .sr { color: $solarized-light-sr; } /* Literal.String.Regex */ + .s1 { color: $solarized-light-s1; } /* Literal.String.Single */ + .ss { color: $solarized-light-ss; } /* Literal.String.Symbol */ + .bp { color: $solarized-light-bp; } /* Name.Builtin.Pseudo */ + .vc { color: $solarized-light-vc; } /* Name.Variable.Class */ + .vg { color: $solarized-light-vg; } /* Name.Variable.Global */ + .vi { color: $solarized-light-vi; } /* Name.Variable.Instance */ + .il { color: $solarized-light-il; } /* Literal.Number.Integer.Long */ +} diff --git a/app/assets/stylesheets/highlight/themes/white.scss b/app/assets/stylesheets/highlight/themes/white.scss new file mode 100644 index 00000000000..7239086f649 --- /dev/null +++ b/app/assets/stylesheets/highlight/themes/white.scss @@ -0,0 +1,3 @@ +.code.white { + @import "../white_base"; +} diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss deleted file mode 100644 index 355c8d223f7..00000000000 --- a/app/assets/stylesheets/highlight/white.scss +++ /dev/null @@ -1,3 +0,0 @@ -.code.white { - @import "white_base"; -} diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index 90a5250c247..ee0ec94c636 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -1,5 +1,7 @@ /* https://github.com/aahan/pygments-github-style */ +@import './common'; + /* * White Syntax Colors */ @@ -35,16 +37,16 @@ $white-kt: #458; $white-m: #099; $white-s: #d14; $white-n: #333; -$white-na: teal; +$white-na: #008080; $white-nb: #0086b3; $white-nc: #458; -$white-no: teal; -$white-ni: purple; +$white-no: #008080; +$white-ni: #800080; $white-ne: #900; $white-nf: #900; $white-nn: #555; -$white-nt: navy; -$white-nv: teal; +$white-nt: #000080; +$white-nv: #008080; $white-w: #bbb; $white-mf: #099; $white-mh: #099; @@ -62,20 +64,20 @@ $white-sr: #009926; $white-s1: #d14; $white-ss: #990073; $white-bp: #999; -$white-vc: teal; -$white-vg: teal; -$white-vi: teal; +$white-vc: #008080; +$white-vg: #008080; +$white-vi: #008080; $white-il: #099; $white-gc-color: #999; $white-gc-bg: #eaf2f5; -@mixin matchLine { +@mixin match-line { color: $black-transparent; background-color: $gray-light; } - // Line numbers +// Line numbers .line-numbers, .diff-line-num { background-color: $gray-light; @@ -101,11 +103,10 @@ pre.code, // Diff line .line_holder { - &.match .line_content, .new-nonewline.line_content, .old-nonewline.line_content { - @include matchLine; + @include match-line; } .diff-line-num { @@ -185,7 +186,7 @@ pre.code, } &.match { - @include matchLine; + @include match-line; } &.hll:not(.empty-cell) { @@ -199,25 +200,38 @@ pre .hll { background-color: $white-pre-hll-bg !important; } - // Search result highlight +// Search result highlight span.highlight_word { background-color: $white-highlight !important; } - // Links to URLs, emails, or dependencies +// Links to URLs, emails, or dependencies .line a { color: $white-nb; } .hll { background-color: $white-hll-bg; } -.c { color: $white-c; font-style: italic; } -.err { color: $white-err; background-color: $white-err-bg; } + +.c { color: $white-c; + font-style: italic; } + +.err { color: $white-err; + background-color: $white-err-bg; } .k { font-weight: $gl-font-weight-bold; } .o { font-weight: $gl-font-weight-bold; } -.cm { color: $white-cm; font-style: italic; } -.cp { color: $white-cp; font-weight: $gl-font-weight-bold; } -.c1 { color: $white-c1; font-style: italic; } -.cs { color: $white-cs; font-weight: $gl-font-weight-bold; font-style: italic; } + +.cm { color: $white-cm; + font-style: italic; } + +.cp { color: $white-cp; + font-weight: $gl-font-weight-bold; } + +.c1 { color: $white-c1; + font-style: italic; } + +.cs { color: $white-cs; + font-weight: $gl-font-weight-bold; + font-style: italic; } .gd { color: $white-gd; @@ -246,24 +260,34 @@ span.highlight_word { .go { color: $white-go; } .gp { color: $white-gp; } .gs { font-weight: $gl-font-weight-bold; } -.gu { color: $white-gu; font-weight: $gl-font-weight-bold; } + +.gu { color: $white-gu; + font-weight: $gl-font-weight-bold; } .gt { color: $white-gt; } .kc { font-weight: $gl-font-weight-bold; } .kd { font-weight: $gl-font-weight-bold; } .kn { font-weight: $gl-font-weight-bold; } .kp { font-weight: $gl-font-weight-bold; } .kr { font-weight: $gl-font-weight-bold; } -.kt { color: $white-kt; font-weight: $gl-font-weight-bold; } + +.kt { color: $white-kt; + font-weight: $gl-font-weight-bold; } .m { color: $white-m; } .s { color: $white-s; } .n { color: $white-n; } .na { color: $white-na; } .nb { color: $white-nb; } -.nc { color: $white-nc; font-weight: $gl-font-weight-bold; } + +.nc { color: $white-nc; + font-weight: $gl-font-weight-bold; } .no { color: $white-no; } .ni { color: $white-ni; } -.ne { color: $white-ne; font-weight: $gl-font-weight-bold; } -.nf { color: $white-nf; font-weight: $gl-font-weight-bold; } + +.ne { color: $white-ne; + font-weight: $gl-font-weight-bold; } + +.nf { color: $white-nf; + font-weight: $gl-font-weight-bold; } .nn { color: $white-nn; } .nt { color: $white-nt; } .nv { color: $white-nv; } @@ -289,4 +313,6 @@ span.highlight_word { .vg { color: $white-vg; } .vi { color: $white-vi; } .il { color: $white-il; } -.gc { color: $white-gc-color; background-color: $white-gc-bg; } + +.gc { color: $white-gc-color; + background-color: $white-gc-bg; } diff --git a/app/assets/stylesheets/mailers/highlighted_diff_email.scss b/app/assets/stylesheets/mailers/highlighted_diff_email.scss index 8b234a5a656..33c114838c2 100644 --- a/app/assets/stylesheets/mailers/highlighted_diff_email.scss +++ b/app/assets/stylesheets/mailers/highlighted_diff_email.scss @@ -1,4 +1,4 @@ -@import "framework/variables"; +@import 'framework/variables'; // This file is largely copied from `highlight/white.scss`, but modified to // avoid all descendant selectors (`table td`). This is because the CSS inlining @@ -40,16 +40,16 @@ $highlighted-kt: #458; $highlighted-m: #099; $highlighted-s: #d14; $highlighted-n: #333; -$highlighted-na: teal; +$highlighted-na: #008080; $highlighted-nb: #0086b3; $highlighted-nc: #458; -$highlighted-no: teal; -$highlighted-ni: purple; +$highlighted-no: #008080; +$highlighted-ni: #800080; $highlighted-ne: #900; $highlighted-nf: #900; $highlighted-nn: #555; -$highlighted-nt: navy; -$highlighted-nv: teal; +$highlighted-nt: #000080; +$highlighted-nv: #008080; $highlighted-w: #bbb; $highlighted-mf: #099; $highlighted-mh: #099; @@ -67,9 +67,9 @@ $highlighted-sr: #009926; $highlighted-s1: #d14; $highlighted-ss: #990073; $highlighted-bp: #999; -$highlighted-vc: teal; -$highlighted-vg: teal; -$highlighted-vi: teal; +$highlighted-vc: #008080; +$highlighted-vg: #008080; +$highlighted-vi: #008080; $highlighted-il: #099; $highlighted-gc: #999; $highlighted-gc-bg: #eaf2f5; @@ -151,14 +151,27 @@ span.highlight_word { } .hll { background-color: $highlighted-hll-bg; } -.c { color: $highlighted-c; font-style: italic; } -.err { color: $highlighted-err; background-color: $highlighted-err-bg; } + +.c { color: $highlighted-c; + font-style: italic; } + +.err { color: $highlighted-err; + background-color: $highlighted-err-bg; } .k { font-weight: $gl-font-weight-bold; } .o { font-weight: $gl-font-weight-bold; } -.cm { color: $highlighted-cm; font-style: italic; } -.cp { color: $highlighted-cp; font-weight: $gl-font-weight-bold; } -.c1 { color: $highlighted-c1; font-style: italic; } -.cs { color: $highlighted-cs; font-weight: $gl-font-weight-bold; font-style: italic; } + +.cm { color: $highlighted-cm; + font-style: italic; } + +.cp { color: $highlighted-cp; + font-weight: $gl-font-weight-bold; } + +.c1 { color: $highlighted-c1; + font-style: italic; } + +.cs { color: $highlighted-cs; + font-weight: $gl-font-weight-bold; + font-style: italic; } .gd { color: $highlighted-gd; @@ -187,24 +200,34 @@ span.highlight_word { .go { color: $highlighted-go; } .gp { color: $highlighted-gp; } .gs { font-weight: $gl-font-weight-bold; } -.gu { color: $highlighted-gu; font-weight: $gl-font-weight-bold; } + +.gu { color: $highlighted-gu; + font-weight: $gl-font-weight-bold; } .gt { color: $highlighted-gt; } .kc { font-weight: $gl-font-weight-bold; } .kd { font-weight: $gl-font-weight-bold; } .kn { font-weight: $gl-font-weight-bold; } .kp { font-weight: $gl-font-weight-bold; } .kr { font-weight: $gl-font-weight-bold; } -.kt { color: $highlighted-kt; font-weight: $gl-font-weight-bold; } + +.kt { color: $highlighted-kt; + font-weight: $gl-font-weight-bold; } .m { color: $highlighted-m; } .s { color: $highlighted-s; } .n { color: $highlighted-n; } .na { color: $highlighted-na; } .nb { color: $highlighted-nb; } -.nc { color: $highlighted-nc; font-weight: $gl-font-weight-bold; } + +.nc { color: $highlighted-nc; + font-weight: $gl-font-weight-bold; } .no { color: $highlighted-no; } .ni { color: $highlighted-ni; } -.ne { color: $highlighted-ne; font-weight: $gl-font-weight-bold; } -.nf { color: $highlighted-nf; font-weight: $gl-font-weight-bold; } + +.ne { color: $highlighted-ne; + font-weight: $gl-font-weight-bold; } + +.nf { color: $highlighted-nf; + font-weight: $gl-font-weight-bold; } .nn { color: $highlighted-nn; } .nt { color: $highlighted-nt; } .nv { color: $highlighted-nv; } @@ -230,4 +253,6 @@ span.highlight_word { .vg { color: $highlighted-vg; } .vi { color: $highlighted-vi; } .il { color: $highlighted-il; } -.gc { color: $highlighted-gc; background-color: $highlighted-gc-bg; } + +.gc { color: $highlighted-gc; + background-color: $highlighted-gc-bg; } diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 98d0a2d43ea..a80158943c6 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -130,7 +130,7 @@ $ide-commit-header-height: 48px; background: none; border: 0; border-radius: $border-radius-default; - color: $theme-gray-900; + color: $gray-900; svg { position: relative; @@ -145,7 +145,7 @@ $ide-commit-header-height: 48px; } &:not([disabled]):hover { - background-color: $theme-gray-200; + background-color: $gray-200; } &:not([disabled]):focus { @@ -265,7 +265,7 @@ $ide-commit-header-height: 48px; .margin { background-color: $white-light; - border-right: 1px solid $theme-gray-100; + border-right: 1px solid $gray-100; .line-insert { border-right: 1px solid $line-added-dark; @@ -292,7 +292,7 @@ $ide-commit-header-height: 48px; .monaco-editor, .monaco-editor-background, .monaco-editor .inputarea.ime-input { - background-color: $theme-gray-50; + background-color: $gray-50; } } } @@ -395,6 +395,11 @@ $ide-commit-header-height: 48px; svg { vertical-align: sub; } + + .ide-status-avatar { + float: none; + margin: 0 0 1px; + } } .ide-status-file { @@ -527,7 +532,7 @@ $ide-commit-header-height: 48px; display: block; margin-left: auto; margin-right: auto; - color: $theme-gray-700; + color: $gray-700; } .file-status-icon { @@ -551,7 +556,7 @@ $ide-commit-header-height: 48px; &:hover, &:focus { - background: $theme-gray-100; + background: $gray-100; outline: 0; @@ -563,7 +568,7 @@ $ide-commit-header-height: 48px; } &:active { - background: $theme-gray-200; + background: $gray-200; } &.is-active { @@ -677,25 +682,6 @@ $ide-commit-header-height: 48px; flex: 1; } -.drag-handle { - position: absolute; - top: 0; - bottom: 0; - width: 4px; - - &:hover { - background-color: $white-normal; - } - - &.drag-right { - right: 0; - } - - &.drag-left { - left: 0; - } -} - .ide-commit-list-container { display: flex; flex: 1; @@ -767,12 +753,12 @@ $ide-commit-header-height: 48px; &:hover { color: $gl-text-color; - background-color: $theme-gray-100; + background-color: $gray-100; } &:focus { color: $gl-text-color; - background-color: $theme-gray-200; + background-color: $gray-200; } &.active { @@ -811,26 +797,6 @@ $ide-commit-header-height: 48px; z-index: 1; } -.ide-file-finder-overlay { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 100; -} - -.ide-file-finder { - top: 10px; - left: 50%; - transform: translateX(-50%); - - .highlighted { - color: $blue-500; - font-weight: $gl-font-weight-bold; - } -} - .ide-commit-message-field { height: 200px; background-color: $white-light; @@ -1273,10 +1239,10 @@ $ide-commit-header-height: 48px; .ide-entry-dropdown-toggle { padding: $gl-padding-4; color: $gl-text-color; - background-color: $theme-gray-100; + background-color: $gray-100; &:hover { - background-color: $theme-gray-200; + background-color: $gray-200; } &:active, @@ -1331,7 +1297,7 @@ $ide-commit-header-height: 48px; &:focus { outline: 0; box-shadow: none; - border-color: $theme-gray-200; + border-color: $gray-200; } } @@ -1358,7 +1324,7 @@ $ide-commit-header-height: 48px; .ide-commit-editor-header { height: 65px; padding: 8px 16px; - background-color: $theme-gray-50; + background-color: $gray-50; box-shadow: inset 0 -1px $white-dark; } @@ -1370,7 +1336,7 @@ $ide-commit-header-height: 48px; .ide-file-icon-holder { display: flex; align-items: center; - color: $theme-gray-700; + color: $gray-700; } .file-row:hover, diff --git a/app/assets/stylesheets/page_bundles/xterm.scss b/app/assets/stylesheets/page_bundles/xterm.scss index 7f040ac9b96..de3f2a1177d 100644 --- a/app/assets/stylesheets/page_bundles/xterm.scss +++ b/app/assets/stylesheets/page_bundles/xterm.scss @@ -6,11 +6,11 @@ $black: #000; $red: #ea1010; - $green: #009900; - $yellow: #999900; + $green: #090; + $yellow: #990; $blue: #0073e6; $magenta: #d411d4; - $cyan: #009999; + $cyan: #099; $white: #ccc; $l-black: #373b41; $l-red: #ff6161; diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 37984a8666f..81216b2b98e 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -3,7 +3,6 @@ } .user-can-drag { - cursor: -webkit-grab; cursor: grab; } @@ -12,12 +11,8 @@ opacity: 1 !important; * { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; user-select: none; // !important to make sure no style can override this when dragging - cursor: -webkit-grabbing !important; cursor: grabbing !important; } } @@ -26,13 +21,15 @@ opacity: 0.3; } +.dropdown-projects { + .dropdown-content { + max-height: 200px; + } +} + .dropdown-menu-issues-board-new { width: 320px; - .open & { - max-height: 400px; - } - .dropdown-content { max-height: 162px; } @@ -162,6 +159,13 @@ display: none; } } + + &:not(.is-collapsed) { + .board-list-component { + display: flex; + flex-direction: column; + } + } } .board-inner { @@ -171,6 +175,7 @@ background: $gray-light; border: 1px solid $border-color; border-radius: $border-radius-default; + flex: 1; } .board-header { @@ -232,9 +237,11 @@ } .board-blank-state { - height: calc(100% - 49px); padding: $gl-padding; background-color: $white-light; + flex: 1; + overflow-y: auto; + overflow-x: hidden; } .board-blank-state-list { @@ -256,9 +263,9 @@ } .board-list-component { - height: calc(100% - 49px); - overflow: hidden; position: relative; + flex: 1; + min-height: 0; // firefox fix } .board-list { @@ -281,7 +288,7 @@ padding: $gl-padding; background: $white-light; border-radius: $border-radius-default; - border: 1px solid $theme-gray-200; + border: 1px solid $gray-200; box-shadow: 0 1px 2px $issue-boards-card-shadow; list-style: none; line-height: $gl-padding; @@ -489,7 +496,6 @@ } .add-issues-modal { - display: -webkit-flex; display: flex; position: fixed; top: 0; @@ -501,9 +507,7 @@ } .add-issues-container { - display: -webkit-flex; display: flex; - -webkit-flex-direction: column; flex-direction: column; width: 90vw; height: 85vh; @@ -516,16 +520,12 @@ box-shadow: 0 2px 12px rgba($black, 0.5); .empty-state { - display: -webkit-flex; display: flex; - -webkit-flex: 1; flex: 1; margin-top: 0; &.add-issues-empty-state-filter { - -webkit-flex-direction: column; flex-direction: column; - -webkit-justify-content: center; justify-content: center; } @@ -554,11 +554,9 @@ } .add-issues-search { - display: -webkit-flex; display: flex; .issues-filters { - -webkit-flex: 1; flex: 1; } } @@ -576,9 +574,7 @@ } .add-issues-list { - display: -webkit-flex; display: flex; - -webkit-flex: 1; flex: 1; padding-top: 3px; margin-left: -$gl-vert-padding; @@ -597,7 +593,6 @@ } .add-issues-list-loading { - -webkit-align-self: center; align-self: center; width: 100%; padding-left: $gl-vert-padding; @@ -673,7 +668,7 @@ } .board-card-info-icon { - color: $theme-gray-600; + color: $gray-600; margin-right: $gl-padding-4; } diff --git a/app/assets/stylesheets/pages/branches.scss b/app/assets/stylesheets/pages/branches.scss index 38fec3f0aa8..ce0622b3d48 100644 --- a/app/assets/stylesheets/pages/branches.scss +++ b/app/assets/stylesheets/pages/branches.scss @@ -11,15 +11,24 @@ } .divergence-graph { + $graph-side-width: 80px; + $graph-separator-width: 1px; + padding: 0 6px; .graph-side { position: relative; - width: 80px; + width: $graph-side-width; height: 22px; padding: 5px 0 13px; float: left; + &.full { + width: $graph-side-width * 2 + $graph-separator-width; + display: flex; + justify-content: center; + } + .bar { position: absolute; height: 4px; @@ -57,7 +66,7 @@ .graph-separator { position: relative; - width: 1px; + width: $graph-separator-width; height: 18px; margin: 5px 0 0; float: left; diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 09235661cea..fa5a182243c 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -55,16 +55,16 @@ @include build-trace(); } - .archived-sticky { + .archived-job { top: $header-height; border-radius: 2px 2px 0 0; color: $orange-600; background-color: $orange-100; border: 1px solid $border-gray-normal; - border-bottom: 0; padding: 3px 12px; margin: auto; align-items: center; + z-index: 1; .with-performance-bar & { top: $header-height + $performance-bar-height; @@ -75,7 +75,11 @@ @include build-trace-top-bar(35px); &.has-archived-block { - top: $header-height + $performance-bar-height + 28px; + top: $header-height + 28px; + + .with-performance-bar & { + top: $header-height + $performance-bar-height + 28px; + } } &.affix { @@ -261,7 +265,7 @@ .trigger-variables-table-cell { font-size: $gl-font-size-small; line-height: $gl-line-height; - border: 1px solid $theme-gray-200; + border: 1px solid $gray-200; padding: $gl-padding-4 6px; width: 50%; vertical-align: top; @@ -272,7 +276,13 @@ } .retry-link { - display: none; + display: block; + + .btn { + i { + margin-left: 5px; + } + } .btn-inverted-secondary { color: $blue-500; @@ -281,16 +291,6 @@ color: $white-light; } } - - @include media-breakpoint-down(sm) { - display: block; - - .btn { - i { - margin-left: 5px; - } - } - } } .stage-item { diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index ad12cd101b6..809ba6d4953 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -58,6 +58,20 @@ } } +.cluster-application-banner { + height: 45px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.cluster-application-banner-close { + align-self: flex-start; + font-weight: 500; + font-size: 20px; + margin: $gl-padding-8 14px 0 0; +} + .cluster-application-description { flex: 1; } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 11966931a6c..670e320dbc2 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -342,11 +342,11 @@ } &.invalid { - @include status-color($gray-dark, color("gray"), $gray-darkest); + @include status-color($gray-dark, color('gray'), $gray-darkest); border-color: $gray-darkest; &:not(span):hover { - color: color("gray"); + color: color('gray'); } } } diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index ec2108b15be..2b932d164a5 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -330,7 +330,6 @@ // Custom Styles for stage items .item-build-component { - .item-title { .icon-build-status { float: left; diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss index 37ed5ae674a..cb5f1a84005 100644 --- a/app/assets/stylesheets/pages/detail_page.scss +++ b/app/assets/stylesheets/pages/detail_page.scss @@ -34,7 +34,6 @@ .detail-page-header-actions { align-self: center; - flex-shrink: 0; flex: 0 0 auto; @include media-breakpoint-down(xs) { diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 18c62cb4f1e..0dbbe9e4c25 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -7,21 +7,12 @@ cursor: pointer; @media (min-width: map-get($grid-breakpoints, md)) { - position: -webkit-sticky; + $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height; + position: sticky; - top: 92px; - margin-left: -1px; - border-left: 1px solid $border-color; + top: $mr-file-header-top; z-index: 102; - &.is-commit { - top: $header-height + 36px; - - .with-performance-bar & { - top: $header-height + 36px + $performance-bar-height; - } - } - &::before { content: ''; position: absolute; @@ -34,7 +25,23 @@ } .with-performance-bar & { - top: 127px; + top: $mr-file-header-top + $performance-bar-height; + } + + &.is-commit { + top: $header-height + $commit-stat-summary-height; + + .with-performance-bar & { + top: $header-height + $commit-stat-summary-height + $performance-bar-height; + } + } + + &.is-compare { + top: $header-height + $compare-branches-sticky-header-height; + + .with-performance-bar & { + top: $performance-bar-height + $header-height + $compare-branches-sticky-header-height; + } } } @@ -231,22 +238,18 @@ img { border: 1px solid $white-light; - background-image: linear-gradient( - 45deg, - $border-color 25%, - transparent 25%, - transparent 75%, - $border-color 75%, - $border-color 100% - ), - linear-gradient( - 45deg, - $border-color 25%, - transparent 25%, - transparent 75%, - $border-color 75%, - $border-color 100% - ); + background-image: linear-gradient(45deg, + $border-color 25%, + transparent 25%, + transparent 75%, + $border-color 75%, + $border-color 100%), + linear-gradient(45deg, + $border-color 25%, + transparent 25%, + transparent 75%, + $border-color 75%, + $border-color 100%); background-size: 10px 10px; background-position: 0 0, 5px 5px; max-width: 100%; @@ -500,6 +503,25 @@ } } +.diff-stats { + align-items: center; + padding: 0 0.25rem; + + .diff-stats-group { + padding: 0 0.25rem; + } + + svg.diff-stats-icon { + vertical-align: text-bottom; + } + + &.is-compare-versions-header { + .diff-stats-group { + padding: 0 0.5rem; + } + } +} + .file-content .diff-file { margin: 0; border: 0; @@ -575,18 +597,6 @@ } } -@mixin diff_background($background, $idiff, $border) { - background: $background; - - &.line_content span.idiff { - background: $idiff; - } - - &.diff-line-num { - border-color: $border; - } -} - .files { .diff-file:last-child { margin-bottom: 0; @@ -711,7 +721,6 @@ } @include media-breakpoint-up(sm) { - position: -webkit-sticky; position: sticky; top: $header-height; background-color: $white-light; @@ -819,34 +828,26 @@ width: 100%; height: 10px; background-color: $white-light; - background-image: linear-gradient( - 45deg, - transparent, - transparent 73%, - $diff-jagged-border-gradient-color 75%, - $white-light 80% - ), - linear-gradient( - 225deg, - transparent, - transparent 73%, - $diff-jagged-border-gradient-color 75%, - $white-light 80% - ), - linear-gradient( - 135deg, - transparent, - transparent 73%, - $diff-jagged-border-gradient-color 75%, - $white-light 80% - ), - linear-gradient( - -45deg, - transparent, - transparent 73%, - $diff-jagged-border-gradient-color 75%, - $white-light 80% - ); + background-image: linear-gradient(45deg, + transparent, + transparent 73%, + $diff-jagged-border-gradient-color 75%, + $white-light 80%), + linear-gradient(225deg, + transparent, + transparent 73%, + $diff-jagged-border-gradient-color 75%, + $white-light 80%), + linear-gradient(135deg, + transparent, + transparent 73%, + $diff-jagged-border-gradient-color 75%, + $white-light 80%), + linear-gradient(-45deg, + transparent, + transparent 73%, + $diff-jagged-border-gradient-color 75%, + $white-light 80%); background-position: 5px 5px, 0 5px, 0 5px, 5px 5px; background-size: 10px 10px; background-repeat: repeat; @@ -889,7 +890,7 @@ } } -.files:not([data-can-create-note="true"]) .frame { +.files:not([data-can-create-note='true']) .frame { cursor: auto; } @@ -898,15 +899,14 @@ .btn-transparent.image-diff-overlay-add-comment { position: relative; cursor: image-url('illustrations/image_comment_light_cursor.svg') - $image-comment-cursor-left-offset $image-comment-cursor-top-offset, + $image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; // Retina cursor - cursor: -webkit-image-set( - image-url('illustrations/image_comment_light_cursor.svg') 1x, - image-url('illustrations/image_comment_light_cursor@2x.svg') 2x - ) - $image-comment-cursor-left-offset $image-comment-cursor-top-offset, + // scss-lint:disable DuplicateProperty + cursor: image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x, + image-url('illustrations/image_comment_light_cursor@2x.svg') 2x) + $image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; .comment-indicator { @@ -1011,12 +1011,29 @@ } .diff-tree-list { - width: 320px; + position: sticky; + $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px; + top: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px; + max-height: calc(100vh - #{$top-pos}); + padding-right: $gl-padding; + z-index: 202; + + .with-performance-bar & { + $performance-bar-top-pos: $performance-bar-height + $top-pos; + top: $performance-bar-top-pos; + max-height: calc(100vh - #{$performance-bar-top-pos}); + } + + .drag-handle { + bottom: 16px; + transform: translateX(-6px); + } } .diff-files-holder { flex: 1; min-width: 0; + z-index: 201; } .compare-versions-container { @@ -1024,21 +1041,12 @@ } .tree-list-holder { - position: -webkit-sticky; - position: sticky; - top: 100px; - max-height: calc(100vh - 100px); - padding-right: $gl-padding; + height: 100%; .file-row { margin-left: 0; margin-right: 0; } - - .with-performance-bar & { - top: 135px; - max-height: calc(100vh - 135px); - } } .tree-list-scroll { @@ -1092,12 +1100,6 @@ } } -.tree-list-view-toggle { - svg { - top: 0; - } -} - .image-diff-overlay, .image-diff-overlay-add-comment { top: 0; diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index f46ff360496..655b297295a 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -128,6 +128,10 @@ width: 100%; } } + + @media(max-width: map-get($grid-breakpoints, md)-1) { + clear: both; + } } .editor-ref { @@ -178,9 +182,8 @@ .template-selector-dropdowns-wrap { display: inline-block; - margin-left: 8px; - vertical-align: top; margin: 5px 0 0 8px; + vertical-align: top; @media(max-width: map-get($grid-breakpoints, md)-1) { display: block; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 75166ffcada..61ecf133b02 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -240,18 +240,7 @@ } .prometheus-graph { - flex: 1 0 auto; - min-width: 450px; - max-width: 100%; padding: $gl-padding / 2; - - h5 { - font-size: 16px; - } - - @include media-breakpoint-down(sm) { - min-width: 100%; - } } .prometheus-graph-header { @@ -261,13 +250,14 @@ margin-bottom: $gl-padding-8; h5 { + font-size: $gl-font-size-large; margin: 0; } } .prometheus-graph-cursor { position: absolute; - background: $theme-gray-600; + background: $gray-600; width: 1px; } @@ -309,7 +299,7 @@ > .arrow::after { border-top: 6px solid transparent; border-bottom: 6px solid transparent; - border-left: 4px solid $theme-gray-50; + border-left: 4px solid $gray-50; } .arrow-shadow { @@ -331,7 +321,7 @@ > .arrow::after { border-top: 6px solid transparent; border-bottom: 6px solid transparent; - border-right: 4px solid $theme-gray-50; + border-right: 4px solid $gray-50; } .arrow-shadow { @@ -364,7 +354,7 @@ } > .popover-title { - background-color: $theme-gray-50; + background-color: $gray-50; border-radius: $border-radius-default $border-radius-default 0 0; } } @@ -439,7 +429,7 @@ } > text { - fill: $theme-gray-600; + fill: $gray-600; font-size: 10px; } } @@ -482,5 +472,5 @@ } .prometheus-table-row-highlight { - background-color: $theme-gray-100; + background-color: $gray-100; } diff --git a/app/assets/stylesheets/pages/graph.scss b/app/assets/stylesheets/pages/graph.scss index 4fb1a956fab..3febf4cf826 100644 --- a/app/assets/stylesheets/pages/graph.scss +++ b/app/assets/stylesheets/pages/graph.scss @@ -44,7 +44,7 @@ } .x-axis-text { - fill: $theme-gray-900; + fill: $gray-900; } .bar-rect { @@ -64,19 +64,17 @@ text { font-weight: bold; font-size: 12px; - fill: $theme-gray-800; + fill: $gray-800; } } } .svg-graph-container-with-grab { cursor: grab; - cursor: -webkit-grab; } .svg-graph-container-grabbed { cursor: grabbing; - cursor: -webkit-grabbing; } @keyframes flickerAnimation { @@ -87,5 +85,5 @@ .animate-flicker { animation: flickerAnimation 1.5s infinite; - fill: $theme-gray-500; + fill: $gray-500; } diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index f0cb81e0bc3..8ade995525a 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -29,9 +29,7 @@ } } -.group-nav-container .group-search, .group-nav-container .nav-controls { - display: flex; align-items: flex-start; padding: $gl-padding-top 0 0; @@ -44,6 +42,52 @@ margin-top: 0; } + @include media-breakpoint-down(sm) { + .dropdown, + .dropdown .dropdown-toggle, + .btn-success { + display: block; + } + + .group-filter-form, + .dropdown { + margin-bottom: 10px; + margin-right: 0; + } + + &, + .group-filter-form, + .group-filter-form-field, + .dropdown, + .dropdown .dropdown-toggle, + .btn-success { + width: 100%; + } + + .dropdown .dropdown-toggle .fa-chevron-down { + position: absolute; + top: 11px; + right: 8px; + } + } +} + +.home-panel-buttons { + .home-panel-action-button { + vertical-align: top; + } + + + .notification-dropdown { + .dropdown-menu { + @extend .dropdown-menu-right; + } + + .icon { + fill: $gl-text-color-secondary; + } + } + .new-project-subgroup { .dropdown-primary { min-width: 115px; @@ -78,7 +122,7 @@ &:hover { background-color: $gray-darker; - color: $theme-gray-900; + color: $gray-900; } } @@ -99,61 +143,29 @@ font-weight: $gl-font-weight-bold; } } - } - } - - @include media-breakpoint-down(sm) { - &, - .dropdown, - .dropdown .dropdown-toggle, - .btn-success { - display: block; - } - .group-filter-form, - .dropdown { - margin-bottom: 10px; - margin-right: 0; - } - - .group-filter-form, - .dropdown .dropdown-toggle, - .btn-success { - width: 100%; - } - - .dropdown .dropdown-toggle .fa-chevron-down { - position: absolute; - top: 11px; - right: 8px; - } - - .new-project-subgroup { - display: flex; - align-items: flex-start; + @include media-breakpoint-down(sm) { + display: flex; + align-items: flex-start; - .dropdown-primary { - flex: 1; - } + .dropdown-primary { + flex: 1; + } - .dropdown-toggle { - width: auto; - } + .dropdown-toggle { + width: auto; + } - .dropdown-menu { - width: 100%; - max-width: inherit; - min-width: inherit; + .dropdown-menu { + width: 100%; + max-width: inherit; + min-width: inherit; + } } } } } -.group-nav-container .group-search { - padding: $gl-padding 0; - border-bottom: 1px solid $border-color; -} - .groups-listing { .group-list-tree .group-row:first-child { border-top: 0; diff --git a/app/assets/stylesheets/pages/help.scss b/app/assets/stylesheets/pages/help.scss index 2c23f31c240..161d4dbfb22 100644 --- a/app/assets/stylesheets/pages/help.scss +++ b/app/assets/stylesheets/pages/help.scss @@ -30,7 +30,7 @@ .key { @extend .badge.badge-pill; background-color: $label-inverse-bg; - font: 11px Consolas, "Liberation Mono", Menlo, Courier, monospace; + font: 11px Consolas, 'Liberation Mono', Menlo, Courier, monospace; padding: 3px 5px; } } diff --git a/app/assets/stylesheets/pages/import.scss b/app/assets/stylesheets/pages/import.scss index a4f76a9495a..7f800367cad 100644 --- a/app/assets/stylesheets/pages/import.scss +++ b/app/assets/stylesheets/pages/import.scss @@ -1,20 +1,51 @@ -.import-jobs-from-col, .import-jobs-to-col { - width: 40%; + width: 39%; } .import-jobs-status-col { - width: 20%; + width: 15%; } -.btn-import { - .loading-icon { - display: none; +.import-jobs-cta-col { + width: 1%; +} + +.import-project-name-input { + border-radius: 0 $border-radius-default $border-radius-default 0; + position: relative; + left: -1px; + max-width: 300px; +} + +.import-namespace-select { + width: auto !important; + + > .select2-choice { + border-radius: $border-radius-default 0 0 $border-radius-default; + position: relative; + left: 1px; } +} - &.is-loading { - .loading-icon { - display: inline-block; - } +.import-slash-divider { + background-color: $gray-lightest; + border: 1px solid $border-color; +} + +.import-row { + height: 55px; +} + +.import-table { + .import-jobs-from-col, + .import-jobs-to-col, + .import-jobs-status-col, + .import-jobs-cta-col { + border-bottom-width: 1px; + padding-left: $gl-padding; } } + +.import-projects-loading-icon { + margin-top: $gl-padding-32; +} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 5b5f486ea63..623fa485ba6 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -22,6 +22,7 @@ .detail-page-header, .page-content-header, .commit-box, + .info-well, .commit-ci-menu, .files-changed-inner, .limited-header-width, @@ -60,6 +61,10 @@ padding: 0; margin-bottom: $gl-padding; border-bottom: 0; + word-wrap: break-word; + overflow-wrap: break-word; + min-width: 0; + width: 100%; } .btn-edit { @@ -132,7 +137,7 @@ color: $blue-800; .avatar { - border-color: rgba($gray-normal, .2); + border-color: rgba($gray-normal, 0.2); } } @@ -219,7 +224,7 @@ } a.edit-link:not([href]):hover { - color: rgba($gray-normal, .2); + color: rgba($gray-normal, 0.2); } .lock-edit, // uses same style, different js behaviour @@ -707,14 +712,11 @@ .issuable-list { li { - .issue-box { - display: -webkit-flex; display: flex; } .issuable-info-container { - -webkit-flex: 1; flex: 1; display: flex; padding-right: $gl-padding; @@ -809,7 +811,6 @@ .sidebar-collapsed-icon { - > .stopwatch-svg { display: inline-block; } @@ -867,11 +868,11 @@ } .help-state-toggle-enter-active { - transition: all .8s ease; + transition: all 0.8s ease; } .help-state-toggle-leave-active { - transition: all .5s ease; + transition: all 0.5s ease; } .help-state-toggle-enter, @@ -930,7 +931,7 @@ .sidebar-collapsed-divider { line-height: 5px; font-size: 12px; - color: $theme-gray-700; + color: $gray-700; + .sidebar-collapsed-icon { padding-top: 0; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index bb6b6f84849..9f30495a7ef 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -58,8 +58,6 @@ form.edit-issue { } ul.related-merge-requests > li { - display: -ms-flexbox; - display: -webkit-flex; display: flex; align-items: center; @@ -80,7 +78,6 @@ ul.related-merge-requests > li { } } -.merge-requests-title, .related-branches-title { font-size: 16px; font-weight: $gl-font-weight-bold; @@ -91,23 +88,16 @@ ul.related-merge-requests > li { } .merge-request-status { - font-size: 13px; - padding: 0 5px; - color: $white-light; - height: 20px; - border-radius: 3px; - line-height: 18px; - &.merged { - background: $blue-500; + color: $blue-500; } &.closed { - background: $red-500; + color: $red-500; } &.open { - background: $green-500; + color: $green-500; } } @@ -155,6 +145,14 @@ ul.related-merge-requests > li { } } +.issues-nav-controls { + font-size: 0; + + .btn-group:empty { + display: none; + } +} + .issuable-email-modal-btn { padding: 0; color: $blue-600; diff --git a/app/assets/stylesheets/pages/issues/issue_count_badge.scss b/app/assets/stylesheets/pages/issues/issue_count_badge.scss index 4fba89e956b..64ca61f7094 100644 --- a/app/assets/stylesheets/pages/issues/issue_count_badge.scss +++ b/app/assets/stylesheets/pages/issues/issue_count_badge.scss @@ -1,11 +1,13 @@ -.issue-count-badge { +.issue-count-badge, +.mr-count-badge { display: inline-flex; border-radius: $border-radius-base; border: 1px solid $border-color; padding: 5px $gl-padding-8; } -.issue-count-badge-count { +.issue-count-badge-count, +.mr-count-badge-count { display: inline-flex; align-items: center; } diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index d2b9470be69..75d219320ef 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -82,7 +82,7 @@ justify-content: space-between; padding: $gl-padding; border-radius: $border-radius-default; - border: 1px solid $theme-gray-100; + border: 1px solid $gray-100; &:last-child { margin-bottom: 0; @@ -95,13 +95,11 @@ .prioritized-labels & { box-shadow: 0 1px 2px $issue-boards-card-shadow; cursor: move; - cursor: -webkit-grab; - cursor: -moz-grab; + cursor: grab; border: 0; &:active { - cursor: -webkit-grabbing; - cursor: -moz-grabbing; + cursor: grabbing; } } } @@ -257,7 +255,7 @@ } .label-badge { - color: $theme-gray-900; + color: $gray-900; font-weight: $gl-font-weight-normal; padding: $gl-padding-4 $gl-padding-8; border-radius: $border-radius-default; @@ -269,7 +267,7 @@ } .label-badge-gray { - background-color: $theme-gray-100; + background-color: $gray-100; } .label-links { @@ -326,11 +324,11 @@ } .label-action { - color: $theme-gray-800; + color: $gray-800; cursor: pointer; svg { - fill: $theme-gray-800; + fill: $gray-800; } &:hover { diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index 67d7a8175ac..22a515cbdaa 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -120,7 +120,6 @@ } .new-session-tabs { - display: -webkit-flex; display: flex; box-shadow: 0 0 0 1px $border-color; border-top-right-radius: $border-radius-default; @@ -190,7 +189,7 @@ margin-top: 16px; } - input[type="submit"] { + input[type='submit'] { @extend .btn-block; margin-bottom: 0; } diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index 99609a96976..eb32beb0972 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -27,7 +27,6 @@ .controls { @include media-breakpoint-up(sm) { - display: -webkit-flex; display: flex; } diff --git a/app/assets/stylesheets/pages/merge_conflicts.scss b/app/assets/stylesheets/pages/merge_conflicts.scss index e0f7d075fc7..278a9014458 100644 --- a/app/assets/stylesheets/pages/merge_conflicts.scss +++ b/app/assets/stylesheets/pages/merge_conflicts.scss @@ -20,81 +20,81 @@ $colors: ( white-header-not-chosen : #f0f0f0, white-line-not-chosen : $gray-light, - dark-header-head-neutral : rgba(#3f3, .2), - dark-line-head-neutral : rgba(#3f3, .1), + dark-header-head-neutral : rgba(#3f3, 0.2), + dark-line-head-neutral : rgba(#3f3, 0.1), dark-button-head-neutral : #40874f, - dark-header-head-chosen : rgba(#3f3, .33), - dark-line-head-chosen : rgba(#3f3, .2), + dark-header-head-chosen : rgba(#3f3, 0.33), + dark-line-head-chosen : rgba(#3f3, 0.2), dark-button-head-chosen : #258537, - dark-header-origin-neutral : rgba(#2878c9, .4), - dark-line-origin-neutral : rgba(#2878c9, .3), + dark-header-origin-neutral : rgba(#2878c9, 0.4), + dark-line-origin-neutral : rgba(#2878c9, 0.3), dark-button-origin-neutral : #2a5c8c, - dark-header-origin-chosen : rgba(#2878c9, .6), - dark-line-origin-chosen : rgba(#2878c9, .4), + dark-header-origin-chosen : rgba(#2878c9, 0.6), + dark-line-origin-chosen : rgba(#2878c9, 0.4), dark-button-origin-chosen : #1d6cbf, - dark-header-not-chosen : rgba(#fff, .25), - dark-line-not-chosen : rgba(#fff, .1), + dark-header-not-chosen : rgba(#fff, 0.25), + dark-line-not-chosen : rgba(#fff, 0.1), - monokai-header-head-neutral : rgba(#a6e22e, .25), - monokai-line-head-neutral : rgba(#a6e22e, .1), + monokai-header-head-neutral : rgba(#a6e22e, 0.25), + monokai-line-head-neutral : rgba(#a6e22e, 0.1), monokai-button-head-neutral : #376b20, - monokai-header-head-chosen : rgba(#a6e22e, .4), - monokai-line-head-chosen : rgba(#a6e22e, .25), + monokai-header-head-chosen : rgba(#a6e22e, 0.4), + monokai-line-head-chosen : rgba(#a6e22e, 0.25), monokai-button-head-chosen : #39800d, - monokai-header-origin-neutral : rgba(#60d9f1, .35), - monokai-line-origin-neutral : rgba(#60d9f1, .15), + monokai-header-origin-neutral : rgba(#60d9f1, 0.35), + monokai-line-origin-neutral : rgba(#60d9f1, 0.15), monokai-button-origin-neutral : #38848c, - monokai-header-origin-chosen : rgba(#60d9f1, .5), - monokai-line-origin-chosen : rgba(#60d9f1, .35), + monokai-header-origin-chosen : rgba(#60d9f1, 0.5), + monokai-line-origin-chosen : rgba(#60d9f1, 0.35), monokai-button-origin-chosen : #3ea4b2, - monokai-header-not-chosen : rgba(#76715d, .24), - monokai-line-not-chosen : rgba(#76715d, .1), + monokai-header-not-chosen : rgba(#76715d, 0.24), + monokai-line-not-chosen : rgba(#76715d, 0.1), - solarized-light-header-head-neutral : rgba(#859900, .37), - solarized-light-line-head-neutral : rgba(#859900, .2), + solarized-light-header-head-neutral : rgba(#859900, 0.37), + solarized-light-line-head-neutral : rgba(#859900, 0.2), solarized-light-button-head-neutral : #afb262, - solarized-light-header-head-chosen : rgba(#859900, .5), - solarized-light-line-head-chosen : rgba(#859900, .37), + solarized-light-header-head-chosen : rgba(#859900, 0.5), + solarized-light-line-head-chosen : rgba(#859900, 0.37), solarized-light-button-head-chosen : #94993d, - solarized-light-header-origin-neutral : rgba(#2878c9, .37), - solarized-light-line-origin-neutral : rgba(#2878c9, .15), + solarized-light-header-origin-neutral : rgba(#2878c9, 0.37), + solarized-light-line-origin-neutral : rgba(#2878c9, 0.15), solarized-light-button-origin-neutral : #60a1bf, - solarized-light-header-origin-chosen : rgba(#2878c9, .6), - solarized-light-line-origin-chosen : rgba(#2878c9, .37), + solarized-light-header-origin-chosen : rgba(#2878c9, 0.6), + solarized-light-line-origin-chosen : rgba(#2878c9, 0.37), solarized-light-button-origin-chosen : #2482b2, - solarized-light-header-not-chosen : rgba(#839496, .37), - solarized-light-line-not-chosen : rgba(#839496, .2), + solarized-light-header-not-chosen : rgba(#839496, 0.37), + solarized-light-line-not-chosen : rgba(#839496, 0.2), - solarized-dark-header-head-neutral : rgba(#859900, .35), - solarized-dark-line-head-neutral : rgba(#859900, .15), + solarized-dark-header-head-neutral : rgba(#859900, 0.35), + solarized-dark-line-head-neutral : rgba(#859900, 0.15), solarized-dark-button-head-neutral : #376b20, - solarized-dark-header-head-chosen : rgba(#859900, .5), - solarized-dark-line-head-chosen : rgba(#859900, .35), + solarized-dark-header-head-chosen : rgba(#859900, 0.5), + solarized-dark-line-head-chosen : rgba(#859900, 0.35), solarized-dark-button-head-chosen : #39800d, - solarized-dark-header-origin-neutral : rgba(#2878c9, .35), - solarized-dark-line-origin-neutral : rgba(#2878c9, .15), + solarized-dark-header-origin-neutral : rgba(#2878c9, 0.35), + solarized-dark-line-origin-neutral : rgba(#2878c9, 0.15), solarized-dark-button-origin-neutral : #086799, - solarized-dark-header-origin-chosen : rgba(#2878c9, .6), - solarized-dark-line-origin-chosen : rgba(#2878c9, .35), + solarized-dark-header-origin-chosen : rgba(#2878c9, 0.6), + solarized-dark-line-origin-chosen : rgba(#2878c9, 0.35), solarized-dark-button-origin-chosen : #0082cc, - solarized_dark_header_not_chosen : rgba(#839496, .25), - solarized_dark_line_not_chosen : rgba(#839496, .15), + solarized_dark_header_not_chosen : rgba(#839496, 0.25), + solarized_dark_line_not_chosen : rgba(#839496, 0.15), none_header_head_neutral : $gray-normal, none_line_head_neutral : $gray-normal, @@ -210,26 +210,20 @@ $colors: ( } #conflicts { - .white { - @include color-scheme('white') - } + @include color-scheme('white'); } .dark { - @include color-scheme('dark') - } + @include color-scheme('dark'); } .monokai { - @include color-scheme('monokai') - } + @include color-scheme('monokai'); } .solarized-light { - @include color-scheme('solarized-light') - } + @include color-scheme('solarized-light'); } .solarized-dark { - @include color-scheme('solarized-dark') - } + @include color-scheme('solarized-dark'); } .diff-wrap-lines .line_content { white-space: normal; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 221b4e934ff..e73d1a1289d 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -38,9 +38,7 @@ } .mr-widget-section { - .media { - align-items: center; - } + border-radius: $border-radius-default $border-radius-default 0 0; .code-text { flex: 1; @@ -56,6 +54,11 @@ .mr-widget-extension { border-top: 1px solid $border-color; background-color: $gray-light; + + &.clickable:hover { + background-color: $gl-gray-200; + cursor: pointer; + } } .mr-widget-workflow { @@ -64,7 +67,7 @@ &::before { content: ''; - border-left: 1px solid $theme-gray-200; + border-left: 1px solid $gray-200; position: absolute; left: 32px; top: -17px; @@ -78,7 +81,7 @@ border-top: 0; } -.mr-widget-section, +.mr-widget-body, .mr-widget-content, .mr-widget-footer { padding: $gl-padding; @@ -87,11 +90,38 @@ .mr-state-widget { color: $gl-text-color; + .commit-message-edit { + border-radius: $border-radius-default; + } + .mr-widget-section, .mr-widget-footer { border-top: solid 1px $border-color; } + .mr-fast-forward-message { + padding-left: $gl-padding-50; + padding-bottom: $gl-padding; + } + + .commits-list { + > li { + padding: $gl-padding; + + @include media-breakpoint-up(md) { + padding-left: $gl-padding-50; + } + } + } + + .mr-commit-dropdown { + .dropdown-menu { + @include media-breakpoint-up(md) { + width: 150%; + } + } + } + .mr-widget-footer { padding: 0; } @@ -405,7 +435,7 @@ } .mr-widget-help { - padding: 10px 16px 10px 48px; + padding: 10px 16px 10px $gl-padding-50; font-style: italic; } @@ -423,10 +453,6 @@ } } -.mr-widget-body-controls { - flex-wrap: wrap; -} - .mr_source_commit, .mr_target_commit { margin-bottom: 0; @@ -465,11 +491,9 @@ .merge-request { padding: 10px 0 10px 15px; position: relative; - display: -webkit-flex; display: flex; .issuable-info-container { - -webkit-flex: 1; flex: 1; } @@ -708,8 +732,11 @@ .mr-version-controls { position: relative; + z-index: 203; background: $gray-light; color: $gl-text-color; + margin-top: -1px; + border-top: 1px solid $border-color; .mr-version-menus-container { display: flex; @@ -755,17 +782,35 @@ color: $orange-500; padding-right: 5px; } + + @include media-breakpoint-up(md) { + position: sticky; + top: $header-height + $mr-tabs-height; + width: 100%; + + &.is-fileTreeOpen { + margin-left: -16px; + width: calc(100% + 32px); + } + + .mr-version-menus-container { + flex-wrap: nowrap; + } + + .with-performance-bar & { + top: $header-height + $performance-bar-height + $mr-tabs-height; + } + } } .merge-request-tabs-holder { top: $header-height; - z-index: 200; + z-index: 300; background-color: $white-light; border-bottom: 1px solid $border-color; @include media-breakpoint-up(sm) { position: sticky; - position: -webkit-sticky; } &.affix { @@ -816,7 +861,7 @@ display: flex; justify-content: space-between; - @include media-breakpoint-down(md) { + @include media-breakpoint-down(sm) { flex-direction: column-reverse; } @@ -907,7 +952,7 @@ } .btn svg { - fill: $theme-gray-700; + fill: $gray-700; } .dropdown-menu { @@ -951,7 +996,7 @@ .coverage { font-size: 12px; - color: $theme-gray-700; + color: $gray-700; line-height: initial; } @@ -961,3 +1006,14 @@ width: $ci-action-icon-size-lg; } } + +.merge-request-details .file-finder-overlay.diff-file-finder { + position: fixed; + z-index: 99999; + background: $black-transparent; +} + +.source-branch-removal-status { + padding-left: 50px; + padding-bottom: $gl-padding; +} diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index 1e92582d6d9..3ca8e943a3a 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -1,3 +1,5 @@ +$status-box-line-height: 26px; + .issues-sortable-list .str-truncated { max-width: 90%; } @@ -6,7 +8,7 @@ padding: $gl-padding-8; margin-top: $gl-padding-8; border-radius: $border-radius-default; - background-color: $theme-gray-100; + background-color: $gray-100; .milestone { border: 0; @@ -38,6 +40,7 @@ font-size: $tooltip-font-size; margin-top: 0; margin-right: $gl-padding-4; + line-height: $status-box-line-height; @include media-breakpoint-down(xs) { line-height: unset; @@ -64,18 +67,14 @@ .card-header { line-height: $line-height-base; padding: 14px 16px; - display: -webkit-flex; display: flex; .title { - -webkit-flex: 1; - -webkit-flex-grow: 1; flex: 1; flex-grow: 2; } .counter { - -webkit-flex: 1; flex: 0; padding-left: 16px; } diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 5b30295adf9..3343b55d24b 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -1,10 +1,6 @@ /** * Note Form */ -.comment-btn { - @extend .btn-success; -} - .diff-file .diff-content { tr.line_holder:hover > td .line_note_link { opacity: 1; @@ -62,7 +58,7 @@ border: 1px solid $border-color; border-radius: $border-radius-base; transition: border-color ease-in-out 0.15s, - box-shadow ease-in-out 0.15s; + box-shadow ease-in-out 0.15s; &.is-focused { @extend .form-control:focus; @@ -76,7 +72,7 @@ &.is-dropzone-hover { border-color: $green-500; box-shadow: 0 0 2px $black-transparent, - 0 0 4px $green-500-focus; + 0 0 4px $green-500-focus; .comment-toolbar, .nav-links { @@ -88,9 +84,7 @@ .md-header .nav-links { display: flex; - display: -webkit-flex; flex-flow: row wrap; - -webkit-flex-flow: row wrap; width: 100%; .float-right { @@ -151,7 +145,7 @@ } .sidebar-item-value & { - fill: $theme-gray-700; + fill: $gray-700; } } @@ -386,7 +380,7 @@ table { } .comment-type-dropdown { - .comment-btn { + .btn-success { width: auto; } @@ -417,7 +411,7 @@ table { width: 100%; margin-bottom: 10px; - .comment-btn { + .btn-success { flex-grow: 1; flex-shrink: 0; width: auto; @@ -448,7 +442,7 @@ table { .uploading-error-message { @include media-breakpoint-down(xs) { &::after { - content: "\a"; + content: '\a'; white-space: pre; } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index a5b1eff3e1d..72f48e98c24 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -5,7 +5,7 @@ $note-form-margin-left: 72px; @mixin vertical-line($left) { &::before { content: ''; - border-left: 2px solid $theme-gray-100; + border-left: 2px solid $gray-100; position: absolute; top: 0; bottom: 0; @@ -153,12 +153,12 @@ $note-form-margin-left: 72px; position: relative; .timeline-discussion-body { - margin-top: -8px; + margin-top: -$gl-padding-8; overflow-x: auto; overflow-y: hidden; - .discussion-resolved-text { - margin-bottom: 8px; + .note-body { + margin-top: $gl-padding-8; } } @@ -303,26 +303,6 @@ $note-form-margin-left: 72px; } } - .timeline-icon { - float: left; - display: flex; - align-items: center; - background-color: $white-light; - width: $system-note-icon-size; - height: $system-note-icon-size; - border: 1px solid $border-color; - border-radius: $system-note-icon-size; - margin: -6px $gl-padding 0 0; - - svg { - width: $system-note-svg-size; - height: $system-note-svg-size; - fill: $gray-darkest; - display: block; - margin: 0 auto; - } - } - .timeline-content { @include notes-media('min', map-get($grid-breakpoints, sm)) { margin-left: 30px; @@ -380,6 +360,37 @@ $note-form-margin-left: 72px; } } } + + .system-note, + .discussion-filter-note { + .timeline-icon { + float: left; + display: flex; + align-items: center; + background-color: $white-light; + width: $system-note-icon-size; + height: $system-note-icon-size; + border: 1px solid $border-color; + border-radius: $system-note-icon-size; + margin: -6px $gl-padding 0 0; + + svg { + width: $system-note-svg-size; + height: $system-note-svg-size; + fill: $gray-darkest; + display: block; + margin: 0 auto; + } + } + } + + .discussion-filter-note { + .timeline-icon { + width: $system-note-icon-size + 6; + height: $system-note-icon-size + 6; + margin-top: -8px; + } + } } // Diff code in discussion view @@ -494,11 +505,6 @@ $note-form-margin-left: 72px; .discussion-notes { margin-left: 0; border-left: 0; - - .notes { - position: relative; - @include vertical-line(52px); - } } .note-wrapper { @@ -550,6 +556,11 @@ $note-form-margin-left: 72px; .note-header-info { padding-bottom: 0; } + + .timeline-content { + overflow-x: auto; + overflow-y: hidden; + } } .unresolved { @@ -597,7 +608,6 @@ $note-form-margin-left: 72px; .note-headline-meta { display: inline-block; - white-space: nowrap; .system-note-message { white-space: normal; @@ -607,6 +617,10 @@ $note-form-margin-left: 72px; color: $gl-text-color-disabled; } + .note-timestamp { + white-space: nowrap; + } + a:hover { text-decoration: underline; } @@ -902,7 +916,6 @@ $note-form-margin-left: 72px; } .discussion-filter-container { - .btn > svg { width: $gl-col-padding; height: $gl-col-padding; @@ -924,7 +937,6 @@ $note-form-margin-left: 72px; //This needs to be deleted when Snippet/Commit comments are convered to Vue // See https://gitlab.com/gitlab-org/gitlab-ce/issues/53918#note_117038785 .unstyled-comments { - .discussion-header { padding: $gl-padding; border-bottom: 1px solid $border-color; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index fdd17af35fb..acc562f0769 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -256,14 +256,25 @@ } } - .mini-pipeline-graph-dropdown-toggle svg { - height: $ci-action-icon-size; - width: $ci-action-icon-size; - position: absolute; - top: -1px; - left: -1px; - z-index: 2; - overflow: visible; + .mini-pipeline-graph-dropdown-toggle { + svg { + height: $ci-action-icon-size; + width: $ci-action-icon-size; + position: absolute; + top: -1px; + left: -1px; + z-index: 2; + overflow: visible; + } + + &:hover, + &:active, + &:focus { + svg { + top: -2px; + left: -2px; + } + } } .stage-container { @@ -293,7 +304,7 @@ width: 7px; position: absolute; right: -7px; - top: 10px; + top: 11px; border-bottom: 2px solid $border-color; } } @@ -433,6 +444,7 @@ } .pipeline-tab-content { + display: flex; width: 100%; background-color: $gray-light; padding: $gl-padding; @@ -707,21 +719,43 @@ font-weight: $gl-font-weight-normal; } -@mixin mini-pipeline-graph-color($color-light, $color-main, $color-dark) { - border-color: $color-main; - color: $color-main; +@mixin mini-pipeline-graph-color( + $color-background-default, + $color-background-hover-focus, + $color-background-active, + $color-foreground-default, + $color-foreground-hover-focus, + $color-foreground-active +) { + background-color: $color-background-default; + border-color: $color-foreground-default; + + svg { + fill: $color-foreground-default; + } &:hover, - &:focus, + &:focus { + background-color: $color-background-hover-focus; + border-color: $color-foreground-hover-focus; + + svg { + fill: $color-foreground-hover-focus; + } + } + &:active { - background-color: $color-light; - border-color: $color-dark; - color: $color-dark; + background-color: $color-background-active; + border-color: $color-foreground-active; svg { - fill: $color-dark; + fill: $color-foreground-active; } } + + &:focus { + box-shadow: 0 0 4px 1px $blue-300; + } } @mixin mini-pipeline-item() { @@ -733,26 +767,32 @@ height: $ci-action-icon-size; margin: 0; padding: 0; - transition: all 0.2s linear; position: relative; vertical-align: middle; + &:hover, + &:active, + &:focus { + outline: none; + border-width: 2px; + } + // Dropdown button animation in mini pipeline graph &.ci-status-icon-success { - @include mini-pipeline-graph-color($green-100, $green-500, $green-600); + @include mini-pipeline-graph-color($white, $green-100, $green-200, $green-500, $green-600, $green-700); } &.ci-status-icon-failed { - @include mini-pipeline-graph-color($red-100, $red-500, $red-600); + @include mini-pipeline-graph-color($white, $red-100, $red-200, $red-500, $red-600, $red-700); } &.ci-status-icon-pending, &.ci-status-icon-success_with_warnings { - @include mini-pipeline-graph-color($orange-100, $orange-500, $orange-600); + @include mini-pipeline-graph-color($white, $orange-100, $orange-200, $orange-500, $orange-600, $orange-700); } &.ci-status-icon-running { - @include mini-pipeline-graph-color($blue-100, $blue-400, $blue-600); + @include mini-pipeline-graph-color($white, $blue-100, $blue-200, $blue-500, $blue-600, $blue-700); } &.ci-status-icon-canceled, @@ -760,42 +800,18 @@ &.ci-status-icon-disabled, &.ci-status-icon-not-found, &.ci-status-icon-manual { - @include mini-pipeline-graph-color(rgba($gl-text-color, 0.1), $gl-text-color, $gl-text-color); + @include mini-pipeline-graph-color($white, $gray-700, $gray-800, $gray-900, $gray-950, $black); } &.ci-status-icon-created, &.ci-status-icon-skipped { - @include mini-pipeline-graph-color(rgba($gray-darkest, 0.1), $gray-darkest, $gray-darkest); + @include mini-pipeline-graph-color($white, $gray-200, $gray-300, $gray-500, $gray-600, $gray-700); } } // Dropdown button in mini pipeline graph button.mini-pipeline-graph-dropdown-toggle { @include mini-pipeline-item(); - - > .fa.fa-caret-down { - position: absolute; - left: 20px; - top: 5px; - display: inline-block; - visibility: hidden; - opacity: 0; - color: inherit; - font-size: 12px; - transition: visibility 0.1s, opacity 0.1s linear; - } - - &:active, - &:focus, - &:hover { - outline: none; - width: 35px; - - .fa.fa-caret-down { - visibility: visible; - opacity: 1; - } - } } /** @@ -845,7 +861,7 @@ button.mini-pipeline-graph-dropdown-toggle { height: $ci-action-dropdown-svg-size; fill: $gl-text-color-secondary; position: relative; - top: 0; + top: 1px; vertical-align: initial; } } @@ -853,7 +869,7 @@ button.mini-pipeline-graph-dropdown-toggle { // SVGs in the commit widget and mr widget a.ci-action-icon-container.ci-action-icon-wrapper svg { - top: 2px; + top: 4px; } .scrollable-menu { @@ -978,8 +994,6 @@ button.mini-pipeline-graph-dropdown-toggle { * Top arrow in the dropdown in the mini pipeline graph */ .mini-pipeline-graph-dropdown-menu { - z-index: 200; - &::before, &::after { content: ''; diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index b813eb16dad..ab26259c007 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -249,6 +249,16 @@ .event-item { padding-left: 40px; } + + @include media-breakpoint-up(lg) { + margin-right: 5px; + } + } + + .projects-block { + @include media-breakpoint-up(lg) { + margin-left: 5px; + } } @include media-breakpoint-down(xs) { @@ -260,11 +270,9 @@ position: static; padding: 0 16px; margin-bottom: 20px; - display: -webkit-flex; display: flex; .btn { - -webkit-flex-grow: 1; flex-grow: 1; &:first-child { diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/pages/profiles/preferences.scss index a353f301d07..45e62913f37 100644 --- a/app/assets/stylesheets/pages/profiles/preferences.scss +++ b/app/assets/stylesheets/pages/profiles/preferences.scss @@ -60,11 +60,11 @@ } &.ui-dark { - background-color: $theme-gray-900; + background-color: $gray-900; } &.ui-light { - background-color: $theme-gray-200; + background-color: $gray-200; } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 0ce0db038a7..1349845f300 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -140,73 +140,19 @@ } } -.project-home-panel, -.group-home-panel { - padding-top: 24px; - padding-bottom: 24px; - - .group-avatar { - float: none; - margin: 0 auto; - - &.identicon { - border-radius: 50%; - } - } - - .group-title { - margin-top: 10px; - margin-bottom: 10px; - font-size: 24px; - font-weight: $gl-font-weight-normal; - line-height: 1; - word-wrap: break-word; - - .fa { - margin-left: 2px; - font-size: 12px; - vertical-align: middle; - } - } - - .group-home-desc { - margin-left: auto; - margin-right: auto; - margin-bottom: 0; - max-width: 700px; - - > p { - margin-bottom: 0; - } - } - - .notifications-btn { - .fa-bell, - .fa-spinner { - margin-right: 6px; - } - - .fa-angle-down { - margin-left: 6px; - } - } -} - +.group-home-panel, .project-home-panel { padding-top: $gl-padding; padding-bottom: $gl-padding; - .project-avatar { - width: $project-title-row-height; - height: $project-title-row-height; + .home-panel-avatar { + width: $home-panel-title-row-height; + height: $home-panel-title-row-height; flex-shrink: 0; - flex-basis: $project-title-row-height; - margin: 0 $gl-padding 0 0; + flex-basis: $home-panel-title-row-height; } - .project-title { - margin-top: 8px; - margin-bottom: 5px; + .home-panel-title { font-size: 20px; line-height: $gl-line-height-24; font-weight: bold; @@ -215,11 +161,7 @@ font-size: $gl-font-size-large; } - .project-visibility { - color: $gl-text-color-secondary; - } - - .project-tag-list { + .home-panel-topic-list { font-size: $gl-font-size; font-weight: $gl-font-weight-normal; @@ -231,12 +173,12 @@ } } - .project-title-row { + .home-panel-title-row { @include media-breakpoint-down(sm) { - .project-avatar { - width: $project-avatar-mobile-size; - height: $project-avatar-mobile-size; - flex-basis: $project-avatar-mobile-size; + .home-panel-avatar { + width: $home-panel-avatar-mobile-size; + height: $home-panel-avatar-mobile-size; + flex-basis: $home-panel-avatar-mobile-size; .avatar { font-size: 20px; @@ -244,28 +186,26 @@ } } - .project-title { + .home-panel-title { margin-top: 4px; margin-bottom: 2px; font-size: $gl-font-size; line-height: $gl-font-size-large; } - .project-tag-list, - .project-metadata { + .home-panel-topic-list, + .home-panel-metadata { font-size: $gl-font-size-small; } } } - .project-metadata { + .home-panel-metadata { font-weight: normal; font-size: 14px; line-height: $gl-btn-line-height; - color: $gl-text-color-secondary; - - .project-license { + .home-panel-license { .btn { line-height: 0; border-width: 0; @@ -273,13 +213,13 @@ } .access-request-link, - .project-tag-list { + .home-panel-topic-list { padding-left: $gl-padding-8; border-left: 1px solid $gl-text-color-secondary; } } - .project-description { + .home-panel-description { @include media-breakpoint-up(md) { font-size: $gl-font-size-large; } @@ -292,12 +232,11 @@ } } -.nav > .project-repo-buttons { +.nav > .project-buttons { margin-top: 0; } -.project-repo-buttons, -.group-buttons { +.project-repo-buttons { .btn { &:last-child { margin-left: 0; @@ -318,8 +257,30 @@ margin-left: 0; } } + + .notifications-icon { + top: 1px; + margin-right: 0; + } + } + + .icon { + top: 0; + } + + .count-badge, + .btn-xs { + height: 24px; + } + + .dropdown-toggle, + .clone-dropdown-btn { + .fa { + color: unset; + } } + .home-panel-action-button, .project-action-button { margin: $gl-padding $gl-padding-8 0 0; vertical-align: top; @@ -385,31 +346,6 @@ } } -.project-repo-buttons { - .icon { - top: 0; - } - - .count-badge, - .btn-xs { - height: 24px; - } - - .dropdown-toggle, - .clone-dropdown-btn { - .fa { - color: unset; - } - } - - .btn { - .notifications-icon { - top: 1px; - margin-right: 0; - } - } -} - .split-one { display: inline-table; margin-right: 12px; @@ -459,7 +395,7 @@ margin-right: $gl-padding-4; margin-bottom: $gl-padding-4; color: $gl-text-color-secondary; - background-color: $theme-gray-100; + background-color: $gray-100; line-height: $gl-btn-line-height; &:hover { @@ -571,12 +507,6 @@ } .template-option { - .logo { - .btn-template-icon { - width: 40px !important; - } - } - padding: 16px 0; &:not(:first-child) { @@ -615,9 +545,8 @@ } .selected-icon { - svg { + img { display: none; - top: 7px; height: 20px; width: 20px; } @@ -642,9 +571,7 @@ .import-buttons { padding-left: 0; - display: -webkit-flex; display: flex; - -webkit-flex-wrap: wrap; flex-wrap: wrap; .btn { @@ -772,14 +699,11 @@ .project-stats, .project-buttons { - font-size: 0; - text-align: center; - .scrolling-tabs-container { .scrolling-tabs { margin-top: $gl-padding-8; - margin-bottom: $gl-padding-8 - $browserScrollbarSize; - padding-bottom: $browserScrollbarSize; + margin-bottom: $gl-padding-8 - $browser-scrollbar-size; + padding-bottom: $browser-scrollbar-size; flex-wrap: wrap; border-bottom: 0; } @@ -787,7 +711,7 @@ .fade-left, .fade-right { top: 0; - height: calc(100% - #{$browserScrollbarSize}); + height: calc(100% - #{$browser-scrollbar-size}); .fa { top: 50%; @@ -914,7 +838,7 @@ } .repository-language-bar-tooltip-share { - color: $theme-gray-400; + color: $gray-400; } pre.light-well { @@ -973,7 +897,7 @@ pre.light-well { padding: $gl-padding 0; @include media-breakpoint-up(lg) { - padding: $gl-padding-24 0; + padding: $gl-padding 0; } &.no-description { @@ -990,7 +914,7 @@ pre.light-well { } h2 { - font-size: $gl-font-size-medium; + font-size: $gl-font-size-large; font-weight: $gl-font-weight-bold; margin-bottom: 0; @@ -1013,6 +937,11 @@ pre.light-well { .flex-wrapper { min-width: 0; margin-top: -$gl-padding-8; // negative margin required for flex-wrap + flex: 1 1 100%; + + .project-title { + line-height: 20px; + } } p, @@ -1025,8 +954,10 @@ pre.light-well { margin: 0; } - @include media-breakpoint-up(md) { - .description { + .description { + line-height: 1.5; + + @include media-breakpoint-up(md) { color: $gl-text-color; } } @@ -1049,14 +980,16 @@ pre.light-well { } .controls { - margin-top: $gl-padding; + @include media-breakpoint-down(xs) { + margin-top: $gl-padding-8; + } - @include media-breakpoint-down(md) { + @include media-breakpoint-up(sm) { margin-top: 0; } - @include media-breakpoint-down(xs) { - margin-top: $gl-padding-8; + @include media-breakpoint-up(lg) { + flex: 1 1 40%; } .icon-wrapper { @@ -1106,7 +1039,7 @@ pre.light-well { min-height: 40px; min-width: 40px; - .identicon.s64 { + .identicon.s48 { font-size: 16px; } } diff --git a/app/assets/stylesheets/pages/reports.scss b/app/assets/stylesheets/pages/reports.scss index ecd51aa06a4..f7619ccbd20 100644 --- a/app/assets/stylesheets/pages/reports.scss +++ b/app/assets/stylesheets/pages/reports.scss @@ -96,7 +96,7 @@ } &.neutral svg { - color: $theme-gray-700; + color: $gray-700; } .ci-status-icon { diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 149c3254d84..20bdc6596e9 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -84,9 +84,7 @@ input[type='checkbox']:hover { .search-icon { transition: color $default-transition-duration; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; + user-select: none; } .clear-icon { @@ -185,13 +183,11 @@ input[type='checkbox']:hover { .search-holder { @include media-breakpoint-up(sm) { - display: -webkit-flex; display: flex; } .search-field-holder, .project-filter-form { - -webkit-flex: 1 0 auto; flex: 1 0 auto; position: relative; margin-right: 0; diff --git a/app/assets/stylesheets/pages/serverless.scss b/app/assets/stylesheets/pages/serverless.scss new file mode 100644 index 00000000000..a5b73492380 --- /dev/null +++ b/app/assets/stylesheets/pages/serverless.scss @@ -0,0 +1,3 @@ +.url-text-field { + cursor: text; +} diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index ccfa4e00a5b..4f9d96da4bd 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -167,12 +167,14 @@ font-weight: $gl-font-weight-normal; display: inline-block; color: $gl-text-color; + vertical-align: top; } .option-description, .option-disabled-reason { margin-left: 30px; color: $project-option-descr-color; + margin-top: -5px; } .option-disabled-reason { @@ -297,7 +299,7 @@ .btn-clipboard { background-color: $white-light; - border: 1px solid $theme-gray-200; + border: 1px solid $gray-200; } .deploy-token-help-block { @@ -314,8 +316,4 @@ .push-pull-table { margin-top: 1em; - - .mirror-action-buttons { - padding-right: 0; - } } diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss index 7d59dd3b5d1..f4d568d02ac 100644 --- a/app/assets/stylesheets/pages/status.scss +++ b/app/assets/stylesheets/pages/status.scss @@ -33,7 +33,7 @@ border-color: $gl-text-color; &:not(span):hover { - background-color: rgba($gl-text-color, .07); + background-color: rgba($gl-text-color, 0.07); } } @@ -54,7 +54,7 @@ border-color: $gl-text-color-secondary; &:not(span):hover { - background-color: rgba($gl-text-color-secondary, .07); + background-color: rgba($gl-text-color-secondary, 0.07); } } } diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index 3fc37e20c36..2a1e8345755 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -6,9 +6,7 @@ .todos-list > .todo { // workaround because we cannot use border-colapse border-top: 1px solid transparent; - display: -webkit-flex; display: flex; - -webkit-flex-direction: row; flex-direction: row; &:hover { @@ -29,23 +27,18 @@ .todo-avatar, .todo-actions { @include transition(opacity); - -webkit-flex: 0 0 auto; flex: 0 0 auto; } .todo-actions { - display: -webkit-flex; display: flex; - -webkit-justify-content: center; justify-content: center; - -webkit-flex-direction: column; flex-direction: column; margin-left: 10px; min-width: 55px; } .todo-item { - -webkit-flex: 0 1 100%; flex: 0 1 100%; min-width: 0; } @@ -60,13 +53,13 @@ .todo-avatar, .todo-item { - opacity: .6; + opacity: 0.6; } } .todo-avatar, .todo-item { - opacity: .2; + opacity: 0.2; } .btn { @@ -82,7 +75,6 @@ display: flex; > .title-item { - -webkit-flex: 0 0 auto; flex: 0 0 auto; margin: 0 2px; @@ -96,7 +88,6 @@ } .todo-label { - -webkit-flex: 0 1 auto; flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; @@ -222,23 +213,19 @@ } .todos-empty { - display: -webkit-flex; display: flex; - -webkit-flex-direction: column; flex-direction: column; max-width: 900px; margin-left: auto; margin-right: auto; @include media-breakpoint-up(sm) { - -webkit-flex-direction: row; flex-direction: row; padding-top: 80px; } } .todos-empty-content { - -webkit-align-self: center; align-self: center; max-width: 480px; margin-right: 20px; @@ -252,7 +239,6 @@ @include media-breakpoint-up(sm) { width: 300px; margin-right: 0; - -webkit-order: 2; order: 2; } } diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index a46b8679a42..5664f46484e 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -172,26 +172,6 @@ text-decoration: inherit; } } - - .tree_commit { - max-width: 320px; - - .str-truncated { - max-width: 100%; - } - } - - .tree_time_ago { - min-width: 135px; - } - } - - .tree_author { - padding-right: 8px; - - .commit-author-name { - color: $gl-text-color; - } } .tree-truncated-warning { diff --git a/app/assets/stylesheets/pages/ui_dev_kit.scss b/app/assets/stylesheets/pages/ui_dev_kit.scss index 84c617c7ec0..7744fd814d0 100644 --- a/app/assets/stylesheets/pages/ui_dev_kit.scss +++ b/app/assets/stylesheets/pages/ui_dev_kit.scss @@ -10,7 +10,7 @@ margin-bottom: 15px; &::before { - content: "Example"; + content: 'Example'; color: $ui-dev-kit-example-color; } } diff --git a/app/controllers/admin/appearances_controller.rb b/app/controllers/admin/appearances_controller.rb index e3226c86b0b..189fee98aa0 100644 --- a/app/controllers/admin/appearances_controller.rb +++ b/app/controllers/admin/appearances_controller.rb @@ -74,6 +74,11 @@ class Admin::AppearancesController < Admin::ApplicationController favicon_cache new_project_guidelines updated_by + header_message + footer_message + message_background_color + message_font_color + email_header_and_footer_enabled ] end end diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 8f683ca06ad..8f267eccc8a 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -77,7 +77,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController def reset_health_check_token @application_setting.reset_health_check_access_token! flash[:notice] = 'New health check access token has been generated!' - redirect_to :back + redirect_back_or_default end def clear_repository_check_states diff --git a/app/controllers/admin/requests_profiles_controller.rb b/app/controllers/admin/requests_profiles_controller.rb index 57f7d3e3951..89d4c4f18d9 100644 --- a/app/controllers/admin/requests_profiles_controller.rb +++ b/app/controllers/admin/requests_profiles_controller.rb @@ -11,7 +11,7 @@ class Admin::RequestsProfilesController < Admin::ApplicationController profile = Gitlab::RequestProfiler::Profile.find(clean_name) if profile - render html: profile.content + render html: profile.content.html_safe else redirect_to admin_requests_profiles_path, alert: 'Profile not found' end diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index 0b6ff491c66..8a00408001e 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Admin::RunnersController < Admin::ApplicationController - before_action :runner, except: :index + before_action :runner, except: [:index, :tag_list] def index finder = Admin::RunnersFinder.new(params: params) @@ -48,6 +48,12 @@ class Admin::RunnersController < Admin::ApplicationController end end + def tag_list + tags = Autocomplete::ActsAsTaggableOn::TagsFinder.new(params: params).execute + + render json: ActsAsTaggableOn::TagSerializer.new.represent(tags) + end + private def runner diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index e93be1c1ba2..bfa7c7d0109 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -1,11 +1,13 @@ # frozen_string_literal: true class Admin::UsersController < Admin::ApplicationController + include RoutableActions + before_action :user, except: [:index, :new, :create] before_action :check_impersonation_availability, only: :impersonate def index - @users = User.order_name_asc.filter(params[:filter]) + @users = User.filter_items(params[:filter]).order_name_asc @users = @users.search_with_secondary_emails(params[:search_query]) if params[:search_query].present? @users = @users.sort_by_attribute(@sort = params[:sort]) @users = @users.page(params[:page]) @@ -177,11 +179,13 @@ class Admin::UsersController < Admin::ApplicationController user == current_user end - # rubocop: disable CodeReuse/ActiveRecord def user - @user ||= User.find_by!(username: params[:id]) + @user ||= find_routable!(User, params[:id]) + end + + def build_canonical_path(user) + url_for(safe_params.merge(id: user.to_param)) end - # rubocop: enable CodeReuse/ActiveRecord def redirect_back_or_admin_user(options = {}) redirect_back_or_default(default: default_route, options: options) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 140a625d333..b7eb6af6d67 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -43,7 +43,10 @@ class ApplicationController < ActionController::Base :git_import_enabled?, :gitlab_project_import_enabled?, :manifest_import_enabled? + # Adds `no-store` to the DEFAULT_CACHE_CONTROL, to prevent security + # concerns due to caching private data. DEFAULT_GITLAB_CACHE_CONTROL = "#{ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL}, no-store".freeze + DEFAULT_GITLAB_CONTROL_NO_CACHE = "#{DEFAULT_GITLAB_CACHE_CONTROL}, no-cache".freeze rescue_from Encoding::CompatibilityError do |exception| log_exception(exception) @@ -76,7 +79,7 @@ class ApplicationController < ActionController::Base end def redirect_back_or_default(default: root_path, options: {}) - redirect_to request.referer.present? ? :back : default, options + redirect_back(fallback_location: default, **options) end def not_found @@ -137,6 +140,8 @@ class ApplicationController < ActionController::Base if response.status == 422 && response.body.present? && response.content_type == 'application/json'.freeze payload[:response] = response.body end + + payload[:queue_duration] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY] end ## @@ -177,11 +182,17 @@ class ApplicationController < ActionController::Base # hide existence of the resource, rather tell them they cannot access it using # the provided message status ||= message.present? ? :forbidden : :not_found + template = + if status == :not_found + "errors/not_found" + else + "errors/access_denied" + end respond_to do |format| format.any { head status } format.html do - render "errors/access_denied", + render template, layout: "errors", status: status, locals: { message: message } @@ -227,9 +238,9 @@ class ApplicationController < ActionController::Base end def no_cache_headers - response.headers["Cache-Control"] = "no-cache, no-store, max-age=0, must-revalidate" - response.headers["Pragma"] = "no-cache" - response.headers["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT" + headers['Cache-Control'] = DEFAULT_GITLAB_CONTROL_NO_CACHE + headers['Pragma'] = 'no-cache' # HTTP 1.0 compatibility + headers['Expires'] = 'Fri, 01 Jan 1990 00:00:00 GMT' end def default_headers @@ -239,10 +250,16 @@ class ApplicationController < ActionController::Base headers['X-Content-Type-Options'] = 'nosniff' if current_user - # Adds `no-store` to the DEFAULT_CACHE_CONTROL, to prevent security - # concerns due to caching private data. - headers['Cache-Control'] = DEFAULT_GITLAB_CACHE_CONTROL - headers["Pragma"] = "no-cache" # HTTP 1.0 compatibility + headers['Cache-Control'] = default_cache_control + headers['Pragma'] = 'no-cache' # HTTP 1.0 compatibility + end + end + + def default_cache_control + if request.xhr? + ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL + else + DEFAULT_GITLAB_CACHE_CONTROL end end diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index b9717b97640..68a2a83f0de 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -24,7 +24,7 @@ class Clusters::ClustersController < Clusters::BaseController # Note: We are paginating through an array here but this should OK as: # # In CE, we can have a maximum group nesting depth of 21, so including - # project cluster, we can have max 22 clusters for a group hierachy. + # project cluster, we can have max 22 clusters for a group hierarchy. # In EE (Premium) we can have any number, as multiple clusters are # supported, but the number of clusters are fairly low currently. # @@ -127,6 +127,7 @@ class Clusters::ClustersController < Clusters::BaseController params.require(:cluster).permit( :enabled, :environment_scope, + :base_domain, platform_kubernetes_attributes: [ :namespace ] @@ -136,6 +137,7 @@ class Clusters::ClustersController < Clusters::BaseController :enabled, :name, :environment_scope, + :base_domain, platform_kubernetes_attributes: [ :api_url, :token, diff --git a/app/controllers/concerns/boards_responses.rb b/app/controllers/concerns/boards_responses.rb index 3cdf4ddf8bb..8b191c86397 100644 --- a/app/controllers/concerns/boards_responses.rb +++ b/app/controllers/concerns/boards_responses.rb @@ -34,15 +34,11 @@ module BoardsResponses end def authorize_read_list - ability = board.group_board? ? :read_group : :read_list - - authorize_action_for!(board.parent, ability) + authorize_action_for!(board, :read_list) end def authorize_read_issue - ability = board.group_board? ? :read_group : :read_issue - - authorize_action_for!(board.parent, ability) + authorize_action_for!(board, :read_issue) end def authorize_update_issue @@ -57,7 +53,7 @@ module BoardsResponses end def authorize_admin_list - authorize_action_for!(board.parent, :admin_list) + authorize_action_for!(board, :admin_list) end def authorize_action_for!(resource, ability) diff --git a/app/controllers/concerns/continue_params.rb b/app/controllers/concerns/continue_params.rb index f0e6adf4dec..54c0510497f 100644 --- a/app/controllers/concerns/continue_params.rb +++ b/app/controllers/concerns/continue_params.rb @@ -6,7 +6,7 @@ module ContinueParams def continue_params continue_params = params[:continue] - return nil unless continue_params + return unless continue_params continue_params = continue_params.permit(:to, :notice, :notice_now) continue_params[:to] = safe_redirect_path(continue_params[:to]) diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 3d64ae8b775..cd3fa641e89 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -7,6 +7,9 @@ module IssuableActions included do before_action :authorize_destroy_issuable!, only: :destroy before_action :authorize_admin_issuable!, only: :bulk_update + before_action only: :show do + push_frontend_feature_flag(:reply_to_individual_notes) + end end def permitted_keys @@ -58,7 +61,8 @@ module IssuableActions title_text: issuable.title, description: view_context.markdown_field(issuable, :description), description_text: issuable.description, - task_status: issuable.task_status + task_status: issuable.task_status, + lock_version: issuable.lock_version } if issuable.edited? diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 789e0dc736e..c529aabf797 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -91,6 +91,7 @@ module IssuableCollections options = { scope: params[:scope], state: params[:state], + confidential: Gitlab::Utils.to_boolean(params[:confidential]), sort: set_sort_order } @@ -129,13 +130,13 @@ module IssuableCollections return sort_param if Gitlab::Database.read_only? if user_preference[issuable_sorting_field] != sort_param - user_preference.update_attribute(issuable_sorting_field, sort_param) + user_preference.update(issuable_sorting_field => sort_param) end sort_param end - # Implement default_sorting_field method on controllers + # Implement issuable_sorting_field method on controllers # to choose which column to store the sorting parameter. def issuable_sorting_field nil diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb new file mode 100644 index 00000000000..18ed4027eac --- /dev/null +++ b/app/controllers/concerns/issuable_collections_action.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module IssuableCollectionsAction + extend ActiveSupport::Concern + include IssuableCollections + include IssuesCalendar + + # rubocop:disable Gitlab/ModuleWithInstanceVariables + def issues + @issues = issuables_collection + .non_archived + .page(params[:page]) + + @issuable_meta_data = issuable_meta_data(@issues, collection_type) + + respond_to do |format| + format.html + format.atom { render layout: 'xml.atom' } + end + end + + def merge_requests + @merge_requests = issuables_collection.page(params[:page]) + + @issuable_meta_data = issuable_meta_data(@merge_requests, collection_type) + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables + + def issues_calendar + render_issues_calendar(issuables_collection) + end + + private + + def issuable_sorting_field + case action_name + when 'issues' + Issue::SORTING_PREFERENCE_FIELD + when 'merge_requests' + MergeRequest::SORTING_PREFERENCE_FIELD + else + nil + end + end + + def finder_type + case action_name + when 'issues', 'issues_calendar' + IssuesFinder + when 'merge_requests' + MergeRequestsFinder + else + nil + end + end + + def finder_options + super.merge(non_archived: true) + end +end diff --git a/app/controllers/concerns/issues_action.rb b/app/controllers/concerns/issues_action.rb deleted file mode 100644 index a75590457d6..00000000000 --- a/app/controllers/concerns/issues_action.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module IssuesAction - extend ActiveSupport::Concern - include IssuableCollections - include IssuesCalendar - - # rubocop:disable Gitlab/ModuleWithInstanceVariables - def issues - @issues = issuables_collection - .non_archived - .page(params[:page]) - - @issuable_meta_data = issuable_meta_data(@issues, collection_type) - - respond_to do |format| - format.html - format.atom { render layout: 'xml.atom' } - end - end - # rubocop:enable Gitlab/ModuleWithInstanceVariables - - def issues_calendar - render_issues_calendar(issuables_collection) - end - - private - - def finder_type - (super if defined?(super)) || - (IssuesFinder if %w(issues issues_calendar).include?(action_name)) - end -end diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index 5572c3cee2d..57e444319e0 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -123,7 +123,7 @@ module LfsRequest (authentication_abilities || []).include?(capability) end - # Overriden in EE + # Overridden in EE def limit_exceeded? false end diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index ca713192c9e..6402e01ddc0 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -35,7 +35,9 @@ module MembershipActions respond_to do |format| format.html do - message = "User was successfully removed from #{source_type}." + source = source_type == 'group' ? 'group and any subresources' : source_type + + message = "User was successfully removed from #{source}." redirect_to members_page_url, notice: message end diff --git a/app/controllers/concerns/merge_requests_action.rb b/app/controllers/concerns/merge_requests_action.rb deleted file mode 100644 index ed10f32512e..00000000000 --- a/app/controllers/concerns/merge_requests_action.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module MergeRequestsAction - extend ActiveSupport::Concern - include IssuableCollections - - # rubocop:disable Gitlab/ModuleWithInstanceVariables - def merge_requests - @merge_requests = issuables_collection.page(params[:page]) - - @issuable_meta_data = issuable_meta_data(@merge_requests, collection_type) - end - # rubocop:enable Gitlab/ModuleWithInstanceVariables - - private - - def finder_type - (super if defined?(super)) || - (MergeRequestsFinder if action_name == 'merge_requests') - end - - def finder_options - super.merge(non_archived: true) - end -end diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb index eccbe35577b..c0c0160a827 100644 --- a/app/controllers/concerns/milestone_actions.rb +++ b/app/controllers/concerns/milestone_actions.rb @@ -8,7 +8,7 @@ module MilestoneActions format.html { redirect_to milestone_redirect_path } format.json do render json: tabs_json("shared/milestones/_merge_requests_tab", { - merge_requests: @milestone.sorted_merge_requests, # rubocop:disable Gitlab/ModuleWithInstanceVariables + merge_requests: @milestone.sorted_merge_requests(current_user), # rubocop:disable Gitlab/ModuleWithInstanceVariables show_project_name: true }) end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 0319948a12f..b4fee93713b 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -6,7 +6,6 @@ module NotesActions extend ActiveSupport::Concern included do - prepend_before_action :normalize_create_params, only: [:create] before_action :set_polling_interval_header, only: [:index] before_action :require_noteable!, only: [:index, :create] before_action :authorize_admin_note!, only: [:update, :destroy] @@ -44,12 +43,7 @@ module NotesActions # rubocop:disable Gitlab/ModuleWithInstanceVariables def create - create_params = note_params.merge( - merge_request_diff_head_sha: params[:merge_request_diff_head_sha], - in_reply_to_discussion_id: params[:in_reply_to_discussion_id] - ) - - @note = Notes::CreateService.new(note_project, current_user, create_params).execute + @note = Notes::CreateService.new(note_project, current_user, create_note_params).execute respond_to do |format| format.json do @@ -78,7 +72,7 @@ module NotesActions # rubocop:disable Gitlab/ModuleWithInstanceVariables def update - @note = Notes::UpdateService.new(project, current_user, note_params).execute(note) + @note = Notes::UpdateService.new(project, current_user, update_note_params).execute(note) prepare_notes_for_rendering([@note]) respond_to do |format| @@ -196,24 +190,36 @@ module NotesActions return access_denied! unless can?(current_user, :admin_note, note) end - def note_params + def create_note_params params.require(:note).permit( - :project_id, - :noteable_type, - :noteable_id, - :commit_id, - :noteable, :type, - :note, - :attachment, + :line_code, # LegacyDiffNote + :position # DiffNote + ).tap do |create_params| + create_params.merge!( + params.permit(:merge_request_diff_head_sha, :in_reply_to_discussion_id) + ) - # LegacyDiffNote - :line_code, + # These params are also sent by the client but we need to set these based on + # target_type and target_id because we're checking permissions based on that + create_params[:noteable_type] = params[:target_type].classify + + case params[:target_type] + when 'commit' + create_params[:commit_id] = params[:target_id] + when 'merge_request' + create_params[:noteable_id] = params[:target_id] + # Notes on MergeRequest can have an extra `commit_id` context + create_params[:commit_id] = params.dig(:note, :commit_id) + else + create_params[:noteable_id] = params[:target_id] + end + end + end - # DiffNote - :position - ) + def update_note_params + params.require(:note).permit(:note) end def set_polling_interval_header @@ -248,15 +254,6 @@ module NotesActions DiscussionSerializer.new(project: project, noteable: noteable, current_user: current_user, note_entity: ProjectNoteEntity) end - # Avoids checking permissions in the wrong object - this ensures that the object we checked permissions for - # is the object we're actually creating a note in. - def normalize_create_params - params[:note].try do |note| - note[:noteable_id] = params[:target_id] - note[:noteable_type] = params[:target_type].classify - end - end - def note_project strong_memoize(:note_project) do next nil unless project diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index 4b0f0b8255c..f72d25fc54c 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -16,8 +16,6 @@ module PreviewMarkdown else {} end - markdown_params[:markdown_engine] = result[:markdown_engine] - render json: { body: view_context.markdown(result[:text], markdown_params), references: { diff --git a/app/controllers/concerns/record_user_last_activity.rb b/app/controllers/concerns/record_user_last_activity.rb new file mode 100644 index 00000000000..372c803278d --- /dev/null +++ b/app/controllers/concerns/record_user_last_activity.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# == RecordUserLastActivity +# +# Controller concern that updates the `last_activity_on` field of `users` +# for any authenticated GET request. The DB update will only happen once per day. +# +# In order to determine if you should include this concern or not, please check the +# description and discussion on this issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/54947 +module RecordUserLastActivity + include CookiesHelper + extend ActiveSupport::Concern + + included do + before_action :set_user_last_activity + end + + def set_user_last_activity + return unless request.get? + return unless Feature.enabled?(:set_user_last_activity, default_enabled: true) + return if Gitlab::Database.read_only? + + if current_user && current_user.last_activity_on != Date.today + Users::ActivityService.new(current_user, "visited #{request.path}").execute + end + end +end diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb index ce36da6b715..18015b1de88 100644 --- a/app/controllers/concerns/renders_notes.rb +++ b/app/controllers/concerns/renders_notes.rb @@ -16,7 +16,7 @@ module RendersNotes private def preload_max_access_for_authors(notes, project) - return nil unless project + return unless project user_ids = notes.map(&:author_id) project.team.max_member_access_for_user_ids(user_ids) diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb index 515a9eede8e..28e4cece548 100644 --- a/app/controllers/concerns/send_file_upload.rb +++ b/app/controllers/concerns/send_file_upload.rb @@ -3,16 +3,19 @@ module SendFileUpload def send_upload(file_upload, send_params: {}, redirect_params: {}, attachment: nil, proxy: false, disposition: 'attachment') if attachment + response_disposition = ::Gitlab::ContentDisposition.format(disposition: disposition, filename: attachment) + # Response-Content-Type will not override an existing Content-Type in # Google Cloud Storage, so the metadata needs to be cleared on GCS for # this to work. However, this override works with AWS. - redirect_params[:query] = { "response-content-disposition" => "#{disposition};filename=#{attachment.inspect}", + redirect_params[:query] = { "response-content-disposition" => response_disposition, "response-content-type" => guess_content_type(attachment) } # By default, Rails will send uploads with an extension of .js with a # content-type of text/javascript, which will trigger Rails' # cross-origin JavaScript protection. send_params[:content_type] = 'text/plain' if File.extname(attachment) == '.js' - send_params.merge!(filename: attachment, disposition: disposition) + + send_params.merge!(filename: attachment, disposition: utf8_encoded_disposition(disposition, attachment)) end if file_upload.file_storage? @@ -25,6 +28,18 @@ module SendFileUpload end end + # Since Rails 5 doesn't properly support support non-ASCII filenames, + # we have to add our own to ensure RFC 5987 compliance. However, Rails + # 5 automatically appends `filename#{filename}` here: + # https://github.com/rails/rails/blob/v5.0.7/actionpack/lib/action_controller/metal/data_streaming.rb#L137 + # Rails 6 will have https://github.com/rails/rails/pull/33829, so we + # can get rid of this special case handling when we upgrade. + def utf8_encoded_disposition(disposition, filename) + content = ::Gitlab::ContentDisposition.new(disposition: disposition, filename: filename) + + "#{disposition}; #{content.utf8_filename}" + end + def guess_content_type(filename) types = MIME::Types.type_for(filename) diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index c6ae4fe15bf..48451bedcc2 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -72,7 +72,7 @@ module ServiceParams dynamic_params = @service.event_channel_names + @service.event_names # rubocop:disable Gitlab/ModuleWithInstanceVariables service_params = params.permit(:id, service: allowed_service_params + dynamic_params) - if service_params[:service].is_a?(Hash) + if service_params[:service].is_a?(ActionController::Parameters) FILTER_BLANK_PARAMS.each do |param| service_params[:service].delete(param) if service_params[:service][param].blank? end diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index 0eea0cdd50f..4ec0e94df9a 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -7,12 +7,12 @@ module UploadsActions UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze def create - link_to_file = UploadService.new(model, params[:file], uploader_class).execute + uploader = UploadService.new(model, params[:file], uploader_class).execute respond_to do |format| - if link_to_file + if uploader format.json do - render json: { link: link_to_file } + render json: { link: uploader.to_h } end else format.json do @@ -29,7 +29,13 @@ module UploadsActions def show return render_404 unless uploader&.exists? - expires_in 0.seconds, must_revalidate: true, private: true + if cache_publicly? + # We need to reset caching from the applications controller to get rid of the no-store value + headers['Cache-Control'] = '' + expires_in 5.minutes, public: true, must_revalidate: false + else + expires_in 0.seconds, must_revalidate: true, private: true + end disposition = uploader.image_or_video? ? 'inline' : 'attachment' @@ -114,6 +120,10 @@ module UploadsActions nil end + def cache_publicly? + false + end + def model strong_memoize(:model) { find_model } end diff --git a/app/controllers/dashboard/application_controller.rb b/app/controllers/dashboard/application_controller.rb index cee0753a021..0e9fdc60363 100644 --- a/app/controllers/dashboard/application_controller.rb +++ b/app/controllers/dashboard/application_controller.rb @@ -2,6 +2,7 @@ class Dashboard::ApplicationController < ApplicationController include ControllerWithCrossProjectAccessCheck + include RecordUserLastActivity layout 'dashboard' diff --git a/app/controllers/dashboard/milestones_controller.rb b/app/controllers/dashboard/milestones_controller.rb index 3802aa5f40f..912036da0ea 100644 --- a/app/controllers/dashboard/milestones_controller.rb +++ b/app/controllers/dashboard/milestones_controller.rb @@ -25,9 +25,7 @@ class Dashboard::MilestonesController < Dashboard::ApplicationController private def group_milestones - groups = GroupsFinder.new(current_user, all_available: false).execute - - DashboardGroupMilestone.build_collection(groups) + DashboardGroupMilestone.build_collection(groups, params) end # See [#39545](https://gitlab.com/gitlab-org/gitlab-ce/issues/39545) for info about the deprecation of dynamic milestones @@ -45,6 +43,6 @@ class Dashboard::MilestonesController < Dashboard::ApplicationController end def groups - @groups ||= GroupsFinder.new(current_user, state_all: true).execute + @groups ||= GroupsFinder.new(current_user, all_available: false).execute end end diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index f073b6de444..b044affd4e8 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -13,7 +13,13 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController @projects = load_projects(params.merge(non_public: true)) respond_to do |format| - format.html + format.html do + # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37434 + # Also https://gitlab.com/gitlab-org/gitlab-ce/issues/40260 + Gitlab::GitalyClient.allow_n_plus_1_calls do + render + end + end format.atom do load_events render layout: 'xml.atom' @@ -53,6 +59,9 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def load_projects(finder_params) + @total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute + @total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute + projects = ProjectsFinder .new(params: finder_params, current_user: current_user) .execute diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index be2d9512c01..75329b05a6f 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true class DashboardController < Dashboard::ApplicationController - include IssuesAction - include MergeRequestsAction + include IssuableCollectionsAction prepend_before_action(only: [:issues]) { authenticate_sessionless_user!(:rss) } prepend_before_action(only: [:issues_calendar]) { authenticate_sessionless_user!(:ics) } diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index 778fdda8dbd..f3d76c5a478 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -15,7 +15,7 @@ class Explore::ProjectsController < Explore::ApplicationController format.html format.json do render json: { - html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects }) + html: view_to_html_string("explore/projects/_projects", locals: { projects: @projects }) } end end @@ -30,7 +30,7 @@ class Explore::ProjectsController < Explore::ApplicationController format.html format.json do render json: { - html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects }) + html: view_to_html_string("explore/projects/_projects", locals: { projects: @projects }) } end end @@ -44,7 +44,7 @@ class Explore::ProjectsController < Explore::ApplicationController format.html format.json do render json: { - html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects }) + html: view_to_html_string("explore/projects/_projects", locals: { projects: @projects }) } end end @@ -55,6 +55,9 @@ class Explore::ProjectsController < Explore::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def load_projects + @total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute + @total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute + projects = ProjectsFinder.new(current_user: current_user, params: params) .execute .includes(:route, :creator, :group, namespace: [:route, :owner]) diff --git a/app/controllers/google_api/authorizations_controller.rb b/app/controllers/google_api/authorizations_controller.rb index dd9f5af61b3..ed0995e7ffd 100644 --- a/app/controllers/google_api/authorizations_controller.rb +++ b/app/controllers/google_api/authorizations_controller.rb @@ -2,6 +2,10 @@ module GoogleApi class AuthorizationsController < ApplicationController + include Gitlab::Utils::StrongMemoize + + before_action :validate_session_key! + def callback token, expires_at = GoogleApi::CloudPlatform::Client .new(nil, callback_google_api_auth_url) @@ -11,21 +15,27 @@ module GoogleApi session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = expires_at.to_s - state_redirect_uri = redirect_uri_from_session_key(params[:state]) - - if state_redirect_uri - redirect_to state_redirect_uri - else - redirect_to root_path - end + redirect_to redirect_uri_from_session end private - def redirect_uri_from_session_key(state) - key = GoogleApi::CloudPlatform::Client - .session_key_for_redirect_uri(params[:state]) - session[key] if key + def validate_session_key! + access_denied! unless redirect_uri_from_session.present? + end + + def redirect_uri_from_session + strong_memoize(:redirect_uri_from_session) do + if params[:state].present? + session[session_key_for_redirect_uri(params[:state])] + else + nil + end + end + end + + def session_key_for_redirect_uri(state) + GoogleApi::CloudPlatform::Client.session_key_for_redirect_uri(state) end end end diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 3ef03bc9622..e147d32be2e 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -3,9 +3,16 @@ class GraphqlController < ApplicationController # Unauthenticated users have access to the API for public data skip_before_action :authenticate_user! - prepend_before_action(only: [:execute]) { authenticate_sessionless_user!(:api) } + + # Allow missing CSRF tokens, this would mean that if a CSRF is invalid or missing, + # the user won't be authenticated but can proceed as an anonymous user. + # + # If a CSRF is valid, the user is authenticated. This makes it easier to play + # around in GraphiQL. + protect_from_forgery with: :null_session, only: :execute before_action :check_graphql_feature_flag! + before_action(only: [:execute]) { authenticate_sessionless_user!(:api) } def execute variables = Gitlab::Graphql::Variables.new(params[:variables]).to_h diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index cdc6f53df8e..51fdb6c05fb 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -2,6 +2,7 @@ class Groups::BoardsController < Groups::ApplicationController include BoardsResponses + include RecordUserLastActivity before_action :assign_endpoint_vars before_action :boards, only: :index diff --git a/app/controllers/groups/children_controller.rb b/app/controllers/groups/children_controller.rb index d549f793ad7..236a19a8dc4 100644 --- a/app/controllers/groups/children_controller.rb +++ b/app/controllers/groups/children_controller.rb @@ -35,7 +35,7 @@ module Groups def setup_children(parent) @children = GroupDescendantsFinder.new(current_user: current_user, parent_group: parent, - params: params).execute + params: params.to_unsafe_h).execute @children = @children.page(params[:page]) end end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index b42116b0f36..7ed4384089b 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -43,14 +43,7 @@ class Groups::MilestonesController < Groups::ApplicationController def update # Keep this compatible with legacy group milestones where we have to update # all projects milestones states at once. - if @milestone.legacy_group_milestone? - update_params = milestone_params.select { |key| key == "state_event" } - milestones = @milestone.milestones - else - update_params = milestone_params - milestones = [@milestone] - end - + milestones, update_params = get_milestones_for_update milestones.each do |milestone| Milestones::UpdateService.new(milestone.parent, current_user, update_params).execute(milestone) end @@ -71,6 +64,14 @@ class Groups::MilestonesController < Groups::ApplicationController private + def get_milestones_for_update + if @milestone.legacy_group_milestone? + [@milestone.milestones, legacy_milestone_params] + else + [[@milestone], milestone_params] + end + end + def authorize_admin_milestones! return render_404 unless can?(current_user, :admin_milestone, group) end @@ -79,6 +80,10 @@ class Groups::MilestonesController < Groups::ApplicationController params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event) end + def legacy_milestone_params + params.require(:milestone).permit(:state_event) + end + def milestone_path if @milestone.legacy_group_milestone? group_milestone_path(group, @milestone.safe_title, title: @milestone.title) @@ -110,6 +115,6 @@ class Groups::MilestonesController < Groups::ApplicationController end def search_params - params.permit(:state).merge(group_ids: group.id) + params.permit(:state, :search_title).merge(group_ids: group.id) end end diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index c1dcc463de7..f476f428fdb 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -4,7 +4,7 @@ module Groups module Settings class CiCdController < Groups::ApplicationController skip_cross_project_access_check :show - before_action :authorize_admin_pipeline! + before_action :authorize_admin_group! def show define_ci_variables @@ -26,8 +26,8 @@ module Groups .map { |variable| variable.present(current_user: current_user) } end - def authorize_admin_pipeline! - return render_404 unless can?(current_user, :admin_pipeline, group) + def authorize_admin_group! + return render_404 unless can?(current_user, :admin_group, group) end end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index c5d8ac2ed77..4e50106398a 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -2,10 +2,10 @@ class GroupsController < Groups::ApplicationController include API::Helpers::RelatedResourcesHelpers - include IssuesAction - include MergeRequestsAction + include IssuableCollectionsAction include ParamsBackwardCompatibility include PreviewMarkdown + include RecordUserLastActivity respond_to :html diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index e5a1fc9d6ff..a9d6addd4a4 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -13,9 +13,10 @@ class HelpController < ApplicationController # Remove YAML frontmatter so that it doesn't look weird @help_index = File.read(Rails.root.join('doc', 'README.md')).sub(YAML_FRONT_MATTER_REGEXP, '') - # Prefix Markdown links with `help/` unless they are external links - # See http://rubular.com/r/X3baHTbPO2 - @help_index.gsub!(%r{(?<delim>\]\()(?!.+://)(?!/)(?<link>[^\)\(]+\))}) do + # Prefix Markdown links with `help/` unless they are external links. + # '//' not necessarily part of URL, e.g., mailto:mail@example.com + # See https://rubular.com/r/DFHZl5w8d3bpzV + @help_index.gsub!(%r{(?<delim>\]\()(?!\w+:)(?!/)(?<link>[^\)\(]+\))}) do "#{$~[:delim]}#{Gitlab.config.gitlab.relative_url_root}/help/#{$~[:link]}" end end diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb index 042b6b1264f..9b45be6db99 100644 --- a/app/controllers/import/base_controller.rb +++ b/app/controllers/import/base_controller.rb @@ -18,6 +18,7 @@ class Import::BaseController < ApplicationController end # rubocop: enable CodeReuse/ActiveRecord + # deprecated: being replaced by app/services/import/base_service.rb def find_or_create_namespace(names, owner) names = params[:target_namespace].presence || names @@ -32,6 +33,7 @@ class Import::BaseController < ApplicationController current_user.namespace end + # deprecated: being replaced by app/services/import/base_service.rb def project_save_error(project) project.errors.full_messages.join(', ') end diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 1b30b4dda36..2b1395f364f 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -8,7 +8,7 @@ class Import::BitbucketController < Import::BaseController rescue_from Bitbucket::Error::Unauthorized, with: :bitbucket_unauthorized def callback - response = client.auth_code.get_token(params[:code], redirect_uri: callback_import_bitbucket_url) + response = client.auth_code.get_token(params[:code], redirect_uri: users_import_bitbucket_callback_url) session[:bitbucket_token] = response.token session[:bitbucket_expires_at] = response.expires_at @@ -89,7 +89,7 @@ class Import::BitbucketController < Import::BaseController end def go_to_bitbucket_for_permissions - redirect_to client.auth_code.authorize_url(redirect_uri: callback_import_bitbucket_url) + redirect_to client.auth_code.authorize_url(redirect_uri: users_import_bitbucket_callback_url) end def bitbucket_unauthorized diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb index 575c40d5f6f..f333e43b892 100644 --- a/app/controllers/import/bitbucket_server_controller.rb +++ b/app/controllers/import/bitbucket_server_controller.rb @@ -13,7 +13,10 @@ class Import::BitbucketServerController < Import::BaseController # Repository names are limited to 128 characters. They must start with a # letter or number and may contain spaces, hyphens, underscores, and periods. # (https://community.atlassian.com/t5/Answers-Developer-Questions/stash-repository-names/qaq-p/499054) - VALID_BITBUCKET_CHARS = /\A[\w\-_\.\s]+\z/ + # + # Bitbucket Server starts personal project names with a tilde. + VALID_BITBUCKET_PROJECT_CHARS = /\A~?[\w\-\.\s]+\z/ + VALID_BITBUCKET_CHARS = /\A[\w\-\.\s]+\z/ def new end @@ -40,7 +43,7 @@ class Import::BitbucketServerController < Import::BaseController else render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity end - rescue BitbucketServer::Client::ServerError => e + rescue BitbucketServer::Connection::ConnectionError => e render json: { errors: "Unable to connect to server: #{e}" }, status: :unprocessable_entity end @@ -62,7 +65,7 @@ class Import::BitbucketServerController < Import::BaseController already_added_projects_names = @already_added_projects.pluck(:import_source) @repos.reject! { |repo| already_added_projects_names.include?(repo.browse_url) } - rescue BitbucketServer::Connection::ConnectionError, BitbucketServer::Client::ServerError => e + rescue BitbucketServer::Connection::ConnectionError => e flash[:alert] = "Unable to connect to server: #{e}" clear_session_data redirect_to new_import_bitbucket_server_path @@ -91,7 +94,7 @@ class Import::BitbucketServerController < Import::BaseController return render_validation_error('Missing project key') unless @project_key.present? && @repo_slug.present? return render_validation_error('Missing repository slug') unless @repo_slug.present? - return render_validation_error('Invalid project key') unless @project_key =~ VALID_BITBUCKET_CHARS + return render_validation_error('Invalid project key') unless @project_key =~ VALID_BITBUCKET_PROJECT_CHARS return render_validation_error('Invalid repository slug') unless @repo_slug =~ VALID_BITBUCKET_CHARS end diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb index f067ef625aa..68ad8650dba 100644 --- a/app/controllers/import/gitea_controller.rb +++ b/app/controllers/import/gitea_controller.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true class Import::GiteaController < Import::GithubController + extend ::Gitlab::Utils::Override + def new - if session[access_token_key].present? && session[host_key].present? + if session[access_token_key].present? && provider_url.present? redirect_to status_import_url end end @@ -12,8 +14,8 @@ class Import::GiteaController < Import::GithubController super end + # Must be defined or it will 404 def status - @gitea_host_url = session[host_key] super end @@ -23,25 +25,33 @@ class Import::GiteaController < Import::GithubController :"#{provider}_host_url" end - # Overridden methods + override :provider def provider :gitea end + override :provider_url + def provider_url + session[host_key] + end + # Gitea is not yet an OAuth provider # See https://github.com/go-gitea/gitea/issues/27 + override :logged_in_with_provider? def logged_in_with_provider? false end + override :provider_auth def provider_auth - if session[access_token_key].blank? || session[host_key].blank? + if session[access_token_key].blank? || provider_url.blank? redirect_to new_import_gitea_url, alert: 'You need to specify both an Access Token and a Host URL.' end end + override :client_options def client_options - { host: session[host_key], api_version: 'v1' } + { host: provider_url, api_version: 'v1' } end end diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index d4c26fa0709..aa4aa0fbdac 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true class Import::GithubController < Import::BaseController + include ImportHelper + before_action :verify_import_enabled - before_action :provider_auth, only: [:status, :jobs, :create] + before_action :provider_auth, only: [:status, :realtime_changes, :create] + before_action :expire_etag_cache, only: [:status, :create] rescue_from Octokit::Unauthorized, with: :provider_unauthorized @@ -24,47 +27,86 @@ class Import::GithubController < Import::BaseController redirect_to status_import_url end - # rubocop: disable CodeReuse/ActiveRecord def status - @repos = client.repos - @already_added_projects = find_already_added_projects(provider) - already_added_projects_names = @already_added_projects.pluck(:import_source) - - @repos.reject! { |repo| already_added_projects_names.include? repo.full_name } - end - # rubocop: enable CodeReuse/ActiveRecord - - def jobs - render json: find_jobs(provider) + # Request repos to display error page if provider token is invalid + # Improving in https://gitlab.com/gitlab-org/gitlab-ce/issues/55585 + client_repos + + respond_to do |format| + format.json do + render json: { imported_projects: serialized_imported_projects, + provider_repos: serialized_provider_repos, + namespaces: serialized_namespaces } + end + format.html + end end def create - repo = client.repo(params[:repo_id].to_i) - project_name = params[:new_name].presence || repo.name - namespace_path = params[:target_namespace].presence || current_user.namespace_path - target_namespace = find_or_create_namespace(namespace_path, current_user.namespace_path) - - if can?(current_user, :create_projects, target_namespace) - project = Gitlab::LegacyGithubImport::ProjectCreator - .new(repo, project_name, target_namespace, current_user, access_params, type: provider) - .execute(extra_project_attrs) - - if project.persisted? - render json: ProjectSerializer.new.represent(project) - else - render json: { errors: project_save_error(project) }, status: :unprocessable_entity - end + result = Import::GithubService.new(client, current_user, import_params).execute(access_params, provider) + + if result[:status] == :success + render json: serialized_imported_projects(result[:project]) else - render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity + render json: { errors: result[:message] }, status: result[:http_status] end end + def realtime_changes + Gitlab::PollingInterval.set_header(response, interval: 3_000) + + render json: find_jobs(provider) + end + private + def import_params + params.permit(permitted_import_params) + end + + def permitted_import_params + [:repo_id, :new_name, :target_namespace] + end + + def serialized_imported_projects(projects = already_added_projects) + ProjectSerializer.new.represent(projects, serializer: :import, provider_url: provider_url) + end + + def serialized_provider_repos + repos = client_repos.reject { |repo| already_added_project_names.include? repo.full_name } + ProviderRepoSerializer.new(current_user: current_user).represent(repos, provider: provider, provider_url: provider_url) + end + + def serialized_namespaces + NamespaceSerializer.new.represent(namespaces) + end + + def already_added_projects + @already_added_projects ||= find_already_added_projects(provider) + end + + def already_added_project_names + @already_added_projects_names ||= already_added_projects.pluck(:import_source) # rubocop:disable CodeReuse/ActiveRecord + end + + def namespaces + current_user.manageable_groups_with_routes + end + + def expire_etag_cache + Gitlab::EtagCaching::Store.new.tap do |store| + store.touch(realtime_changes_path) + end + end + def client @client ||= Gitlab::LegacyGithubImport::Client.new(session[access_token_key], client_options) end + def client_repos + @client_repos ||= client.repos + end + def verify_import_enabled render_404 unless import_enabled? end @@ -77,6 +119,10 @@ class Import::GithubController < Import::BaseController __send__("#{provider}_import_enabled?") # rubocop:disable GitlabSecurity/PublicSend end + def realtime_changes_path + public_send("realtime_changes_import_#{provider}_path", format: :json) # rubocop:disable GitlabSecurity/PublicSend + end + def new_import_url public_send("new_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend end @@ -86,7 +132,7 @@ class Import::GithubController < Import::BaseController end def callback_import_url - public_send("callback_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend + public_send("users_import_#{provider}_callback_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend end def provider_unauthorized @@ -108,6 +154,14 @@ class Import::GithubController < Import::BaseController :github end + def provider_url + strong_memoize(:provider_url) do + provider = Gitlab::Auth::OAuth::Provider.config_for('github') + + provider&.dig('url').presence || 'https://github.com' + end + end + # rubocop: disable CodeReuse/ActiveRecord def logged_in_with_provider? current_user.identities.exists?(provider: provider) @@ -124,10 +178,6 @@ class Import::GithubController < Import::BaseController {} end - def extra_project_attrs - {} - end - def extra_import_params {} end diff --git a/app/controllers/notification_settings_controller.rb b/app/controllers/notification_settings_controller.rb index 384f308269a..43c4f4d220e 100644 --- a/app/controllers/notification_settings_controller.rb +++ b/app/controllers/notification_settings_controller.rb @@ -17,7 +17,8 @@ class NotificationSettingsController < ApplicationController @saved = @notification_setting.update(notification_setting_params_for(@notification_setting.source)) if params[:hide_label].present? - render_response("projects/buttons/_notifications") + btn_class = params[:project_id].present? ? 'btn-xs' : '' + render_response("shared/notifications/_new_button", btn_class) else render_response end @@ -41,9 +42,9 @@ class NotificationSettingsController < ApplicationController can?(current_user, ability_name, resource) end - def render_response(response_template = "shared/notifications/_button") + def render_response(response_template = "shared/notifications/_button", btn_class = nil) render json: { - html: view_to_html_string(response_template, notification_setting: @notification_setting), + html: view_to_html_string(response_template, notification_setting: @notification_setting, btn_class: btn_class), saved: @saved } end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 30be50d4595..cc2bb99f55b 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -4,7 +4,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController include AuthenticatesWithTwoFactor include Devise::Controllers::Rememberable - protect_from_forgery except: [:kerberos, :saml, :cas3], prepend: true + protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true def handle_omniauth omniauth_flow(Gitlab::Auth::OAuth) @@ -75,6 +75,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController private def omniauth_flow(auth_module, identity_linker: nil) + if fragment = request.env.dig('omniauth.params', 'redirect_fragment').presence + store_redirect_fragment(fragment) + end + if current_user log_audit_event(current_user, with: oauth['provider']) @@ -112,8 +116,12 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController session[:service_tickets][provider] = ticket end + def build_auth_user(auth_user_class) + auth_user_class.new(oauth) + end + def sign_in_user_flow(auth_user_class) - auth_user = auth_user_class.new(oauth) + auth_user = build_auth_user(auth_user_class) user = auth_user.find_and_update! if auth_user.valid_sign_in? @@ -189,4 +197,13 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController request_params = request.env['omniauth.params'] (request_params['remember_me'] == '1') if request_params.present? end + + def store_redirect_fragment(redirect_fragment) + key = stored_location_key_for(:user) + location = session[key] + if uri = parse_uri(location) + uri.fragment = redirect_fragment + store_location_for(:user, uri.to_s) + end + end end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 2912a22411e..28f113b5cbe 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -5,7 +5,7 @@ class PasswordsController < Devise::PasswordsController before_action :resource_from_email, only: [:create] before_action :check_password_authentication_available, only: [:create] - before_action :throttle_reset, only: [:create] + before_action :throttle_reset, only: [:create] # rubocop: disable CodeReuse/ActiveRecord def edit diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb index efe7ede5efa..c473023cacb 100644 --- a/app/controllers/profiles/active_sessions_controller.rb +++ b/app/controllers/profiles/active_sessions_controller.rb @@ -2,15 +2,6 @@ class Profiles::ActiveSessionsController < Profiles::ApplicationController def index - @sessions = ActiveSession.list(current_user) - end - - def destroy - ActiveSession.destroy(current_user, params[:id]) - - respond_to do |format| - format.html { redirect_to profile_active_sessions_url, status: :found } - format.js { head :ok } - end + @sessions = ActiveSession.list(current_user).reject(&:is_impersonated) end end diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 37ac11dc6a1..0227af2c266 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -33,12 +33,18 @@ class Profiles::PreferencesController < Profiles::ApplicationController end def preferences_params - params.require(:user).permit( + params.require(:user).permit(preferences_param_names) + end + + def preferences_param_names + [ :color_scheme_id, :layout, :dashboard, :project_view, - :theme_id - ) + :theme_id, + :first_day_of_week, + :preferred_language + ] end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 15248d2d08f..b9c52618d4b 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -104,7 +104,6 @@ class ProfilesController < Profiles::ApplicationController :username, :website_url, :organization, - :preferred_language, :private_profile, :include_private_contributions, status: [:emoji, :message] diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index e0677ce3fbc..6504fd6c08a 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -17,7 +17,7 @@ class Projects::ApplicationController < ApplicationController def project return @project if @project - return nil unless params[:project_id] || params[:id] + return unless params[:project_id] || params[:id] path = File.join(params[:namespace_id], params[:project_id] || params[:id]) auth_proc = ->(project) { !project.pending_delete? } diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 1a91e07b97f..2ef18d900f2 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -85,20 +85,15 @@ class Projects::ArtifactsController < Projects::ApplicationController end end - # rubocop: disable CodeReuse/ActiveRecord def build_from_id - project.builds.find_by(id: params[:job_id]) if params[:job_id] + project.builds.find_by_id(params[:job_id]) if params[:job_id] end - # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord def build_from_ref return unless @ref_name - builds = project.latest_successful_builds_for(@ref_name) - builds.find_by(name: params[:job]) + project.latest_successful_build_for(params[:job], @ref_name) end - # rubocop: enable CodeReuse/ActiveRecord def artifacts_file @artifacts_file ||= build&.artifacts_file_for_type(params[:file_type] || :archive) diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index 9c130af8394..0e3f13045ce 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Projects::AutocompleteSourcesController < Projects::ApplicationController + before_action :authorize_read_milestone!, only: :milestones + def members render json: ::Projects::ParticipantsService.new(@project, current_user).execute(target) end diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb index c24bf211760..09a384e89ab 100644 --- a/app/controllers/projects/badges_controller.rb +++ b/app/controllers/projects/badges_controller.rb @@ -21,11 +21,22 @@ class Projects::BadgesController < Projects::ApplicationController private + def badge_layout + case params[:style] + when 'flat' + 'badge' + when 'flat-square' + 'badge_flat-square' + else + 'badge' + end + end + def render_badge(badge) respond_to do |format| format.html { render_404 } format.svg do - render 'badge', locals: { badge: badge.template } + render badge_layout, locals: { badge: badge.template } end end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 60fabd15333..77672e7d9fc 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -93,7 +93,7 @@ class Projects::BlobController < Projects::ApplicationController @blob.load_all_data! @lines = @blob.present.highlight.lines - @form = UnfoldForm.new(params) + @form = UnfoldForm.new(params.to_unsafe_h) @lines = @lines[@form.since - 1..@form.to - 1].map(&:html_safe) @@ -260,7 +260,7 @@ class Projects::BlobController < Projects::ApplicationController extension: blob.extension, size: blob.raw_size, mime_type: blob.mime_type, - binary: blob.raw_binary?, + binary: blob.binary?, simple_viewer: blob.simple_viewer&.class&.partial_name, rich_viewer: blob.rich_viewer&.class&.partial_name, show_viewer_switcher: !!blob.show_viewer_switcher?, diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index a6bfb913900..32b7f3207ef 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -29,7 +29,8 @@ class Projects::BranchesController < Projects::ApplicationController Gitlab::GitalyClient.allow_n_plus_1_calls do @max_commits = @branches.reduce(0) do |memo, branch| diverging_commit_counts = repository.diverging_commit_counts(branch) - [memo, diverging_commit_counts[:behind], diverging_commit_counts[:ahead]].max + [memo, diverging_commit_counts.values_at(:behind, :ahead, :distance)] + .flatten.compact.max end end diff --git a/app/controllers/projects/build_artifacts_controller.rb b/app/controllers/projects/build_artifacts_controller.rb index 7d4d566499c..4274c356227 100644 --- a/app/controllers/projects/build_artifacts_controller.rb +++ b/app/controllers/projects/build_artifacts_controller.rb @@ -44,18 +44,13 @@ class Projects::BuildArtifactsController < Projects::ApplicationController @job ||= job_from_id || job_from_ref end - # rubocop: disable CodeReuse/ActiveRecord def job_from_id - project.builds.find_by(id: params[:build_id]) if params[:build_id] + project.builds.find_by_id(params[:build_id]) if params[:build_id] end - # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord def job_from_ref return unless @ref_name - jobs = project.latest_successful_builds_for(@ref_name) - jobs.find_by(name: params[:job]) + project.latest_successful_build_for(params[:job], @ref_name) end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb index 2090af0a111..d7a0b7ece14 100644 --- a/app/controllers/projects/ci/lints_controller.rb +++ b/app/controllers/projects/ci/lints_controller.rb @@ -8,7 +8,7 @@ class Projects::Ci::LintsController < Projects::ApplicationController def create @content = params[:content] - @error = Gitlab::Ci::YamlProcessor.validation_message(@content, yaml_processor_options) + @error = Gitlab::Ci::YamlProcessor.validation_message(@content, yaml_processor_options) @status = @error.blank? if @error.blank? @@ -24,6 +24,10 @@ class Projects::Ci::LintsController < Projects::ApplicationController private def yaml_processor_options - { project: @project, sha: project.repository.commit.sha } + { + project: @project, + user: current_user, + sha: project.repository.commit.sha + } end end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 32fc5140366..939a09d4fd2 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -24,10 +24,10 @@ class Projects::CommitController < Projects::ApplicationController apply_diff_view_cookie! respond_to do |format| - format.html do + format.html do render end - format.diff do + format.diff do send_git_diff(@project.repository, @commit.diff_refs) end format.patch do @@ -65,7 +65,11 @@ class Projects::CommitController < Projects::ApplicationController # rubocop: enable CodeReuse/ActiveRecord def merge_requests - @merge_requests = @commit.merge_requests.map do |mr| + @merge_requests = MergeRequestsFinder.new( + current_user, + project_id: @project.id, + commit_sha: @commit.sha + ).execute.map do |mr| { iid: mr.iid, path: merge_request_path(mr), title: mr.title } end diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index b62606067c0..028390c7e2a 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -40,7 +40,7 @@ class Projects::DiscussionsController < Projects::ApplicationController def render_json_with_discussions_serializer render json: - DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user, note_entity: ProjectNoteEntity) + DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user, note_entity: ProjectNoteEntity) .represent(discussion, context: self, render_truncated_diff_lines: true) end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index a63eea0ca0e..e9cd475a199 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -11,10 +11,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :expire_etag_cache, only: [:index] - before_action do - push_frontend_feature_flag(:area_chart, project) - end - def index @environments = project.environments .with_state(params[:scope] || :available) @@ -25,11 +21,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController Gitlab::PollingInterval.set_header(response, interval: 3_000) render json: { - environments: EnvironmentSerializer - .new(project: @project, current_user: @current_user) - .with_pagination(request, response) - .within_folders - .represent(@environments), + environments: serialize_environments(request, response, params[:nested]), available_count: project.environments.available.count, stopped_count: project.environments.stopped.count } @@ -37,6 +29,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController end end + # Returns all environments for a given folder # rubocop: disable CodeReuse/ActiveRecord def folder folder_environments = project.environments.where(environment_type: params[:id]) @@ -48,10 +41,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController format.html format.json do render json: { - environments: EnvironmentSerializer - .new(project: @project, current_user: @current_user) - .with_pagination(request, response) - .represent(@environments), + environments: serialize_environments(request, response), available_count: folder_environments.available.count, stopped_count: folder_environments.stopped.count } @@ -163,6 +153,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController end end + def search + respond_to do |format| + format.json do + environment_names = search_environment_names + + render json: environment_names, status: environment_names.any? ? :ok : :no_content + end + end + end + private def verify_api_request! @@ -186,6 +186,20 @@ class Projects::EnvironmentsController < Projects::ApplicationController @environment ||= project.environments.find(params[:id]) end + def search_environment_names + return [] unless params[:query] + + project.environments.for_name_like(params[:query]).pluck_names + end + + def serialize_environments(request, response, nested = false) + EnvironmentSerializer + .new(project: @project, current_user: @current_user) + .tap { |serializer| serializer.within_folders if nested } + .with_pagination(request, response) + .represent(@environments) + end + def authorize_stop_environment! access_denied! unless can?(current_user, :stop_environment, environment) end diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb new file mode 100644 index 00000000000..88d0755f41f --- /dev/null +++ b/app/controllers/projects/error_tracking_controller.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +class Projects::ErrorTrackingController < Projects::ApplicationController + before_action :authorize_read_sentry_issue! + + POLLING_INTERVAL = 10_000 + + def index + respond_to do |format| + format.html + format.json do + set_polling_interval + render_index_json + end + end + end + + def list_projects + respond_to do |format| + format.json do + render_project_list_json + end + end + end + + private + + def render_index_json + service = ErrorTracking::ListIssuesService.new(project, current_user) + result = service.execute + + unless result[:status] == :success + return render json: { message: result[:message] }, + status: result[:http_status] || :bad_request + end + + render json: { + errors: serialize_errors(result[:issues]), + external_url: service.external_url + } + end + + def render_project_list_json + service = ErrorTracking::ListProjectsService.new( + project, + current_user, + list_projects_params + ) + result = service.execute + + if result[:status] == :success + render json: { + projects: serialize_projects(result[:projects]) + } + else + return render( + status: result[:http_status] || :bad_request, + json: { + message: result[:message] + } + ) + end + end + + def list_projects_params + params.require(:error_tracking_setting).permit([:api_host, :token]) + end + + def set_polling_interval + Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) + end + + def serialize_errors(errors) + ErrorTracking::ErrorSerializer + .new(project: project, user: current_user) + .represent(errors) + end + + def serialize_projects(projects) + ErrorTracking::ProjectSerializer + .new(project: project, user: current_user) + .represent(projects) + end +end diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index c0aa39d87c6..0c5328fc941 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -20,6 +20,8 @@ class Projects::GitHttpController < Projects::GitHttpClientController # POST /foo/bar.git/git-upload-pack (git pull) def git_upload_pack + enqueue_fetch_statistics_update + render_ok end @@ -67,6 +69,13 @@ class Projects::GitHttpController < Projects::GitHttpClientController render plain: exception.message, status: :service_unavailable end + def enqueue_fetch_statistics_update + return if wiki? + return unless project.daily_statistics_enabled? + + ProjectDailyStatisticsWorker.perform_async(project.id) + end + def access @access ||= access_klass.new(access_actor, project, 'http', authentication_abilities: authentication_abilities, @@ -80,9 +89,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController end def access_check - # Use the magic string '_any' to indicate we do not know what the - # changes are. This is also what gitlab-shell does. - access.check(git_command, '_any') + access.check(git_command, Gitlab::GitAccess::ANY) @project ||= access.project end diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb index 925b6ed9bfd..c80fce513f6 100644 --- a/app/controllers/projects/graphs_controller.rb +++ b/app/controllers/projects/graphs_controller.rb @@ -45,7 +45,14 @@ class Projects::GraphsController < Projects::ApplicationController end def get_languages - @languages = @project.repository.languages + @languages = + if @project.repository_languages.present? + @project.repository_languages.map do |lang| + { value: lang.share, label: lang.name, color: lang.color, highlight: lang.color } + end + else + @project.repository.languages + end end def fetch_graph diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb index 7c713c19762..bc942ba9288 100644 --- a/app/controllers/projects/group_links_controller.rb +++ b/app/controllers/projects/group_links_controller.rb @@ -13,9 +13,10 @@ class Projects::GroupLinksController < Projects::ApplicationController group = Group.find(params[:link_group_id]) if params[:link_group_id].present? if group - return render_404 unless can?(current_user, :read_group, group) + result = Projects::GroupLinks::CreateService.new(project, current_user, group_link_create_params).execute(group) + return render_404 if result[:http_status] == 404 - Projects::GroupLinks::CreateService.new(project, current_user, group_link_create_params).execute(group) + flash[:alert] = result[:message] if result[:http_status] == 409 else flash[:alert] = 'Please select a group.' end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 5ed46fc0545..b9d02a62fc3 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -8,9 +8,10 @@ class Projects::IssuesController < Projects::ApplicationController include IssuableCollections include IssuesCalendar include SpammableActions + include RecordUserLastActivity def self.issue_except_actions - %i[index calendar new create bulk_update] + %i[index calendar new create bulk_update import_csv] end def self.set_issuables_index_only_actions @@ -19,7 +20,7 @@ class Projects::IssuesController < Projects::ApplicationController prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) } - prepend_before_action :authenticate_new_issue!, only: [:new] + prepend_before_action :authenticate_user!, only: [:new] prepend_before_action :store_uri, only: [:new, :show] before_action :whitelist_query_limiting, only: [:create, :create_merge_request, :move, :bulk_update] @@ -37,6 +38,8 @@ class Projects::IssuesController < Projects::ApplicationController # Allow create a new branch and empty WIP merge request from current issue before_action :authorize_create_merge_request_from!, only: [:create_merge_request] + before_action :authorize_import_issues!, only: [:import_csv] + before_action :set_suggested_issues_feature_flags, only: [:new] respond_to :html @@ -175,8 +178,24 @@ class Projects::IssuesController < Projects::ApplicationController end end + def import_csv + if uploader = UploadService.new(project, params[:file]).execute + ImportIssuesCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id) + + flash[:notice] = _("Your issues are being imported. Once finished, you'll get a confirmation email.") + else + flash[:alert] = _("File upload error.") + end + + redirect_to project_issues_path(project) + end + protected + def issuable_sorting_field + Issue::SORTING_PREFERENCE_FIELD + end + # rubocop: disable CodeReuse/ActiveRecord def issue return @issue if defined?(@issue) @@ -228,15 +247,7 @@ class Projects::IssuesController < Projects::ApplicationController task_num lock_version discussion_locked - ] + [{ label_ids: [], assignee_ids: [] }] - end - - def authenticate_new_issue! - return if current_user - - notice = "Please sign in to create the new issue." - - redirect_to new_user_session_path, notice: notice + ] + [{ label_ids: [], assignee_ids: [], update_task: [:index, :checked, :line_number, :line_source] }] end def store_uri diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index bfbbcba883f..d5ce790e2d9 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -4,10 +4,10 @@ class Projects::JobsController < Projects::ApplicationController include SendFileUpload include ContinueParams - before_action :build, except: [:index, :cancel_all] + before_action :build, except: [:index] before_action :authorize_read_build! before_action :authorize_update_build!, - except: [:index, :show, :status, :raw, :trace, :cancel_all, :erase] + except: [:index, :show, :status, :raw, :trace, :erase] before_action :authorize_erase_build!, only: [:erase] before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_websocket_authorize] before_action :verify_api_request!, only: :terminal_websocket_authorize @@ -39,16 +39,6 @@ class Projects::JobsController < Projects::ApplicationController end # rubocop: enable CodeReuse/ActiveRecord - def cancel_all - return access_denied! unless can?(current_user, :update_build, project) - - @project.builds.running_or_pending.each do |build| - build.cancel if can?(current_user, :update_build, build) - end - - redirect_to project_jobs_path(project) - end - # rubocop: disable CodeReuse/ActiveRecord def show @pipeline = @build.pipeline diff --git a/app/controllers/projects/lfs_locks_api_controller.rb b/app/controllers/projects/lfs_locks_api_controller.rb index fc67cd72faa..6aacb9d9a56 100644 --- a/app/controllers/projects/lfs_locks_api_controller.rb +++ b/app/controllers/projects/lfs_locks_api_controller.rb @@ -4,19 +4,19 @@ class Projects::LfsLocksApiController < Projects::GitHttpClientController include LfsRequest def create - @result = Lfs::LockFileService.new(project, user, params).execute + @result = Lfs::LockFileService.new(project, user, lfs_params).execute render_json(@result[:lock]) end def unlock - @result = Lfs::UnlockFileService.new(project, user, params).execute + @result = Lfs::UnlockFileService.new(project, user, lfs_params).execute render_json(@result[:lock]) end def index - @result = Lfs::LocksFinderService.new(project, user, params).execute + @result = Lfs::LocksFinderService.new(project, user, lfs_params).execute render_json(@result[:locks]) end @@ -69,4 +69,8 @@ class Projects::LfsLocksApiController < Projects::GitHttpClientController def upload_request? %w(create unlock verify).include?(params[:action]) end + + def lfs_params + params.permit(:id, :path, :force) + end end diff --git a/app/controllers/projects/lfs_storage_controller.rb b/app/controllers/projects/lfs_storage_controller.rb index babeee48ef3..013e01b82aa 100644 --- a/app/controllers/projects/lfs_storage_controller.rb +++ b/app/controllers/projects/lfs_storage_controller.rb @@ -5,7 +5,7 @@ class Projects::LfsStorageController < Projects::GitHttpClientController include WorkhorseRequest include SendFileUpload - skip_before_action :verify_workhorse_api!, only: [:download, :upload_finalize] + skip_before_action :verify_workhorse_api!, only: :download def download lfs_object = LfsObject.find_by_oid(oid) diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 368ee89ff5c..6045ee4e171 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -34,13 +34,17 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont :task_num, :title, :discussion_locked, - label_ids: [] + label_ids: [], + update_task: [:index, :checked, :line_number, :line_source] ] end def set_pipeline_variables - @pipelines = @merge_request.all_pipelines - @pipeline = @merge_request.head_pipeline - @statuses_count = @pipeline.present? ? @pipeline.statuses.relevant.count : 0 + @pipelines = + if can?(current_user, :read_pipeline, @project) + @merge_request.all_pipelines + else + Ci::Pipeline.none + end end end diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 5639402a1e9..32cefe54613 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -89,7 +89,11 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap def build_merge_request params[:merge_request] ||= ActionController::Parameters.new(source_project: @project) - @merge_request = ::MergeRequests::BuildService.new(project, current_user, merge_request_params.merge(diff_options: diff_options)).execute + + # Gitaly N+1 issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/58096 + Gitlab::GitalyClient.allow_n_plus_1_calls do + @merge_request = ::MergeRequests::BuildService.new(project, current_user, merge_request_params.merge(diff_options: diff_options)).execute + end end def define_new_vars diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index ddffbb17ace..456d2c34768 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class Projects::MergeRequests::DiffsController < Projects::MergeRequests::ApplicationController - include DiffForPath include DiffHelper include RendersNotes @@ -47,8 +46,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic # rubocop: disable CodeReuse/ActiveRecord def commit - return nil unless commit_id = params[:commit_id].presence - return nil unless @merge_request.all_commits.exists?(sha: commit_id) + return unless commit_id = params[:commit_id].presence + return unless @merge_request.all_commits.exists?(sha: commit_id) @commit ||= @project.commit(commit_id) end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 8b8eac7ff8e..46a44841c31 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -7,6 +7,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo include RendersCommits include ToggleAwardEmoji include IssuableCollections + include RecordUserLastActivity skip_before_action :merge_request, only: [:index, :bulk_update] before_action :whitelist_query_limiting, only: [:assign_related_issues, :update] @@ -15,6 +16,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action :authenticate_user!, only: [:assign_related_issues] before_action :check_user_can_push_to_source_branch!, only: [:rebase] + before_action only: [:show] do + push_frontend_feature_flag(:diff_tree_filtering, default_enabled: true) + end + def index @merge_requests = @issuables @@ -55,7 +60,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo render json: serializer.represent(@merge_request, serializer: params[:serializer]) end - format.patch do + format.patch do break render_404 unless @merge_request.diff_refs send_git_patch @project.repository, @merge_request.diff_refs @@ -218,18 +223,28 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo head :ok end + def discussions + merge_request.preload_discussions_diff_highlight + + super + end + protected alias_method :subscribable_resource, :merge_request alias_method :issuable, :merge_request alias_method :awardable, :merge_request + def issuable_sorting_field + MergeRequest::SORTING_PREFERENCE_FIELD + end + def merge_params params.permit(merge_params_attributes) end def merge_params_attributes - [:should_remove_source_branch, :commit_message, :squash] + [:should_remove_source_branch, :commit_message, :squash_commit_message, :squash] end def merge_when_pipeline_succeeds_active? diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 8e68014a30d..f6f61b6e5fb 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -144,9 +144,9 @@ class Projects::MilestonesController < Projects::ApplicationController def search_params if request.format.json? && project_group && can?(current_user, :read_group, project_group) - groups = project_group.self_and_ancestors_ids + groups = project_group.self_and_ancestors.select(:id) end - params.permit(:state).merge(project_ids: @project.id, group_ids: groups) + params.permit(:state, :search_title).merge(project_ids: @project.id, group_ids: groups) end end diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index c1ad6707c97..73e629ab7c3 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -5,7 +5,8 @@ class Projects::PagesController < Projects::ApplicationController before_action :require_pages_enabled! before_action :authorize_read_pages!, only: [:show] - before_action :authorize_update_pages!, except: [:show] + before_action :authorize_update_pages!, except: [:show, :destroy] + before_action :authorize_remove_pages!, only: [:destroy] # rubocop: disable CodeReuse/ActiveRecord def show @@ -18,7 +19,7 @@ class Projects::PagesController < Projects::ApplicationController project.pages_domains.destroy_all # rubocop: disable DestroyAll respond_to do |format| - format.html do + format.html do redirect_to project_pages_path(@project), status: 302, notice: 'Pages were removed' diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb index 439ec9b1731..58b1bc54181 100644 --- a/app/controllers/projects/pages_domains_controller.rb +++ b/app/controllers/projects/pages_domains_controller.rb @@ -4,7 +4,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController layout 'project_settings' before_action :require_pages_enabled! - before_action :authorize_update_pages!, except: [:show] + before_action :authorize_update_pages! before_action :domain, except: [:new, :create] def show diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 67827b1d3bb..6a86f8ca729 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -4,6 +4,7 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :whitelist_query_limiting, only: [:create, :retry] before_action :pipeline, except: [:index, :new, :create, :charts] before_action :authorize_read_pipeline! + before_action :authorize_read_build!, only: [:index] before_action :authorize_create_pipeline!, only: [:new, :create] before_action :authorize_update_pipeline!, only: [:retry, :cancel] @@ -69,7 +70,7 @@ class Projects::PipelinesController < Projects::ApplicationController render json: PipelineSerializer .new(project: @project, current_user: @current_user) - .represent(@pipeline, grouped: true) + .represent(@pipeline, show_represent_params) end end end @@ -157,6 +158,10 @@ class Projects::PipelinesController < Projects::ApplicationController end end + def show_represent_params + { grouped: true } + end + def create_params params.require(:pipeline).permit(:ref, variables_attributes: %i[key secret_value]) end diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb index 192e6d38f36..7f988110977 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -4,6 +4,6 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController before_action :authorize_admin_pipeline! def show - redirect_to project_settings_ci_cd_path(@project, params: params) + redirect_to project_settings_ci_cd_path(@project, params: params.to_unsafe_h) end end diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index 58d5ea4762f..4c39ee4045f 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -3,17 +3,8 @@ class Projects::ReleasesController < Projects::ApplicationController # Authorize before_action :require_non_empty_project - before_action :authorize_download_code! - before_action :check_releases_page_feature_flag + before_action :authorize_read_release! def index end - - private - - def check_releases_page_feature_flag - return render_404 unless Feature.enabled?(:releases_page) - - push_frontend_feature_flag(:releases_page) - end end diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb index 0af2b7ef343..39eca10134f 100644 --- a/app/controllers/projects/serverless/functions_controller.rb +++ b/app/controllers/projects/serverless/functions_controller.rb @@ -7,19 +7,17 @@ module Projects before_action :authorize_read_cluster! - INDEX_PRIMING_INTERVAL = 10_000 - INDEX_POLLING_INTERVAL = 30_000 + INDEX_PRIMING_INTERVAL = 15_000 + INDEX_POLLING_INTERVAL = 60_000 def index - finder = Projects::Serverless::FunctionsFinder.new(project.clusters) - respond_to do |format| format.json do functions = finder.execute if functions.any? Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL) - render json: Projects::Serverless::ServiceSerializer.new(current_user: @current_user).represent(functions) + render json: serialize_function(functions) else Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL) head :no_content @@ -32,6 +30,29 @@ module Projects end end end + + def show + @service = serialize_function(finder.service(params[:environment_id], params[:id])) + return not_found if @service.nil? + + respond_to do |format| + format.json do + render json: @service + end + + format.html + end + end + + private + + def finder + Projects::Serverless::FunctionsFinder.new(project.clusters) + end + + def serialize_function(function) + Projects::Serverless::ServiceSerializer.new(current_user: @current_user, project: project).represent(function) + end end end end diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 75e590f3f33..f2f63e986bb 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -99,7 +99,9 @@ module Projects def define_triggers_variables @triggers = @project.triggers + .present(current_user: current_user) @trigger = ::Ci::Trigger.new + .present(current_user: current_user) end def define_badges_variables diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb new file mode 100644 index 00000000000..7276964b6e1 --- /dev/null +++ b/app/controllers/projects/settings/operations_controller.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Projects + module Settings + class OperationsController < Projects::ApplicationController + before_action :check_license + before_action :authorize_update_environment! + + helper_method :error_tracking_setting + + def show + end + + def update + result = ::Projects::Operations::UpdateService.new(project, current_user, update_params).execute + + render_update_response(result) + end + + private + + # overridden in EE + def render_update_response(result) + respond_to do |format| + format.json do + render_update_json_response(result) + end + end + end + + def render_update_json_response(result) + if result[:status] == :success + flash[:notice] = _('Your changes have been saved') + render json: { + status: result[:status] + } + else + render( + status: result[:http_status] || :bad_request, + json: { + status: result[:status], + message: result[:message] + } + ) + end + end + + def error_tracking_setting + @error_tracking_setting ||= project.error_tracking_setting || + project.build_error_tracking_setting + end + + def update_params + params.require(:project).permit(permitted_project_params) + end + + # overridden in EE + def permitted_project_params + { + error_tracking_setting_attributes: [ + :enabled, + :api_host, + :token, + project: [:slug, :name, :organization_slug, :organization_name] + ] + } + end + + def check_license + render_404 unless helpers.settings_operations_available? + end + end + end +end diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index a44acb12bdf..255f1f3569a 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -75,7 +75,14 @@ class Projects::SnippetsController < Projects::ApplicationController format.json do render_blob_json(blob) end - format.js { render 'shared/snippets/show'} + + format.js do + if @snippet.embeddable? + render 'shared/snippets/show' + else + head :not_found + end + end end end diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index a50a1475eb2..a17c050b696 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -43,9 +43,22 @@ class Projects::TagsController < Projects::ApplicationController def create result = ::Tags::CreateService.new(@project, current_user) - .execute(params[:tag_name], params[:ref], params[:message], params[:release_description]) + .execute(params[:tag_name], params[:ref], params[:message]) if result[:status] == :success + # Release creation with Tags was deprecated in GitLab 11.7 + if params[:release_description].present? + release_params = { + tag: params[:tag_name], + name: params[:tag_name], + description: params[:release_description] + } + + Releases::CreateService + .new(@project, current_user, release_params) + .execute + end + @tag = result[:tag] redirect_to project_tag_path(@project, @tag.name) diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 3fe300dcfc0..edebfc55c17 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -31,20 +31,6 @@ class Projects::TreeController < Projects::ApplicationController lfs_blob_ids @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit end - - format.js do - # Disable cache so browser history works - no_cache_headers - end - - format.json do - page_title @path.presence || _("Files"), @ref, @project.full_name - - # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/38261 - Gitlab::GitalyClient.allow_n_plus_1_calls do - render json: TreeSerializer.new(project: @project, repository: @repository, ref: @ref).represent(@tree) - end - end end end diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb index f5fdfb8accc..c7b4ebb2b24 100644 --- a/app/controllers/projects/triggers_controller.rb +++ b/app/controllers/projects/triggers_controller.rb @@ -66,12 +66,11 @@ class Projects::TriggersController < Projects::ApplicationController end def trigger - @trigger ||= project.triggers.find(params[:id]) || render_404 + @trigger ||= project.triggers.find(params[:id]) + .present(current_user: current_user) end def trigger_params - params.require(:trigger).permit( - :description - ) + params.require(:trigger).permit(:description) end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 8bf93bfd68d..33c6608d321 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -6,19 +6,21 @@ class ProjectsController < Projects::ApplicationController include ExtractsPath include PreviewMarkdown include SendFileUpload + include RecordUserLastActivity prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } before_action :whitelist_query_limiting, only: [:create] - before_action :authenticate_user!, except: [:index, :show, :activity, :refs] + before_action :authenticate_user!, except: [:index, :show, :activity, :refs, :resolve] before_action :redirect_git_extension, only: [:show] - before_action :project, except: [:index, :new, :create] - before_action :repository, except: [:index, :new, :create] + before_action :project, except: [:index, :new, :create, :resolve] + before_action :repository, except: [:index, :new, :create, :resolve] before_action :assign_ref_vars, only: [:show], if: :repo_exists? before_action :tree, only: [:show], if: [:repo_exists?, :project_view_files?] before_action :lfs_blob_ids, only: [:show], if: [:repo_exists?, :project_view_files?] before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export] before_action :present_project, only: [:edit] + before_action :authorize_download_code!, only: [:refs] # Authorize before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export] @@ -441,4 +443,14 @@ class ProjectsController < Projects::ApplicationController def present_project @project = @project.present(current_user: current_user) end + + def resolve + @project = Project.find(params[:id]) + + if can?(current_user, :read_project, @project) + redirect_to @project + else + render_404 + end + end end diff --git a/app/controllers/sherlock/transactions_controller.rb b/app/controllers/sherlock/transactions_controller.rb index 46e382e594e..8d1847507cc 100644 --- a/app/controllers/sherlock/transactions_controller.rb +++ b/app/controllers/sherlock/transactions_controller.rb @@ -15,7 +15,7 @@ module Sherlock def destroy_all Gitlab::Sherlock.collection.clear - redirect_to :back, status: :found + redirect_back_or_default(options: { status: :found }) end end end diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb index 091bcb1253d..eee14b0faf4 100644 --- a/app/controllers/snippets/notes_controller.rb +++ b/app/controllers/snippets/notes_controller.rb @@ -26,10 +26,6 @@ class Snippets::NotesController < ApplicationController # rubocop: enable CodeReuse/ActiveRecord alias_method :noteable, :snippet - def note_params - super.merge(noteable_id: params[:snippet_id]) - end - def finder_params params.merge(last_fetched_at: last_fetched_at, target_id: snippet.id, target_type: 'personal_snippet') end diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index dd9bf17cf0c..8ea5450b4e8 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -80,7 +80,13 @@ class SnippetsController < ApplicationController render_blob_json(blob) end - format.js { render 'shared/snippets/show' } + format.js do + if @snippet.embeddable? + render 'shared/snippets/show' + else + head :not_found + end + end end end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index fa5d84633b5..568c6e2a852 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -28,13 +28,13 @@ class UploadsController < ApplicationController end def find_model - return nil unless params[:id] + return unless params[:id] upload_model_class.find(params[:id]) end def authorize_access! - return nil unless model + return unless model authorized = case model @@ -54,7 +54,7 @@ class UploadsController < ApplicationController end def authorize_create_access! - return nil unless model + return unless model # for now we support only personal snippets comments authorized = can?(current_user, :comment_personal_snippet, model) @@ -70,6 +70,10 @@ class UploadsController < ApplicationController end end + def cache_publicly? + User === model || Appearance === model + end + def upload_model_class MODEL_CLASSES[params[:model]] || raise(UnknownUploadModelError) end diff --git a/app/finders/admin/runners_finder.rb b/app/finders/admin/runners_finder.rb index fbb1cfc5c66..b2799565f57 100644 --- a/app/finders/admin/runners_finder.rb +++ b/app/finders/admin/runners_finder.rb @@ -11,10 +11,11 @@ class Admin::RunnersFinder < UnionFinder search! filter_by_status! filter_by_runner_type! + filter_by_tag_list! sort! paginate! - @runners + @runners.with_tags end def sort_key @@ -44,6 +45,14 @@ class Admin::RunnersFinder < UnionFinder filter_by!(:type_type, Ci::Runner::AVAILABLE_TYPES) end + def filter_by_tag_list! + tag_list = @params[:tag_name].presence + + if tag_list + @runners = @runners.tagged_with(tag_list) + end + end + def sort! @runners = @runners.order_by(sort_key) end diff --git a/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb b/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb new file mode 100644 index 00000000000..f38c187799c --- /dev/null +++ b/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Autocomplete + module ActsAsTaggableOn + class TagsFinder + LIMIT = 20 + + def initialize(params:) + @params = params + end + + def execute + tags = all_tags + tags = filter_by_name(tags) + limit(tags) + end + + private + + def all_tags + ::ActsAsTaggableOn::Tag.all + end + + def filter_by_name(tags) + return tags unless search + return tags.none if search.empty? + + if search.length >= Gitlab::SQL::Pattern::MIN_CHARS_FOR_PARTIAL_MATCHING + tags.named_like(search) + else + tags.named(search) + end + end + + def limit(tags) + tags.limit(LIMIT) # rubocop: disable CodeReuse/ActiveRecord + end + + def search + @params[:search] + end + end + end +end diff --git a/app/finders/concerns/finder_methods.rb b/app/finders/concerns/finder_methods.rb index 5290313585f..8de3276184d 100644 --- a/app/finders/concerns/finder_methods.rb +++ b/app/finders/concerns/finder_methods.rb @@ -3,13 +3,13 @@ module FinderMethods # rubocop: disable CodeReuse/ActiveRecord def find_by!(*args) - raise_not_found_unless_authorized execute.find_by!(*args) + raise_not_found_unless_authorized execute.reorder(nil).find_by!(*args) end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord def find_by(*args) - if_authorized execute.find_by(*args) + if_authorized execute.reorder(nil).find_by(*args) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/contributed_projects_finder.rb b/app/finders/contributed_projects_finder.rb index c1ef9dfefa7..f8c7f0c3167 100644 --- a/app/finders/contributed_projects_finder.rb +++ b/app/finders/contributed_projects_finder.rb @@ -14,6 +14,9 @@ class ContributedProjectsFinder < UnionFinder # Returns an ActiveRecord::Relation. # rubocop: disable CodeReuse/ActiveRecord def execute(current_user = nil) + # Do not show contributed projects if the user profile is private. + return Project.none unless can_read_profile?(current_user) + segments = all_projects(current_user) find_union(segments, Project).includes(:namespace).order_id_desc @@ -22,6 +25,10 @@ class ContributedProjectsFinder < UnionFinder private + def can_read_profile?(current_user) + Ability.allowed?(current_user, :read_user_profile, @user) + end + def all_projects(current_user) projects = [] diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb index 96a36db7ec8..ec340f38450 100644 --- a/app/finders/group_descendants_finder.rb +++ b/app/finders/group_descendants_finder.rb @@ -134,7 +134,7 @@ class GroupDescendantsFinder def subgroups return Group.none unless Group.supports_nested_objects? - # When filtering subgroups, we want to find all matches withing the tree of + # When filtering subgroups, we want to find all matches within the tree of # descendants to show to the user groups = if params[:filter] subgroups_matching_filter diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index b73a3fa6e01..072d07e0ed2 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -18,6 +18,7 @@ # assignee_id: integer or 'None' or 'Any' # assignee_username: string # search: string +# in: 'title', 'description', or a string joining them with comma # label_name: string # sort: string # non_archived: boolean @@ -56,6 +57,7 @@ class IssuableFinder milestone_title my_reaction_emoji search + in ] end @@ -76,13 +78,15 @@ class IssuableFinder items = init_collection items = filter_items(items) - # This has to be last as we may use a CTE as an optimization fence - # by passing the attempt_group_search_optimizations param and - # enabling the use_cte_for_group_issues_search feature flag + # This has to be last as we use a CTE as an optimization fence + # for counts by passing the force_cte param and enabling the + # attempt_group_search_optimizations feature flag # https://www.postgresql.org/docs/current/static/queries-with.html items = by_search(items) - sort(items) + items = sort(items) unless use_cte_for_count? + + items end def filter_items(items) @@ -92,6 +96,7 @@ class IssuableFinder items = by_scope(items) items = by_created_at(items) items = by_updated_at(items) + items = by_closed_at(items) items = by_state(items) items = by_group(items) items = by_assignee(items) @@ -114,8 +119,9 @@ class IssuableFinder # # rubocop: disable CodeReuse/ActiveRecord def count_by_state - count_params = params.merge(state: nil, sort: nil) + count_params = params.merge(state: nil, sort: nil, force_cte: true) finder = self.class.new(current_user, count_params) + counts = Hash.new(0) # Searching by label includes a GROUP BY in the query, but ours will be last @@ -125,8 +131,11 @@ class IssuableFinder # # This does not apply when we are using a CTE for the search, as the labels # GROUP BY is inside the subquery in that case, so we set labels_count to 1. + # + # We always use CTE when searching in Groups if the feature flag is enabled, + # but never when searching in Projects. labels_count = label_names.any? ? label_names.count : 1 - labels_count = 1 if use_cte_for_search? + labels_count = 1 if use_cte_for_count? finder.execute.reorder(nil).group(:state).count.each do |key, value| counts[count_key(key)] += value / labels_count @@ -149,6 +158,18 @@ class IssuableFinder end end + def related_groups + if project? && project && project.group && Ability.allowed?(current_user, :read_group, project.group) + project.group.self_and_ancestors + elsif group + [group] + elsif current_user + Gitlab::ObjectHierarchy.new(current_user.authorized_groups, current_user.groups).all_objects + else + [] + end + end + def project? params[:project_id].present? end @@ -163,8 +184,10 @@ class IssuableFinder end # rubocop: disable CodeReuse/ActiveRecord - def projects(items = nil) - return @projects = project if project? + def projects + return @projects if defined?(@projects) + + return @projects = [project] if project? projects = if current_user && params[:authorized_only].presence && !current_user_related? @@ -288,27 +311,31 @@ class IssuableFinder def use_subquery_for_search? strong_memoize(:use_subquery_for_search) do - attempt_group_search_optimizations? && - Feature.enabled?(:use_subquery_for_group_issues_search, default_enabled: false) + !force_cte? && attempt_group_search_optimizations? end end - def use_cte_for_search? - strong_memoize(:use_cte_for_search) do - attempt_group_search_optimizations? && - !use_subquery_for_search? && - Feature.enabled?(:use_cte_for_group_issues_search, default_enabled: true) + def use_cte_for_count? + strong_memoize(:use_cte_for_count) do + force_cte? && attempt_group_search_optimizations? end end private + def force_cte? + !!params[:force_cte] + end + def init_collection klass.all end def attempt_group_search_optimizations? - search && Gitlab::Database.postgresql? && params[:attempt_group_search_optimizations] + search && + Gitlab::Database.postgresql? && + params[:attempt_group_search_optimizations] && + Feature.enabled?(:attempt_group_search_optimizations, default_enabled: true) end def count_key(value) @@ -337,6 +364,13 @@ class IssuableFinder items end + def by_closed_at(items) + items = items.closed_after(params[:closed_after]) if params[:closed_after].present? + items = items.closed_before(params[:closed_before]) if params[:closed_before].present? + + items + end + # rubocop: disable CodeReuse/ActiveRecord def by_state(items) case params[:state].to_s @@ -387,14 +421,14 @@ class IssuableFinder def by_search(items) return items unless search - if use_cte_for_search? + if use_cte_for_count? cte = Gitlab::SQL::RecursiveCTE.new(klass.table_name) cte << items items = klass.with(cte.to_arel).from(klass.table_name) end - items.full_search(search) + items.full_search(search, matched_columns: params[:in]) end # rubocop: enable CodeReuse/ActiveRecord @@ -459,7 +493,7 @@ class IssuableFinder elsif filter_by_any_milestone? items = items.any_milestone elsif filter_by_upcoming_milestone? - upcoming_ids = Milestone.upcoming_ids_by_projects(projects(items)) + upcoming_ids = Milestone.upcoming_ids(projects, related_groups) items = items.left_joins_milestones.where(milestone_id: upcoming_ids) elsif filter_by_started_milestone? items = items.left_joins_milestones.where('milestones.start_date <= NOW()') diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 45e494725d7..cb44575d6f1 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -14,6 +14,7 @@ # milestone_title: string # assignee_id: integer # search: string +# in: 'title', 'description', or a string joining them with comma # label_name: string # sort: string # my_reaction_emoji: string @@ -68,7 +69,16 @@ class IssuesFinder < IssuableFinder end def filter_items(items) - by_due_date(super) + issues = super + issues = by_due_date(issues) + issues = by_confidential(issues) + issues + end + + def by_confidential(items) + return items if params[:confidential].nil? + + params[:confidential] ? items.confidential_only : items.public_only end def by_due_date(items) diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index e190d5d90c9..93bee3f1488 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -15,6 +15,7 @@ # author_id: integer # assignee_id: integer # search: string +# in: 'title', 'description', or a string joining them with comma # label_name: string # sort: string # non_archived: boolean @@ -36,13 +37,20 @@ class MergeRequestsFinder < IssuableFinder end def filter_items(_items) - items = by_source_branch(super) + items = by_commit(super) + items = by_source_branch(items) items = by_wip(items) by_target_branch(items) end private + def by_commit(items) + return items unless params[:commit_sha].presence + + items.by_commit_sha(params[:commit_sha]) + end + def source_branch @source_branch ||= params[:source_branch].presence end diff --git a/app/finders/milestones_finder.rb b/app/finders/milestones_finder.rb index 9c477978f60..77b55cbb838 100644 --- a/app/finders/milestones_finder.rb +++ b/app/finders/milestones_finder.rb @@ -3,8 +3,8 @@ # Search for milestones # # params - Hash -# project_ids: Array of project ids or single project id. -# group_ids: Array of group ids or single group id. +# project_ids: Array of project ids or single project id or ActiveRecord relation. +# group_ids: Array of group ids or single group id or ActiveRecord relation. # order - Orders by field default due date asc. # title - filter by title. # state - filters by state. @@ -12,20 +12,17 @@ class MilestonesFinder include FinderMethods - attr_reader :params, :project_ids, :group_ids + attr_reader :params def initialize(params = {}) - @project_ids = Array(params[:project_ids]) - @group_ids = Array(params[:group_ids]) @params = params end def execute - return Milestone.none if project_ids.empty? && group_ids.empty? - items = Milestone.all items = by_groups_and_projects(items) items = by_title(items) + items = by_search_title(items) items = by_state(items) order(items) @@ -34,7 +31,7 @@ class MilestonesFinder private def by_groups_and_projects(items) - items.for_projects_and_groups(project_ids, group_ids) + items.for_projects_and_groups(params[:project_ids], params[:group_ids]) end # rubocop: disable CodeReuse/ActiveRecord @@ -47,6 +44,14 @@ class MilestonesFinder end # rubocop: enable CodeReuse/ActiveRecord + def by_search_title(items) + if params[:search_title].present? + items.search_title(params[:search_title]) + else + items + end + end + def by_state(items) Milestone.filter_by_state(items, params[:state]) end diff --git a/app/finders/projects/daily_statistics_finder.rb b/app/finders/projects/daily_statistics_finder.rb new file mode 100644 index 00000000000..912c23107bc --- /dev/null +++ b/app/finders/projects/daily_statistics_finder.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Projects + class DailyStatisticsFinder + attr_reader :project + + def initialize(project) + @project = project + end + + def fetches + ProjectDailyStatistic.of_project(project) + .of_last_30_days + .sorted_by_date_desc + end + + def total_fetch_count + fetches.sum_fetch_count + end + end +end diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb index 2b5d67e79d7..2f2816a4a08 100644 --- a/app/finders/projects/serverless/functions_finder.rb +++ b/app/finders/projects/serverless/functions_finder.rb @@ -15,11 +15,40 @@ module Projects clusters_with_knative_installed.exists? end + def service(environment_scope, name) + knative_service(environment_scope, name)&.first + end + private + def knative_service(environment_scope, name) + clusters_with_knative_installed.preload_knative.map do |cluster| + next if environment_scope != cluster.environment_scope + + services = cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace) + .select { |svc| svc["metadata"]["name"] == name } + + add_metadata(cluster, services).first unless services.nil? + end + end + def knative_services clusters_with_knative_installed.preload_knative.map do |cluster| - cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace) + services = cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace) + add_metadata(cluster, services) unless services.nil? + end + end + + def add_metadata(cluster, services) + services.each do |s| + s["environment_scope"] = cluster.environment_scope + s["cluster_id"] = cluster.id + + if services.length == 1 + s["podcount"] = cluster.application_knative.service_pod_details( + cluster.platform_kubernetes&.actual_namespace, + s["metadata"]["name"]).length + end end end diff --git a/app/finders/releases_finder.rb b/app/finders/releases_finder.rb new file mode 100644 index 00000000000..59e84198fde --- /dev/null +++ b/app/finders/releases_finder.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ReleasesFinder + def initialize(project, current_user = nil) + @project = project + @current_user = current_user + end + + def execute + return Release.none unless Ability.allowed?(@current_user, :read_release, @project) + + @project.releases.sorted + end +end diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index d3774746cb8..bf29f15642d 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -69,6 +69,8 @@ class SnippetsFinder < UnionFinder base.with_optional_visibility(visibility_from_scope).fresh end + private + # Produces a query that retrieves snippets from multiple projects. # # The resulting query will, depending on the user's permissions, include the diff --git a/app/graphql/mutations/merge_requests/base.rb b/app/graphql/mutations/merge_requests/base.rb index 54f01c99d78..7d0cb777ad1 100644 --- a/app/graphql/mutations/merge_requests/base.rb +++ b/app/graphql/mutations/merge_requests/base.rb @@ -25,7 +25,8 @@ module Mutations def find_object(project_path:, iid:) project = resolve_project(full_path: project_path) - resolver = Resolvers::MergeRequestResolver.new(object: project, context: context) + resolver = Resolvers::MergeRequestsResolver + .single.new(object: project, context: context) resolver.resolve(iid: iid) end diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 459933af9d3..063def75d38 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -2,5 +2,12 @@ module Resolvers class BaseResolver < GraphQL::Schema::Resolver + def self.single + @single ||= Class.new(self) do + def resolve(**args) + super.first + end + end + end end end diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index 4ab3c13787a..b98d8bd1fff 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -2,8 +2,37 @@ module Resolvers class IssuesResolver < BaseResolver - extend ActiveSupport::Concern + argument :iid, GraphQL::ID_TYPE, + required: false, + description: 'The IID of the issue, e.g., "1"' + argument :iids, [GraphQL::ID_TYPE], + required: false, + description: 'The list of IIDs of issues, e.g., [1, 2]' + argument :state, Types::IssuableStateEnum, + required: false, + description: "Current state of Issue" + argument :label_name, GraphQL::STRING_TYPE.to_list_type, + required: false, + description: "Labels applied to the Issue" + argument :created_before, Types::TimeType, + required: false, + description: "Issues created before this date" + argument :created_after, Types::TimeType, + required: false, + description: "Issues created after this date" + argument :updated_before, Types::TimeType, + required: false, + description: "Issues updated before this date" + argument :updated_after, Types::TimeType, + required: false, + description: "Issues updated after this date" + argument :closed_before, Types::TimeType, + required: false, + description: "Issues closed before this date" + argument :closed_after, Types::TimeType, + required: false, + description: "Issues closed after this date" argument :search, GraphQL::STRING_TYPE, required: false argument :sort, Types::Sort, @@ -18,6 +47,7 @@ module Resolvers # Will need to be be made group & namespace aware with # https://gitlab.com/gitlab-org/gitlab-ce/issues/54520 args[:project_id] = project.id + args[:iids] ||= [args[:iid]].compact IssuesFinder.new(context[:current_user], args).execute end diff --git a/app/graphql/resolvers/merge_request_resolver.rb b/app/graphql/resolvers/merge_request_resolver.rb deleted file mode 100644 index d047ce9e3a1..00000000000 --- a/app/graphql/resolvers/merge_request_resolver.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module Resolvers - class MergeRequestResolver < BaseResolver - argument :iid, GraphQL::ID_TYPE, - required: true, - description: 'The IID of the merge request, e.g., "1"' - - type Types::MergeRequestType, null: true - - alias_method :project, :object - - # rubocop: disable CodeReuse/ActiveRecord - def resolve(iid:) - return unless project.present? - - BatchLoader.for(iid.to_s).batch(key: project) do |iids, loader, args| - args[:key].merge_requests.where(iid: iids).each do |mr| - loader.call(mr.iid.to_s, mr) - end - end - end - # rubocop: enable CodeReuse/ActiveRecord - end -end diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb new file mode 100644 index 00000000000..90795c797ac --- /dev/null +++ b/app/graphql/resolvers/merge_requests_resolver.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Resolvers + class MergeRequestsResolver < BaseResolver + argument :iid, GraphQL::ID_TYPE, + required: false, + description: 'The IID of the merge request, e.g., "1"' + + argument :iids, [GraphQL::ID_TYPE], + required: false, + description: 'The list of IIDs of issues, e.g., [1, 2]' + + type Types::MergeRequestType, null: true + + alias_method :project, :object + + def resolve(**args) + return unless project.present? + + args[:iids] ||= [args[:iid]].compact + + args[:iids].map { |iid| batch_load(iid) } + .select(&:itself) # .compact doesn't work on BatchLoader + end + + # rubocop: disable CodeReuse/ActiveRecord + def batch_load(iid) + BatchLoader.for(iid.to_s).batch(key: project) do |iids, loader, args| + args[:key].merge_requests.where(iid: iids).each do |mr| + loader.call(mr.iid.to_s, mr) + end + end + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/graphql/resolvers/metadata_resolver.rb b/app/graphql/resolvers/metadata_resolver.rb new file mode 100644 index 00000000000..3a79e6434fb --- /dev/null +++ b/app/graphql/resolvers/metadata_resolver.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Resolvers + class MetadataResolver < BaseResolver + type Types::MetadataType, null: false + + def resolve(**args) + { version: Gitlab::VERSION, revision: Gitlab.revision } + end + end +end diff --git a/app/graphql/types/issuable_state_enum.rb b/app/graphql/types/issuable_state_enum.rb new file mode 100644 index 00000000000..f2f6d6c6cab --- /dev/null +++ b/app/graphql/types/issuable_state_enum.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + class IssuableStateEnum < BaseEnum + graphql_name 'IssuableState' + description 'State of a GitLab issue or merge request' + + value 'opened' + value 'closed' + value 'locked' + end +end diff --git a/app/graphql/types/issue_state_enum.rb b/app/graphql/types/issue_state_enum.rb new file mode 100644 index 00000000000..6521407fc9d --- /dev/null +++ b/app/graphql/types/issue_state_enum.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + class IssueStateEnum < IssuableStateEnum + graphql_name 'IssueState' + description 'State of a GitLab issue' + end +end diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index a8f2f7914a8..5ad3ea52930 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -11,22 +11,20 @@ module Types field :iid, GraphQL::ID_TYPE, null: false field :title, GraphQL::STRING_TYPE, null: false field :description, GraphQL::STRING_TYPE, null: true - field :state, GraphQL::STRING_TYPE, null: false + field :state, IssueStateEnum, null: false field :author, Types::UserType, null: false, - resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find } do - authorize :read_user - end + resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find }, + authorize: :read_user field :assignees, Types::UserType.connection_type, null: true field :labels, Types::LabelType.connection_type, null: true field :milestone, Types::MilestoneType, null: true, - resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find } do - authorize :read_milestone - end + resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find }, + authorize: :read_milestone field :due_date, Types::TimeType, null: true field :confidential, GraphQL::BOOLEAN_TYPE, null: false diff --git a/app/graphql/types/merge_request_state_enum.rb b/app/graphql/types/merge_request_state_enum.rb new file mode 100644 index 00000000000..92f52726ab3 --- /dev/null +++ b/app/graphql/types/merge_request_state_enum.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + class MergeRequestStateEnum < IssuableStateEnum + graphql_name 'MergeRequestState' + description 'State of a GitLab merge request' + + value 'merged' + end +end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index fb740b6fb1c..1ed27a14e33 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -12,7 +12,7 @@ module Types field :iid, GraphQL::ID_TYPE, null: false field :title, GraphQL::STRING_TYPE, null: false field :description, GraphQL::STRING_TYPE, null: true - field :state, GraphQL::STRING_TYPE, null: true + field :state, MergeRequestStateEnum, null: false field :created_at, Types::TimeType, null: false field :updated_at, Types::TimeType, null: false field :source_project, Types::ProjectType, null: true @@ -38,8 +38,8 @@ module Types field :should_be_rebased, GraphQL::BOOLEAN_TYPE, method: :should_be_rebased?, null: false field :rebase_commit_sha, GraphQL::STRING_TYPE, null: true field :rebase_in_progress, GraphQL::BOOLEAN_TYPE, method: :rebase_in_progress?, null: false - field :diff_head_sha, GraphQL::STRING_TYPE, null: true - field :merge_commit_message, GraphQL::STRING_TYPE, null: true + field :merge_commit_message, GraphQL::STRING_TYPE, method: :default_merge_commit_message, null: true, deprecation_reason: "Renamed to defaultMergeCommitMessage" + field :default_merge_commit_message, GraphQL::STRING_TYPE, null: true field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false field :source_branch_exists, GraphQL::BOOLEAN_TYPE, method: :source_branch_exists?, null: false field :mergeable_discussions_state, GraphQL::BOOLEAN_TYPE, null: true @@ -48,9 +48,7 @@ module Types field :downvotes, GraphQL::INT_TYPE, null: false field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false - field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline do - authorize :read_pipeline - end + field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline, authorize: :read_pipeline field :pipelines, Types::Ci::PipelineType.connection_type, resolver: Resolvers::MergeRequestPipelinesResolver end diff --git a/app/graphql/types/metadata_type.rb b/app/graphql/types/metadata_type.rb new file mode 100644 index 00000000000..2d8bad0614b --- /dev/null +++ b/app/graphql/types/metadata_type.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + class MetadataType < ::Types::BaseObject + graphql_name 'Metadata' + + field :version, GraphQL::STRING_TYPE, null: false + field :revision, GraphQL::STRING_TYPE, null: false + end +end diff --git a/app/graphql/types/permission_types/project.rb b/app/graphql/types/permission_types/project.rb index ab37c282fe5..e9a4ea9157b 100644 --- a/app/graphql/types/permission_types/project.rb +++ b/app/graphql/types/permission_types/project.rb @@ -8,7 +8,7 @@ module Types abilities :change_namespace, :change_visibility_level, :rename_project, :remove_project, :archive_project, :remove_fork_project, :remove_pages, :read_project, :create_merge_request_in, - :read_wiki, :read_project_member, :create_issue, :upload_file, + :read_wiki, :read_project_member, :create_issue, :upload_file, :read_cycle_analytics, :download_code, :download_wiki_code, :fork_project, :create_project_snippet, :read_commit_status, :request_access, :create_pipeline, :create_pipeline_schedule, diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 050706f97be..b96c2f3afb2 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -16,7 +16,6 @@ module Types field :description, GraphQL::STRING_TYPE, null: true - field :default_branch, GraphQL::STRING_TYPE, null: true field :tag_list, GraphQL::STRING_TYPE, null: true field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true @@ -59,25 +58,34 @@ module Types end field :import_status, GraphQL::STRING_TYPE, null: true - field :ci_config_path, GraphQL::STRING_TYPE, null: true field :only_allow_merge_if_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true + field :merge_requests, + Types::MergeRequestType.connection_type, + null: true, + resolver: Resolvers::MergeRequestsResolver, + authorize: :read_merge_request + field :merge_request, Types::MergeRequestType, null: true, - resolver: Resolvers::MergeRequestResolver do - authorize :read_merge_request - end + resolver: Resolvers::MergeRequestsResolver.single, + authorize: :read_merge_request field :issues, Types::IssueType.connection_type, null: true, resolver: Resolvers::IssuesResolver + field :issue, + Types::IssueType, + null: true, + resolver: Resolvers::IssuesResolver.single + field :pipelines, Types::Ci::PipelineType.connection_type, null: false, diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 7c41716b82a..472fe5d6ec2 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -1,14 +1,21 @@ # frozen_string_literal: true module Types - class QueryType < BaseObject + class QueryType < ::Types::BaseObject graphql_name 'Query' field :project, Types::ProjectType, null: true, resolver: Resolvers::ProjectResolver, - description: "Find a project" do - authorize :read_project + description: "Find a project", + authorize: :read_project + + field :metadata, Types::MetadataType, + null: true, + resolver: Resolvers::MetadataResolver, + description: 'Metadata about GitLab' do |*args| + + authorize :read_instance_metadata end field :echo, GraphQL::STRING_TYPE, null: false, function: Functions::Echo.new diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index 3f69af50f25..c0db9910143 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module AppearancesHelper + include MarkupHelper + def brand_title current_appearance&.title.presence || default_brand_title end @@ -11,7 +13,7 @@ module AppearancesHelper end def brand_image - image_tag(current_appearance.logo) if current_appearance&.logo? + image_tag(current_appearance.logo_path) if current_appearance&.logo? end def brand_text @@ -28,7 +30,7 @@ module AppearancesHelper def brand_header_logo if current_appearance&.header_logo? - image_tag current_appearance.header_logo + image_tag current_appearance.header_logo_path, class: 'brand-header-logo' else render 'shared/logo.svg' end @@ -40,4 +42,36 @@ module AppearancesHelper render 'shared/logo_type.svg' end end + + def header_message + return unless current_appearance&.show_header? + + class_names = [] + class_names << 'with-performance-bar' if performance_bar_enabled? + + render_message(:header_message, class_names: class_names) + end + + def footer_message + return unless current_appearance&.show_footer? + + render_message(:footer_message) + end + + private + + def render_message(field_sym, class_names: [], style: message_style) + class_names << field_sym.to_s.dasherize + + content_tag :div, class: class_names, style: style do + markdown_field(current_appearance, field_sym) + end + end + + def message_style + style = [] + style << "background-color: #{current_appearance.message_background_color};" + style << "color: #{current_appearance.message_font_color}" + style.join + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 82bb2d1a805..ffa5719fefb 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -214,12 +214,19 @@ module ApplicationHelper class_names = [] class_names << 'issue-boards-page' if current_controller?(:boards) class_names << 'with-performance-bar' if performance_bar_enabled? - + class_names << system_message_class class_names end - # EE feature: System header and footer, unavailable in CE def system_message_class + class_names = [] + + return class_names unless appearance + + class_names << 'with-system-header' if appearance.show_header? + class_names << 'with-system-footer' if appearance.show_footer? + + class_names end # Returns active css class when condition returns true @@ -268,6 +275,17 @@ module ApplicationHelper _('You are on a read-only GitLab instance.') end + def client_class_list + "gl-browser-#{browser.id} gl-platform-#{browser.platform.id}" + end + + def client_js_flags + { + "is#{browser.id.to_s.titlecase}": true, + "is#{browser.platform.id.to_s.titlecase}": true + } + end + def autocomplete_data_sources(object, noteable_type) return {} unless object && noteable_type @@ -281,4 +299,10 @@ module ApplicationHelper snippets: snippets_project_autocomplete_sources_path(object) } end + + private + + def appearance + ::Appearance.current + end end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 5a7c005fd06..e635f608237 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -20,7 +20,7 @@ module ApplicationSettingsHelper def enabled_protocol case Gitlab::CurrentSettings.enabled_git_access_protocol when 'http' - gitlab_config.protocol + Gitlab.config.gitlab.protocol when 'ssh' 'ssh' end @@ -35,7 +35,7 @@ module ApplicationSettingsHelper end def http_enabled? - all_protocols_enabled? || enabled_protocol == 'http' + all_protocols_enabled? || Gitlab::CurrentSettings.enabled_git_access_protocol == 'http' end def enabled_project_button(project, protocol) @@ -150,6 +150,7 @@ module ApplicationSettingsHelper :email_author_in_body, :enabled_git_access_protocol, :enforce_terms, + :first_day_of_week, :gitaly_timeout_default, :gitaly_timeout_medium, :gitaly_timeout_fast, @@ -231,7 +232,8 @@ module ApplicationSettingsHelper :web_ide_clientside_preview_enabled, :diff_max_patch_bytes, :commit_email_hostname, - :protected_ci_variables + :protected_ci_variables, + :local_markdown_version ] end diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 654fb9d9987..2b1d6f49878 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -16,6 +16,13 @@ module AuthHelper PROVIDERS_WITH_ICONS.include?(name.to_s) end + def qa_class_for_provider(provider) + { + saml: 'qa-saml-login-button', + github: 'qa-github-login-button' + }[provider.to_sym] + end + def auth_providers Gitlab::Auth::OAuth::Provider.providers end @@ -31,7 +38,7 @@ module AuthHelper def form_based_provider_with_highest_priority @form_based_provider_with_highest_priority ||= begin form_based_provider_priority.each do |provider_regexp| - highest_priority = form_based_providers.find { |provider| provider.match?(provider_regexp) } + highest_priority = form_based_providers.find { |provider| provider.match?(provider_regexp) } break highest_priority unless highest_priority.nil? end end diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb index 516c8a353ea..67e7e475920 100644 --- a/app/helpers/auto_devops_helper.rb +++ b/app/helpers/auto_devops_helper.rb @@ -9,41 +9,4 @@ module AutoDevopsHelper !project.repository.gitlab_ci_yml && !project.ci_service end - - def auto_devops_warning_message(project) - if missing_auto_devops_service?(project) - params = { - kubernetes: link_to('Kubernetes cluster', project_clusters_path(project)) - } - - if missing_auto_devops_domain?(project) - _('Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly.') % params - else - _('Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly.') % params - end - elsif missing_auto_devops_domain?(project) - _('Auto Review Apps and Auto Deploy need a domain name to work correctly.') - end - end - - # rubocop: disable CodeReuse/ActiveRecord - def cluster_ingress_ip(project) - project - .cluster_ingresses - .where("external_ip is not null") - .limit(1) - .pluck(:external_ip) - .first - end - # rubocop: enable CodeReuse/ActiveRecord - - private - - def missing_auto_devops_domain?(project) - !(project.auto_devops || project.build_auto_devops)&.has_domain? - end - - def missing_auto_devops_service?(project) - !project.deployment_platform&.active? - end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index bd42f00944f..3e1bb9af5cc 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -31,12 +31,13 @@ module BlobHelper edit_button_tag(blob, common_classes, _('Edit'), - edit_blob_path(project, ref, path, options), + Feature.enabled?(:web_ide_default) ? ide_edit_path(project, ref, path, options) : edit_blob_path(project, ref, path, options), project, ref) end def ide_edit_button(project = @project, ref = @ref, path = @path, options = {}) + return if Feature.enabled?(:web_ide_default) return unless blob = readable_blob(options, path, project, ref) edit_button_tag(blob, @@ -140,36 +141,6 @@ module BlobHelper Gitlab::Sanitizers::SVG.clean(data) end - # Remove once https://gitlab.com/gitlab-org/gitlab-ce/issues/36103 is closed - # and :workhorse_set_content_type flag is removed - # If we blindly set the 'real' content type when serving a Git blob we - # are enabling XSS attacks. An attacker could upload e.g. a Javascript - # file to a Git repository, trick the browser of a victim into - # downloading the blob, and then the 'application/javascript' content - # type would tell the browser to execute the attacker's Javascript. By - # overriding the content type and setting it to 'text/plain' (in the - # example of Javascript) we tell the browser of the victim not to - # execute untrusted data. - def safe_content_type(blob) - if blob.extension == 'svg' - blob.mime_type - elsif blob.text? - 'text/plain; charset=utf-8' - elsif blob.image? - blob.content_type - else - 'application/octet-stream' - end - end - - def content_disposition(blob, inline) - # Remove the following line when https://gitlab.com/gitlab-org/gitlab-ce/issues/36103 - # is closed and :workhorse_set_content_type flag is removed - return 'attachment' if blob.extension == 'svg' - - inline ? 'inline' : 'attachment' - end - def ref_project @ref_project ||= @target_project || @project end @@ -207,7 +178,8 @@ module BlobHelper 'relative-url-root' => Rails.application.config.relative_url_root, 'assets-prefix' => Gitlab::Application.config.assets.prefix, 'blob-filename' => @blob && @blob.path, - 'project-id' => project.id + 'project-id' => project.id, + 'is-markdown' => @blob && @blob.path && Gitlab::MarkupHelper.gitlab_markdown?(@blob.path) } end @@ -223,7 +195,7 @@ module BlobHelper def open_raw_blob_button(blob) return if blob.empty? - return if blob.raw_binary? || blob.stored_externally? + return if blob.binary? || blob.stored_externally? title = 'Open raw' link_to icon('file-code-o'), blob_raw_path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' } diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index 923a06a0512..355b91a8661 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -100,17 +100,6 @@ module CiStatusHelper "pipeline-status/#{pipeline_status.sha}-#{pipeline_status.status}" end - def render_project_pipeline_status(pipeline_status, tooltip_placement: 'left') - project = pipeline_status.project - path = pipelines_project_commit_path(project, pipeline_status.sha, ref: pipeline_status.ref) - - render_status_with_link( - 'commit', - pipeline_status.status, - path, - tooltip_placement: tooltip_placement) - end - def render_commit_status(commit, ref: nil, tooltip_placement: 'left') project = commit.project path = pipelines_project_commit_path(project, commit, ref: ref) @@ -123,12 +112,6 @@ module CiStatusHelper icon_size: 24) end - def render_pipeline_status(pipeline, tooltip_placement: 'left') - project = pipeline.project - path = project_pipeline_path(project, pipeline) - render_status_with_link('pipeline', pipeline.status, path, tooltip_placement: tooltip_placement) - end - def render_status_with_link(type, status, path = nil, tooltip_placement: 'left', cssclass: '', container: 'body', icon_size: 16) klass = "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}" title = "#{type.titleize}: #{ci_label_for_status(status)}" diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index d52cfd6e37a..d58f634425b 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -154,7 +154,7 @@ module CommitsHelper if user.nil? mail_to(source_email, text, link_options) else - link_to(text, user_path(user), link_options) + link_to(text, user_path(user), { class: "commit-#{options[:source]}-link js-user-link", data: { user_id: user.id } }) end end diff --git a/app/helpers/count_helper.rb b/app/helpers/count_helper.rb index 13839474e1f..62bb2e4da23 100644 --- a/app/helpers/count_helper.rb +++ b/app/helpers/count_helper.rb @@ -13,7 +13,7 @@ module CountHelper # memberships, and deducting 1 for each root of the fork network. # This might be inacurate as the root of the fork network might have been deleted. # - # This makes querying this information a lot more effecient and it should be + # This makes querying this information a lot more efficient and it should be # accurate enough for the instance wide statistics def approximate_fork_count_with_delimiters(count_data) fork_network_count = count_data[ForkNetwork] diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index b6844d36052..32431959851 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -138,30 +138,6 @@ module DiffHelper !diff_file.deleted_file? && @merge_request && @merge_request.source_project end - def diff_render_error_reason(viewer) - case viewer.render_error - when :too_large - "it is too large" - when :server_side_but_stored_externally - case viewer.diff_file.external_storage - when :lfs - 'it is stored in LFS' - else - 'it is stored externally' - end - end - end - - def diff_render_error_options(viewer) - diff_file = viewer.diff_file - options = [] - - blob_url = project_blob_path(@project, tree_join(diff_file.content_sha, diff_file.file_path)) - options << link_to('view the blob', blob_url) - - options - end - def diff_file_changed_icon(diff_file) if diff_file.deleted_file? "file-deletion" diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index e4c46ceeaa2..96471d15aac 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -36,6 +36,14 @@ module EmailsHelper nil end + def sanitize_name(name) + if name =~ URI::DEFAULT_PARSER.regexp[:URI_REF] + name.tr('.', '_') + else + name + end + end + def password_reset_token_valid_time valid_hours = Devise.reset_password_within / 60 / 60 if valid_hours >= 24 @@ -58,7 +66,7 @@ module EmailsHelper def header_logo if current_appearance&.header_logo? image_tag( - current_appearance.header_logo, + current_appearance.header_logo_path, style: 'height: 50px' ) else @@ -123,4 +131,42 @@ module EmailsHelper project.id.to_s + "." + project_path_as_domain + "." + Gitlab.config.gitlab.host end + + def html_header_message + return unless show_header? + + render_message(:header_message, style: '') + end + + def html_footer_message + return unless show_footer? + + render_message(:footer_message, style: '') + end + + def text_header_message + return unless show_header? + + strip_tags(render_message(:header_message, style: '')) + end + + def text_footer_message + return unless show_footer? + + strip_tags(render_message(:footer_message, style: '')) + end + + private + + def show_footer? + email_header_and_footer_enabled? && current_appearance&.show_footer? + end + + def show_header? + email_header_and_footer_enabled? && current_appearance&.show_header? + end + + def email_header_and_footer_enabled? + current_appearance&.email_header_and_footer_enabled? + end end diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 7b22bc8f98f..365b94f5a3e 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -7,6 +7,14 @@ module EnvironmentsHelper } end + def environments_folder_list_view_data + { + "endpoint" => folder_project_environments_path(@project, @folder, format: :json), + "folder-name" => @folder, + "can-read-environment" => can?(current_user, :read_environment, @project).to_s + } + end + def metrics_data(project, environment) { "settings-path" => edit_project_service_path(project, 'prometheus'), diff --git a/app/helpers/external_wiki_helper.rb b/app/helpers/external_wiki_helper.rb deleted file mode 100644 index e36d63b2946..00000000000 --- a/app/helpers/external_wiki_helper.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -module ExternalWikiHelper - def get_project_wiki_path(project) - external_wiki_service = project.external_wiki - if external_wiki_service - external_wiki_service.properties['external_wiki_url'] - else - project_wiki_path(project, :home) - end - end -end diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb index 49171df1433..3d494c3de6a 100644 --- a/app/helpers/import_helper.rb +++ b/app/helpers/import_helper.rb @@ -8,7 +8,9 @@ module ImportHelper end def sanitize_project_name(name) - name.gsub(/[^\w\-]/, '-') + # For personal projects in Bitbucket in the form ~username, we can + # just drop that leading tilde. + name.gsub(/\A~+/, '').gsub(/[^\w\-]/, '-') end def import_project_target(owner, name) @@ -16,10 +18,8 @@ module ImportHelper "#{namespace}/#{name}" end - def provider_project_link(provider, full_path) - url = __send__("#{provider}_project_url", full_path) # rubocop:disable GitlabSecurity/PublicSend - - link_to full_path, url, target: '_blank', rel: 'noopener noreferrer' + def provider_project_link_url(provider_url, full_path) + Gitlab::Utils.append_path(provider_url, full_path) end def import_will_timeout_message(_ci_cd_only) @@ -44,10 +44,6 @@ module ImportHelper _('Please wait while we import the repository for you. Refresh at will.') end - def import_github_title - _('Import repositories from GitHub') - end - def import_github_authorize_message _('To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:') end @@ -71,30 +67,4 @@ module ImportHelper _('Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token.').html_safe % { github_integration_link: github_integration_link } end end - - def import_githubish_choose_repository_message - _('Choose which repositories you want to import.') - end - - def import_all_githubish_repositories_button_label - _('Import all repositories') - end - - private - - def github_project_url(full_path) - Gitlab::Utils.append_path(github_root_url, full_path) - end - - def github_root_url - strong_memoize(:github_url) do - provider = Gitlab::Auth::OAuth::Provider.config_for('github') - - provider&.dig('url').presence || 'https://github.com' - end - end - - def gitea_project_url(full_path) - Gitlab::Utils.append_path(@gitea_host_url, full_path) - end end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 5f7147508c7..0622cdfc196 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -191,7 +191,7 @@ module IssuablesHelper output << content_tag(:strong) do author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline") - author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-block d-sm-none") + author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-inline d-sm-none") if status = user_status(issuable.author) author_output << "#{status}".html_safe @@ -200,7 +200,7 @@ module IssuablesHelper author_output end - output << content_tag(:span, (issuable_first_contribution_icon if issuable.first_contribution?), class: 'has-tooltip', title: _('1st contribution!')) + output << content_tag(:span, (issuable_first_contribution_icon if issuable.first_contribution?), class: 'has-tooltip prepend-left-4', title: _('1st contribution!')) output << content_tag(:span, (issuable.task_status if issuable.tasks?), id: "task_status", class: "d-none d-sm-none d-md-inline-block prepend-left-8") output << content_tag(:span, (issuable.task_status_short if issuable.tasks?), id: "task_status_short", class: "d-md-none") @@ -209,7 +209,7 @@ module IssuablesHelper end def issuable_labels_tooltip(labels, limit: 5) - first, last = labels.partition.with_index { |_, i| i < limit } + first, last = labels.partition.with_index { |_, i| i < limit } if labels && labels.any? label_names = first.collect { |label| label.fetch(:title) } @@ -268,7 +268,7 @@ module IssuablesHelper issuableRef: issuable.to_reference, markdownPreviewPath: preview_markdown_path(parent), markdownDocsPath: help_page_path('user/markdown'), - markdownVersion: issuable.cached_markdown_version, + lockVersion: issuable.lock_version, issuableTemplates: issuable_templates(issuable), initialTitleHtml: markdown_field(issuable, :title), initialTitleText: issuable.title, diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 39f661b5f0c..bd53add80ca 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -227,6 +227,10 @@ module LabelsHelper "#{action} at #{level} level" end + def labels_sorted_by_title(labels) + labels.sort_by(&:title) + end + # Required for Banzai::Filter::LabelReferenceFilter module_function :render_colored_label, :text_color_for_bg, :escape_once end diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index 0d638b850b4..d83c69603a9 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -74,7 +74,7 @@ module MarkupHelper # the tag contents are truncated without removing the closing tag. def first_line_in_markdown(object, attribute, max_chars = nil, options = {}) md = markdown_field(object, attribute, options) - return nil unless md.present? + return unless md.present? tags = %w(a gl-emoji b pre code p span) tags << 'img' if options[:allow_images] @@ -116,7 +116,6 @@ module MarkupHelper def markup(file_name, text, context = {}) context[:project] ||= @project - context[:markdown_engine] ||= :redcarpet unless commonmark_for_repositories_enabled? html = context.delete(:rendered) || markup_unsafe(file_name, text, context) prepare_for_rendering(html, context) end @@ -132,7 +131,6 @@ module MarkupHelper page_slug: wiki_page.slug, issuable_state_filter_enabled: true ) - context[:markdown_engine] ||= :redcarpet unless commonmark_for_repositories_enabled? html = case wiki_page.format @@ -187,10 +185,6 @@ module MarkupHelper end end - def commonmark_for_repositories_enabled? - Feature.enabled?(:commonmark_for_repositories, default_enabled: true) - end - private # Return +text+, truncated to +max_chars+ characters, excluding any HTML diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index 5a21403bc5e..11d5591d509 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -18,12 +18,13 @@ module MembersHelper "remove #{member.user.name} from" end - "#{text} #{action} the #{member.source.human_name} #{member.real_source_type.humanize(capitalize: false)}?" + "#{text} #{action} the #{member.source.human_name} #{source_text(member)}?" end def remove_member_title(member) action = member.request? ? 'Deny access request' : 'Remove user' - "#{action} from #{member.real_source_type.humanize(capitalize: false)}" + + "#{action} from #{source_text(member)}" end def leave_confirmation_message(member_source) @@ -32,7 +33,17 @@ module MembersHelper end def filter_group_project_member_path(options = {}) - options = params.slice(:search, :sort).merge(options) + options = params.slice(:search, :sort).merge(options).permit! "#{request.path}?#{options.to_param}" end + + private + + def source_text(member) + type = member.real_source_type.humanize(capitalize: false) + + return type if member.request? || member.invite? || type != 'group' + + 'group and any subresources' + end end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 23d7aa427bb..991ca42c445 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -29,7 +29,7 @@ module MergeRequestsHelper def ci_build_details_path(merge_request) build_url = merge_request.source_project.ci_service.build_page(merge_request.diff_head_sha, merge_request.source_branch) - return nil unless build_url + return unless build_url parsed_url = URI.parse(build_url) @@ -92,7 +92,7 @@ module MergeRequestsHelper end def version_index(merge_request_diff) - return nil if @merge_request_diffs.empty? + return if @merge_request_diffs.empty? @merge_request_diffs.size - @merge_request_diffs.index(merge_request_diff) end @@ -149,7 +149,7 @@ module MergeRequestsHelper def merge_request_source_project_for_project(project = @project) unless can?(current_user, :create_merge_request_in, project) - return nil + return end if can?(current_user, :create_merge_request_from, project) diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index 327b69e5110..50aec83b867 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -231,12 +231,15 @@ module MilestonesHelper end end - def group_or_dashboard_milestone_path(milestone) - if milestone.group_milestone? - group_milestone_path(milestone.group, milestone.iid, milestone: { title: milestone.title }) - else - dashboard_milestone_path(milestone.safe_title, title: milestone.title) - end + def group_or_project_milestone_path(milestone) + params = + if milestone.group_milestone? + { milestone: { title: milestone.title } } + else + { title: milestone.title } + end + + milestone_path(milestone.milestone, params) end def can_admin_project_milestones? diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index 6c65e573307..ea3bcfc791a 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -5,11 +5,8 @@ module NamespacesHelper params.dig(:project, :namespace_id) || params[:namespace_id] end - # rubocop: disable CodeReuse/ActiveRecord def namespaces_options(selected = :current_user, display_path: false, groups: nil, extra_group: nil, groups_only: false) - groups ||= current_user.manageable_groups - .eager_load(:route) - .order('routes.path') + groups ||= current_user.manageable_groups_with_routes users = [current_user.namespace] selected_id = selected @@ -43,7 +40,6 @@ module NamespacesHelper grouped_options_for_select(options, selected_id) end - # rubocop: enable CodeReuse/ActiveRecord def namespace_icon(namespace, size = 40) if namespace.is_a?(Group) diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 033686823a2..a50137bea3d 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -85,7 +85,7 @@ module NotesHelper diffs_project_merge_request_path(discussion.project, discussion.noteable, path_params) elsif discussion.for_commit? - anchor = discussion.line_code if discussion.diff_discussion? + anchor = discussion.diff_discussion? ? discussion.line_code : "note_#{discussion.first_note.id}" project_commit_path(discussion.project, discussion.noteable, anchor: anchor) end @@ -122,7 +122,7 @@ module NotesHelper end def new_form_url - return nil unless @snippet.is_a?(PersonalSnippet) + return unless @snippet.is_a?(PersonalSnippet) snippet_notes_path(@snippet) end @@ -171,7 +171,6 @@ module NotesHelper registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'), markdownDocsPath: help_page_path('user/markdown'), - markdownVersion: issuable.cached_markdown_version, quickActionsDocsPath: help_page_path('user/project/quick_actions'), closePath: close_issuable_path(issuable), reopenPath: reopen_issuable_path(issuable), diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index f4f46b0fe96..766508b6609 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -43,6 +43,18 @@ module PreferencesHelper ] end + def first_day_of_week_choices + [ + [_('Sunday'), 0], + [_('Monday'), 1], + [_('Saturday'), 6] + ] + end + + def first_day_of_week_choices_with_default + first_day_of_week_choices.unshift([_('System default (%{default})') % { default: default_first_day_of_week }, nil]) + end + def user_application_theme @user_application_theme ||= Gitlab::Themes.for_user(current_user).css_class end @@ -51,6 +63,10 @@ module PreferencesHelper Gitlab::ColorSchemes.for_user(current_user).css_class end + def language_choices + Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] } + end + private # Ensure that anyone adding new options updates `DASHBOARD_CHOICES` too @@ -66,4 +82,8 @@ module PreferencesHelper def excluded_dashboard_choices ['operations'] end + + def default_first_day_of_week + first_day_of_week_choices.rassoc(Gitlab::CurrentSettings.first_day_of_week).first + end end diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb index df318de740a..5a42e581867 100644 --- a/app/helpers/profiles_helper.rb +++ b/app/helpers/profiles_helper.rb @@ -25,4 +25,8 @@ module ProfilesHelper end end end + + def user_profile? + params[:controller] == 'users' + end end diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb new file mode 100644 index 00000000000..6daf2e21ca2 --- /dev/null +++ b/app/helpers/projects/error_tracking_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Projects::ErrorTrackingHelper + def error_tracking_data(project) + error_tracking_enabled = !!project.error_tracking_setting&.enabled? + + { + 'index-path' => project_error_tracking_index_path(project, + format: :json), + 'enable-error-tracking-link' => project_settings_operations_path(project), + 'error-tracking-enabled' => error_tracking_enabled.to_s, + 'illustration-path' => image_path('illustrations/cluster_popover.svg') + } + end +end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 0cfc2db3285..2ac8ddc5244 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -204,12 +204,10 @@ module ProjectsHelper current_user.require_extra_setup_for_git_auth? end - def show_auto_devops_implicitly_enabled_banner?(project) - cookie_key = "hide_auto_devops_implicitly_enabled_banner_#{project.id}" + def show_auto_devops_implicitly_enabled_banner?(project, user) + return false unless user_can_see_auto_devops_implicitly_enabled_banner?(project, user) - project.has_auto_devops_implicitly_enabled? && - cookies[cookie_key.to_sym].blank? && - (project.owner == current_user || project.team.maintainer?(current_user)) + cookies["hide_auto_devops_implicitly_enabled_banner_#{project.id}".to_sym].blank? end def link_to_set_password @@ -267,10 +265,6 @@ module ProjectsHelper link_to 'BFG', 'https://rtyley.github.io/bfg-repo-cleaner/', target: '_blank', rel: 'noopener noreferrer' end - def legacy_render_context(params) - params[:legacy_render] ? { markdown_engine: :redcarpet } : {} - end - def explore_projects_tab? current_page?(explore_projects_path) || current_page?(trending_explore_projects_path) || @@ -285,6 +279,25 @@ module ProjectsHelper !disabled && !compact_mode && Feature.enabled?(:project_list_show_issue_count, default_enabled: true) end + # overridden in EE + def settings_operations_available? + can?(current_user, :read_environment, @project) + end + + def error_tracking_setting_project_json + setting = @project.error_tracking_setting + + return if setting.blank? || setting.project_slug.blank? || + setting.organization_slug.blank? + + { + name: setting.project_name, + organization_name: setting.organization_name, + organization_slug: setting.organization_slug, + slug: setting.project_slug + }.to_json + end + private def get_project_nav_tabs(project, current_user) @@ -302,7 +315,8 @@ module ProjectsHelper nav_tabs << :container_registry end - if project.builds_enabled? && can?(current_user, :read_pipeline, project) + # Pipelines feature is tied to presence of builds + if can?(current_user, :read_build, project) nav_tabs << :pipelines end @@ -310,19 +324,24 @@ module ProjectsHelper nav_tabs << :operations end - if project.external_issue_tracker - nav_tabs << :external_issue_tracker - end - tab_ability_map.each do |tab, ability| if can?(current_user, ability, project) nav_tabs << tab end end + nav_tabs << external_nav_tabs(project) + nav_tabs.flatten end + def external_nav_tabs(project) + [].tap do |tabs| + tabs << :external_issue_tracker if project.external_issue_tracker + tabs << :external_wiki if project.external_wiki + end + end + def tab_ability_map { environments: :read_environment, @@ -332,6 +351,7 @@ module ProjectsHelper builds: :read_build, clusters: :read_cluster, serverless: :read_cluster, + error_tracking: :read_sentry_issue, labels: :read_label, issues: :read_issue, project_members: :read_project_member, @@ -485,7 +505,7 @@ module ProjectsHelper lfsHelpPath: help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs'), pagesAvailable: Gitlab.config.pages.enabled, pagesAccessControlEnabled: Gitlab.config.pages.access_control, - pagesHelpPath: help_page_path('user/project/pages/index.md') + pagesHelpPath: help_page_path('user/project/pages/introduction', anchor: 'gitlab-pages-access-control-core-only') } end @@ -546,6 +566,7 @@ module ProjectsHelper services#edit repository#show ci_cd#show + operations#show badges#index pages#show ] @@ -575,8 +596,16 @@ module ProjectsHelper environments clusters functions + error_tracking user gcp ] end + + def user_can_see_auto_devops_implicitly_enabled_banner?(project, user) + Ability.allowed?(user, :admin_project, project) && + project.has_auto_devops_implicitly_enabled? && + project.builds_enabled? && + !project.repository.gitlab_ci_yml + end end diff --git a/app/helpers/runners_helper.rb b/app/helpers/runners_helper.rb index cb21f922401..0d880c38a7b 100644 --- a/app/helpers/runners_helper.rb +++ b/app/helpers/runners_helper.rb @@ -28,4 +28,14 @@ module RunnersHelper display_name + id end end + + # Due to inability of performing sorting of runners by cached "contacted_at" values we have to show uncached values if sorting by "contacted_asc" is requested. + # Please refer to the following issue for more details: https://gitlab.com/gitlab-org/gitlab-ce/issues/55920 + def runner_contacted_at(runner) + if params[:sort] == 'contacted_asc' + runner.uncached_contacted_at + else + runner.contacted_at + end + end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 80cc568820a..0ee76a51f7d 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -24,10 +24,10 @@ module SearchHelper end def search_entries_info(collection, scope, term) - return unless collection.count > 0 + return if collection.to_a.empty? from = collection.offset_value + 1 - to = collection.offset_value + collection.count + to = collection.offset_value + collection.to_a.size count = collection.total_count "Showing #{from} - #{to} of #{count} #{scope.humanize(capitalize: false)} for \"#{term}\"" diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index c7d31f3469d..ecb2b2d707b 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -110,7 +110,7 @@ module SnippetsHelper def embedded_snippet_raw_button blob = @snippet.blob - return if blob.empty? || blob.raw_binary? || blob.stored_externally? + return if blob.empty? || blob.binary? || blob.stored_externally? snippet_raw_url = if @snippet.is_a?(PersonalSnippet) raw_snippet_url(@snippet) @@ -130,12 +130,4 @@ module SnippetsHelper link_to external_snippet_icon('download'), download_url, class: 'btn', target: '_blank', title: 'Download', rel: 'noopener noreferrer' end - - def public_snippet? - if @snippet.project_id? - can?(nil, :read_project_snippet, @snippet) - else - can?(nil, :read_personal_snippet, @snippet) - end - end end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 6ac1f42c321..07ec129dea3 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -128,7 +128,9 @@ module SortingHelper sort_value_recently_created => sort_title_recently_created, sort_value_oldest_created => sort_title_oldest_created, sort_value_recently_updated => sort_title_recently_updated, - sort_value_oldest_updated => sort_title_oldest_updated + sort_value_oldest_updated => sort_title_oldest_updated, + sort_value_recently_last_activity => sort_title_recently_last_activity, + sort_value_oldest_last_activity => sort_title_oldest_last_activity } end @@ -234,7 +236,7 @@ module SortingHelper end def sort_title_milestone - s_('SortOptions|Milestone') + s_('SortOptions|Milestone due date') end def sort_title_milestone_later @@ -317,6 +319,14 @@ module SortingHelper s_('SortOptions|Most stars') end + def sort_title_oldest_last_activity + s_('SortOptions|Oldest last activity') + end + + def sort_title_recently_last_activity + s_('SortOptions|Recent last activity') + end + # Values. def sort_value_access_level_asc 'access_level_asc' @@ -445,4 +455,12 @@ module SortingHelper def sort_value_most_stars 'stars_desc' end + + def sort_value_oldest_last_activity + 'last_activity_on_asc' + end + + def sort_value_recently_last_activity + 'last_activity_on_desc' + end end diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index 4aba48061ba..5d658d35107 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -13,6 +13,13 @@ module UserCalloutsHelper !user_dismissed?(GCP_SIGNUP_OFFER) end + def render_flash_user_callout(flash_type, message, feature_name) + render 'shared/flash_user_callout', flash_type: flash_type, message: message, feature_name: feature_name + end + + def render_dashboard_gold_trial(user) + end + private def user_dismissed?(feature_name) diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 73c1402eae5..73ca17c6605 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -74,6 +74,15 @@ module UsersHelper Gitlab.config.gitlab.impersonation_enabled end + def user_badges_in_admin_section(user) + [].tap do |badges| + badges << { text: s_('AdminUsers|Blocked'), variant: 'danger' } if user.blocked? + badges << { text: s_('AdminUsers|Admin'), variant: 'success' } if user.admin? + badges << { text: s_('AdminUsers|External'), variant: 'secondary' } if user.external? + badges << { text: s_("AdminUsers|It's you!"), variant: nil } if current_user == user + end + end + private def get_profile_tabs diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb index e9fc39e451b..bb5b1555dc4 100644 --- a/app/helpers/workhorse_helper.rb +++ b/app/helpers/workhorse_helper.rb @@ -7,8 +7,7 @@ module WorkhorseHelper def send_git_blob(repository, blob, inline: true) headers.store(*Gitlab::Workhorse.send_git_blob(repository, blob)) - headers['Content-Disposition'] = content_disposition(blob, inline) - headers['Content-Type'] = safe_content_type(blob) + headers['Content-Disposition'] = inline ? 'inline' : 'attachment' # If enabled, this will override the values set above workhorse_set_content_type! @@ -47,6 +46,6 @@ module WorkhorseHelper end def workhorse_set_content_type! - headers[Gitlab::Workhorse::DETECT_HEADER] = "true" if Feature.enabled?(:workhorse_set_content_type) + headers[Gitlab::Workhorse::DETECT_HEADER] = "true" end end diff --git a/app/mailers/abuse_report_mailer.rb b/app/mailers/abuse_report_mailer.rb index e032f568913..e0aa66e6de3 100644 --- a/app/mailers/abuse_report_mailer.rb +++ b/app/mailers/abuse_report_mailer.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class AbuseReportMailer < BaseMailer + layout 'empty_mailer' + + helper EmailsHelper + def notify(abuse_report_id) return unless deliverable? diff --git a/app/mailers/email_rejection_mailer.rb b/app/mailers/email_rejection_mailer.rb index 45fc5a6c383..d743533b1bc 100644 --- a/app/mailers/email_rejection_mailer.rb +++ b/app/mailers/email_rejection_mailer.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class EmailRejectionMailer < BaseMailer + layout 'empty_mailer' + + helper EmailsHelper + def rejection(reason, original_raw, can_retry = false) @reason = reason @original_message = Mail::Message.new(original_raw) diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 370e6d2f90b..d2e334fb856 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -74,9 +74,21 @@ module Emails @new_issue = new_issue @new_project = new_issue.project + @can_access_project = recipient.can?(:read_project, @new_project) mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason)) end + def import_issues_csv_email(user_id, project_id, results) + @user = User.find(user_id) + @project = Project.find(project_id) + @results = results + + mail(to: @user.notification_email, subject: subject('Imported issues')) do |format| + format.html { render layout: 'mailer' } + format.text { render layout: 'mailer' } + end + end + private def setup_issue_mail(issue_id, recipient_id) diff --git a/app/mailers/emails/pipelines.rb b/app/mailers/emails/pipelines.rb index 31e183640ad..fb57c0da34d 100644 --- a/app/mailers/emails/pipelines.rb +++ b/app/mailers/emails/pipelines.rb @@ -15,7 +15,7 @@ module Emails def pipeline_mail(pipeline, recipients, status) @project = pipeline.project @pipeline = pipeline - @merge_request = pipeline.merge_requests.first + @merge_request = pipeline.merge_requests_as_head_pipeline.first add_headers # We use bcc here because we don't want to generate this emails for a diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 2ac4610967d..80e0a17c312 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -76,6 +76,10 @@ class NotifyPreview < ActionMailer::Preview Notify.changed_milestone_issue_email(user.id, issue.id, milestone, user.id) end + def import_issues_csv_email + Notify.import_issues_csv_email(user, project, { success: 3, errors: [5, 6, 7], valid_file: true }) + end + def closed_merge_request_email Notify.closed_merge_request_email(user.id, issue.id, user.id).message end diff --git a/app/mailers/repository_check_mailer.rb b/app/mailers/repository_check_mailer.rb index 145169be8a6..a24d3476d0e 100644 --- a/app/mailers/repository_check_mailer.rb +++ b/app/mailers/repository_check_mailer.rb @@ -2,6 +2,10 @@ class RepositoryCheckMailer < BaseMailer # rubocop: disable CodeReuse/ActiveRecord + layout 'empty_mailer' + + helper EmailsHelper + def notify(failed_count) @message = if failed_count == 1 diff --git a/app/models/active_session.rb b/app/models/active_session.rb index 0d9c6a4a1f0..1e01f1d17e6 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -5,7 +5,8 @@ class ActiveSession attr_accessor :created_at, :updated_at, :session_id, :ip_address, - :browser, :os, :device_name, :device_type + :browser, :os, :device_name, :device_type, + :is_impersonated def current?(session) return false if session_id.nil? || session.id.nil? @@ -31,7 +32,8 @@ class ActiveSession device_type: client.device_type, created_at: user.current_sign_in_at || timestamp, updated_at: timestamp, - session_id: session_id + session_id: session_id, + is_impersonated: request.session[:impersonator_id].present? ) redis.pipelined do diff --git a/app/models/appearance.rb b/app/models/appearance.rb index bffba3e13fa..bdee9b2b73c 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -8,12 +8,20 @@ class Appearance < ActiveRecord::Base cache_markdown_field :description cache_markdown_field :new_project_guidelines + cache_markdown_field :header_message, pipeline: :broadcast_message + cache_markdown_field :footer_message, pipeline: :broadcast_message validates :logo, file_size: { maximum: 1.megabyte } validates :header_logo, file_size: { maximum: 1.megabyte } + validates :message_background_color, allow_blank: true, color: true + validates :message_font_color, allow_blank: true, color: true validate :single_appearance_row, on: :create + default_value_for :message_background_color, '#E75E40' + default_value_for :message_font_color, '#FFFFFF' + default_value_for :email_header_and_footer_enabled, false + mount_uploader :logo, AttachmentUploader mount_uploader :header_logo, AttachmentUploader mount_uploader :favicon, FaviconUploader @@ -28,4 +36,44 @@ class Appearance < ActiveRecord::Base errors.add(:single_appearance_row, 'Only 1 appearances row can exist') end end + + def logo_path + logo_system_path(logo, 'logo') + end + + def header_logo_path + logo_system_path(header_logo, 'header_logo') + end + + def favicon_path + logo_system_path(favicon, 'favicon') + end + + def show_header? + header_message.present? + end + + def show_footer? + footer_message.present? + end + + private + + def logo_system_path(logo, mount_type) + # Legacy attachments may not have have an associated Upload record, + # so fallback to the AttachmentUploader#url if this is the + # case. AttachmentUploader#path doesn't work because for a local + # file, this is an absolute path to the file. + return logo&.url unless logo&.upload + + # If we're using a CDN, we need to use the full URL + asset_host = ActionController::Base.asset_host + local_path = Gitlab::Routing.url_helpers.appearance_upload_path( + filename: logo.filename, + id: logo.upload.model_id, + model: 'appearance', + mounted_as: mount_type) + + Gitlab::Utils.append_path(asset_host, local_path) + end end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 71fbba5b328..a3d662d8250 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -2,4 +2,22 @@ class ApplicationRecord < ActiveRecord::Base self.abstract_class = true + + def self.id_in(ids) + where(id: ids) + end + + def self.safe_find_or_create_by!(*args) + safe_find_or_create_by(*args).tap do |record| + record.validate! unless record.persisted? + end + end + + def self.safe_find_or_create_by(*args) + transaction(requires_new: true) do + find_or_create_by(*args) + end + rescue ActiveRecord::RecordNotUnique + retry + end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 73be94eade6..c5035797621 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -7,7 +7,7 @@ class ApplicationSetting < ActiveRecord::Base include IgnorableColumn include ChronicDurationAttribute - add_authentication_token_field :runners_registration_token, encrypted: true, fallback: true + add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption) ? :optional : :required } add_authentication_token_field :health_check_access_token DOMAIN_LIST_SEPARATOR = %r{\s*[,;]\s* # comma or semicolon, optionally surrounded by whitespace @@ -193,6 +193,10 @@ class ApplicationSetting < ActiveRecord::Base allow_nil: true, numericality: { only_integer: true, greater_than_or_equal_to: 1.day.seconds } + validates :local_markdown_version, + allow_nil: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than: 65536 } + SUPPORTED_KEY_TYPES.each do |type| validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } end @@ -246,6 +250,7 @@ class ApplicationSetting < ActiveRecord::Base dsa_key_restriction: 0, ecdsa_key_restriction: 0, ed25519_key_restriction: 0, + first_day_of_week: 0, gitaly_timeout_default: 55, gitaly_timeout_fast: 10, gitaly_timeout_medium: 30, @@ -303,7 +308,8 @@ class ApplicationSetting < ActiveRecord::Base usage_stats_set_by_user_id: nil, diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES, commit_email_hostname: default_commit_email_hostname, - protected_ci_variables: false + protected_ci_variables: false, + local_markdown_version: 0 } end @@ -312,7 +318,7 @@ class ApplicationSetting < ActiveRecord::Base end def self.create_from_defaults - create(defaults) + build_from_defaults.tap(&:save) end def self.human_attribute_name(attr, _options = {}) @@ -383,7 +389,7 @@ class ApplicationSetting < ActiveRecord::Base end def restricted_visibility_levels=(levels) - super(levels.map { |level| Gitlab::VisibilityLevel.level_value(level) }) + super(levels&.map { |level| Gitlab::VisibilityLevel.level_value(level) }) end def strip_sentry_values diff --git a/app/models/blob.rb b/app/models/blob.rb index 66a0925c495..c5766eb0327 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -102,7 +102,7 @@ class Blob < SimpleDelegator # If the blob is a text based blob the content is converted to UTF-8 and any # invalid byte sequences are replaced. def data - if binary? + if binary_in_repo? super else @data ||= super.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) @@ -149,7 +149,7 @@ class Blob < SimpleDelegator # an LFS pointer, we assume the file stored in LFS is binary, unless a # text-based rich blob viewer matched on the file's extension. Otherwise, this # depends on the type of the blob itself. - def raw_binary? + def binary? if stored_externally? if rich_viewer rich_viewer.binary? @@ -161,7 +161,7 @@ class Blob < SimpleDelegator true end else - binary? + binary_in_repo? end end @@ -180,7 +180,7 @@ class Blob < SimpleDelegator end def readable_text? - text? && !stored_externally? && !truncated? + text_in_repo? && !stored_externally? && !truncated? end def simple_viewer @@ -220,7 +220,7 @@ class Blob < SimpleDelegator def simple_viewer_class if empty? BlobViewer::Empty - elsif raw_binary? + elsif binary? BlobViewer::Download else # text BlobViewer::Text diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb index eaaf9af1330..df6b9bb2f0b 100644 --- a/app/models/blob_viewer/base.rb +++ b/app/models/blob_viewer/base.rb @@ -16,7 +16,7 @@ module BlobViewer def initialize(blob) @blob = blob - @initially_binary = blob.binary? + @initially_binary = blob.binary_in_repo? end def self.partial_path @@ -52,7 +52,7 @@ module BlobViewer end def self.can_render?(blob, verify_binary: true) - return false if verify_binary && binary? != blob.binary? + return false if verify_binary && binary? != blob.binary_in_repo? return true if extensions&.include?(blob.extension) return true if file_types&.include?(blob.file_type) @@ -72,7 +72,7 @@ module BlobViewer end def binary_detected_after_load? - !@initially_binary && blob.binary? + !@initially_binary && blob.binary_in_repo? end # This method is used on the server side to check whether we can attempt to diff --git a/app/models/blob_viewer/gitlab_ci_yml.rb b/app/models/blob_viewer/gitlab_ci_yml.rb index 655241c2808..11228e620c9 100644 --- a/app/models/blob_viewer/gitlab_ci_yml.rb +++ b/app/models/blob_viewer/gitlab_ci_yml.rb @@ -10,16 +10,16 @@ module BlobViewer self.file_types = %i(gitlab_ci) self.binary = false - def validation_message(project, sha) + def validation_message(opts) return @validation_message if defined?(@validation_message) prepare! - @validation_message = Gitlab::Ci::YamlProcessor.validation_message(blob.data, { project: project, sha: sha }) + @validation_message = Gitlab::Ci::YamlProcessor.validation_message(blob.data, opts) end - def valid?(project, sha) - validation_message(project, sha).blank? + def valid?(opts) + validation_message(opts).blank? end end end diff --git a/app/models/board.rb b/app/models/board.rb index a137863456c..758a71d6903 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -21,6 +21,10 @@ class Board < ActiveRecord::Base group_id.present? end + def project_board? + project_id.present? + end + def backlog_list lists.merge(List.backlog).take end diff --git a/app/models/board_group_recent_visit.rb b/app/models/board_group_recent_visit.rb index 92abbb67222..f5b75270595 100644 --- a/app/models/board_group_recent_visit.rb +++ b/app/models/board_group_recent_visit.rb @@ -10,7 +10,7 @@ class BoardGroupRecentVisit < ActiveRecord::Base validates :group, presence: true validates :board, presence: true - scope :by_user_group, -> (user, group) { where(user: user, group: group).order(:updated_at) } + scope :by_user_group, -> (user, group) { where(user: user, group: group) } def self.visited!(user, board) visit = find_or_create_by(user: user, group: board.group, board: board) @@ -19,7 +19,10 @@ class BoardGroupRecentVisit < ActiveRecord::Base retry end - def self.latest(user, group) - by_user_group(user, group).last + def self.latest(user, group, count: nil) + visits = by_user_group(user, group).order(updated_at: :desc) + visits = visits.preload(:board) if count && count > 1 + + visits.first(count) end end diff --git a/app/models/board_project_recent_visit.rb b/app/models/board_project_recent_visit.rb index 7cffff906d8..2a1b14b3ae0 100644 --- a/app/models/board_project_recent_visit.rb +++ b/app/models/board_project_recent_visit.rb @@ -10,7 +10,7 @@ class BoardProjectRecentVisit < ActiveRecord::Base validates :project, presence: true validates :board, presence: true - scope :by_user_project, -> (user, project) { where(user: user, project: project).order(:updated_at) } + scope :by_user_project, -> (user, project) { where(user: user, project: project) } def self.visited!(user, board) visit = find_or_create_by(user: user, project: board.project, board: board) @@ -19,7 +19,10 @@ class BoardProjectRecentVisit < ActiveRecord::Base retry end - def self.latest(user, project) - by_user_project(user, project).last + def self.latest(user, project, count: nil) + visits = by_user_project(user, project).order(updated_at: :desc) + visits = visits.preload(:board) if count && count > 1 + + visits.first(count) end end diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 29aa00a66d9..0d8d7d95791 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -2,13 +2,19 @@ module Ci class Bridge < CommitStatus + include Ci::Processable + include Ci::Contextable include Importable include AfterCommitQueue + include HasRef include Gitlab::Utils::StrongMemoize belongs_to :project + belongs_to :trigger_request validates :ref, presence: true + delegate :merge_request_event?, to: :pipeline + def self.retry(bridge, current_user) raise NotImplementedError end @@ -23,12 +29,31 @@ module Ci .fabricate! end - def predefined_variables - raise NotImplementedError + def schedulable? + false + end + + def action? + false + end + + def artifacts? + false + end + + def runnable? + false + end + + def expanded_environment_name end def execute_hooks raise NotImplementedError end + + def to_partial_path + 'projects/generic_commit_statuses/generic_commit_status' + end end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 16a72c680fa..a64c6051f95 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -3,13 +3,22 @@ module Ci class Build < CommitStatus prepend ArtifactMigratable + include Ci::Processable + include Ci::Metadatable + include Ci::Contextable include TokenAuthenticatable include AfterCommitQueue include ObjectStorage::BackgroundMove include Presentable include Importable + include IgnorableColumn include Gitlab::Utils::StrongMemoize include Deployable + include HasRef + + BuildArchivedError = Class.new(StandardError) + + ignore_column :commands belongs_to :project, inverse_of: :builds belongs_to :runner @@ -30,25 +39,34 @@ module Ci has_one :"job_artifacts_#{key}", -> { where(file_type: value) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id end - has_one :metadata, class_name: 'Ci::BuildMetadata' has_one :runner_session, class_name: 'Ci::BuildRunnerSession', validate: true, inverse_of: :build accepts_nested_attributes_for :runner_session - delegate :timeout, to: :metadata, prefix: true, allow_nil: true delegate :url, to: :runner_session, prefix: true, allow_nil: true delegate :terminal_specification, to: :runner_session, allow_nil: true delegate :gitlab_deploy_token, to: :project delegate :trigger_short_token, to: :trigger_request, allow_nil: true + delegate :merge_request_event?, to: :pipeline ## - # The "environment" field for builds is a String, and is the unexpanded name! + # Since Gitlab 11.5, deployments records started being created right after + # `ci_builds` creation. We can look up a relevant `environment` through + # `deployment` relation today. This is much more efficient than expanding + # environment name with variables. + # (See more https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22380) # + # However, we have to still expand environment name if it's a stop action, + # because `deployment` persists information for start action only. + # + # We will follow up this by persisting expanded name in build metadata or + # persisting stop action in database. def persisted_environment return unless has_environment? strong_memoize(:persisted_environment) do - Environment.find_by(name: expanded_environment_name, project: project) + deployment&.environment || + Environment.find_by(name: expanded_environment_name, project: project) end end @@ -120,13 +138,12 @@ module Ci acts_as_taggable - add_authentication_token_field :token, encrypted: true, fallback: true + add_authentication_token_field :token, encrypted: :optional before_save :update_artifacts_size, if: :artifacts_file_changed? before_save :ensure_token before_destroy { unscoped_project } - before_create :ensure_metadata after_create unless: :importing? do |build| run_after_commit { BuildHooksWorker.perform_async(build.id) } end @@ -218,8 +235,19 @@ module Ci before_transition any => [:failed] do |build| next unless build.project + next unless build.deployment + + begin + build.deployment.drop! + rescue => e + Gitlab::Sentry.track_exception(e, extra: { build_id: build.id }) + end + + true + end - build.deployment&.drop + after_transition any => [:failed] do |build| + next unless build.project if build.retry_failure? begin @@ -243,10 +271,6 @@ module Ci end end - def ensure_metadata - metadata || build_metadata(project: project) - end - def detailed_status(current_user) Gitlab::Ci::Status::Build::Factory .new(self, current_user) @@ -266,13 +290,8 @@ module Ci self.name == 'pages' end - # degenerated build is one that cannot be run by Runner - def degenerated? - self.options.nil? - end - - def degenerate! - self.update!(options: nil, yaml_variables: nil, commands: nil) + def runnable? + true end def archived? @@ -384,63 +403,59 @@ module Ci options&.dig(:environment, :on_stop) end - # A slugified version of the build ref, suitable for inclusion in URLs and - # domain names. Rules: + ## + # All variables, including persisted environment variables. # - # * Lowercased - # * Anything not matching [a-z0-9-] is replaced with a - - # * Maximum length is 63 bytes - # * First/Last Character is not a hyphen - def ref_slug - Gitlab::Utils.slugify(ref.to_s) + def variables + strong_memoize(:variables) do + Gitlab::Ci::Variables::Collection.new + .concat(persisted_variables) + .concat(scoped_variables) + .concat(persisted_environment_variables) + .to_runner_variables + end end - ## - # Variables in the environment name scope. - # - def scoped_variables(environment: expanded_environment_name) + CI_REGISTRY_USER = 'gitlab-ci-token'.freeze + + def persisted_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| - variables.concat(predefined_variables) - variables.concat(project.predefined_variables) - variables.concat(pipeline.predefined_variables) - variables.concat(runner.predefined_variables) if runner - variables.concat(project.deployment_variables(environment: environment)) if environment - variables.concat(yaml_variables) - variables.concat(user_variables) - variables.concat(secret_group_variables) - variables.concat(secret_project_variables(environment: environment)) - variables.concat(trigger_request.user_variables) if trigger_request - variables.concat(pipeline.variables) - variables.concat(pipeline.pipeline_schedule.job_variables) if pipeline.pipeline_schedule + break variables unless persisted? + + variables + .concat(pipeline.persisted_variables) + .append(key: 'CI_JOB_ID', value: id.to_s) + .append(key: 'CI_JOB_URL', value: Gitlab::Routing.url_helpers.project_job_url(project, self)) + .append(key: 'CI_JOB_TOKEN', value: token.to_s, public: false) + .append(key: 'CI_BUILD_ID', value: id.to_s) + .append(key: 'CI_BUILD_TOKEN', value: token.to_s, public: false) + .append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER) + .append(key: 'CI_REGISTRY_PASSWORD', value: token.to_s, public: false) + .append(key: 'CI_REPOSITORY_URL', value: repo_url.to_s, public: false) + .concat(deploy_token_variables) end end - ## - # Variables that do not depend on the environment name. - # - def simple_variables - strong_memoize(:simple_variables) do - scoped_variables(environment: nil).to_runner_variables + def persisted_environment_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless persisted? && persisted_environment.present? + + variables.concat(persisted_environment.predefined_variables) + + # Here we're passing unexpanded environment_url for runner to expand, + # and we need to make sure that CI_ENVIRONMENT_NAME and + # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded. + variables.append(key: 'CI_ENVIRONMENT_URL', value: environment_url) if environment_url end end - ## - # All variables, including persisted environment variables. - # - def variables - Gitlab::Ci::Variables::Collection.new - .concat(persisted_variables) - .concat(scoped_variables) - .concat(persisted_environment_variables) - .to_runner_variables - end + def deploy_token_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless gitlab_deploy_token - ## - # Regular Ruby hash of scoped variables, without duplicates that are - # possible to be present in an array of hashes returned from `variables`. - # - def scoped_variables_hash - scoped_variables.to_hash + variables.append(key: 'CI_DEPLOY_USER', value: gitlab_deploy_token.username) + variables.append(key: 'CI_DEPLOY_PASSWORD', value: gitlab_deploy_token.token, public: false) + end end def features @@ -618,35 +633,6 @@ module Ci super || project.try(:build_coverage_regex) end - def when - read_attribute(:when) || build_attributes_from_config[:when] || 'on_success' - end - - def yaml_variables - read_attribute(:yaml_variables) || build_attributes_from_config[:yaml_variables] || [] - end - - def user_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - break variables if user.blank? - - variables.append(key: 'GITLAB_USER_ID', value: user.id.to_s) - variables.append(key: 'GITLAB_USER_EMAIL', value: user.email) - variables.append(key: 'GITLAB_USER_LOGIN', value: user.username) - variables.append(key: 'GITLAB_USER_NAME', value: user.name) - end - end - - def secret_group_variables - return [] unless project.group - - project.group.ci_variables_for(ref, project) - end - - def secret_project_variables(environment: persisted_environment) - project.ci_variables_for(ref: ref, environment: environment) - end - def steps [Gitlab::Ci::Build::Step.from_commands(self), Gitlab::Ci::Build::Step.from_after_script(self)].compact @@ -749,7 +735,7 @@ module Ci # Virtual deployment status depending on the environment status. def deployment_status - return nil unless starts_environment? + return unless starts_environment? if success? return successful_deployment_status @@ -806,89 +792,6 @@ module Ci @unscoped_project ||= Project.unscoped.find_by(id: project_id) end - CI_REGISTRY_USER = 'gitlab-ci-token'.freeze - - def persisted_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - break variables unless persisted? - - variables - .concat(pipeline.persisted_variables) - .append(key: 'CI_JOB_ID', value: id.to_s) - .append(key: 'CI_JOB_URL', value: Gitlab::Routing.url_helpers.project_job_url(project, self)) - .append(key: 'CI_JOB_TOKEN', value: token.to_s, public: false) - .append(key: 'CI_BUILD_ID', value: id.to_s) - .append(key: 'CI_BUILD_TOKEN', value: token.to_s, public: false) - .append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER) - .append(key: 'CI_REGISTRY_PASSWORD', value: token.to_s, public: false) - .append(key: 'CI_REPOSITORY_URL', value: repo_url.to_s, public: false) - .concat(deploy_token_variables) - end - end - - def predefined_variables # rubocop:disable Metrics/AbcSize - Gitlab::Ci::Variables::Collection.new.tap do |variables| - variables.append(key: 'CI', value: 'true') - variables.append(key: 'GITLAB_CI', value: 'true') - variables.append(key: 'GITLAB_FEATURES', value: project.licensed_features.join(',')) - variables.append(key: 'CI_SERVER_NAME', value: 'GitLab') - variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) - variables.append(key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s) - variables.append(key: 'CI_SERVER_VERSION_MINOR', value: Gitlab.version_info.minor.to_s) - variables.append(key: 'CI_SERVER_VERSION_PATCH', value: Gitlab.version_info.patch.to_s) - variables.append(key: 'CI_SERVER_REVISION', value: Gitlab.revision) - variables.append(key: 'CI_JOB_NAME', value: name) - variables.append(key: 'CI_JOB_STAGE', value: stage) - 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: ref) - variables.append(key: 'CI_COMMIT_REF_SLUG', value: ref_slug) - variables.append(key: "CI_COMMIT_TAG", value: ref) if tag? - variables.append(key: "CI_PIPELINE_TRIGGERED", value: 'true') if trigger_request - variables.append(key: "CI_JOB_MANUAL", value: 'true') if action? - variables.append(key: "CI_NODE_INDEX", value: self.options[:instance].to_s) if self.options&.include?(:instance) - variables.append(key: "CI_NODE_TOTAL", value: (self.options&.dig(:parallel) || 1).to_s) - variables.concat(legacy_variables) - end - end - - def legacy_variables - Gitlab::Ci::Variables::Collection.new.tap do |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: ref) - variables.append(key: 'CI_BUILD_REF_SLUG', value: ref_slug) - variables.append(key: 'CI_BUILD_NAME', value: name) - variables.append(key: 'CI_BUILD_STAGE', value: stage) - variables.append(key: "CI_BUILD_TAG", value: ref) if tag? - variables.append(key: "CI_BUILD_TRIGGERED", value: 'true') if trigger_request - variables.append(key: "CI_BUILD_MANUAL", value: 'true') if action? - end - end - - def persisted_environment_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - break variables unless persisted? && persisted_environment.present? - - variables.concat(persisted_environment.predefined_variables) - - # Here we're passing unexpanded environment_url for runner to expand, - # and we need to make sure that CI_ENVIRONMENT_NAME and - # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded. - variables.append(key: 'CI_ENVIRONMENT_URL', value: environment_url) if environment_url - end - end - - def deploy_token_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - break variables unless gitlab_deploy_token - - variables.append(key: 'CI_DEPLOY_USER', value: gitlab_deploy_token.username) - variables.append(key: 'CI_DEPLOY_PASSWORD', value: gitlab_deploy_token.token, public: false) - end - end - def environment_url options&.dig(:environment, :url) || persisted_environment&.external_url end @@ -899,8 +802,11 @@ module Ci # have the old integer only format. This method returns the retry option # normalized as a hash in 11.5+ format. def normalized_retry - value = options&.dig(:retry) - value.is_a?(Integer) ? { max: value } : value.to_h + strong_memoize(:normalized_retry) do + value = options&.dig(:retry) + value = value.is_a?(Integer) ? { max: value } : value.to_h + value.with_indifferent_access + end end def build_attributes_from_config diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 9d588b862bd..cd8eb774cf5 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -10,11 +10,15 @@ module Ci self.table_name = 'ci_builds_metadata' - belongs_to :build, class_name: 'Ci::Build' + belongs_to :build, class_name: 'CommitStatus' belongs_to :project + before_create :set_build_project + validates :build, presence: true - validates :project, presence: true + + serialize :config_options, Serializers::JSON # rubocop:disable Cop/ActiveRecordSerialize + serialize :config_variables, Serializers::JSON # rubocop:disable Cop/ActiveRecordSerialize chronic_duration_attr_reader :timeout_human_readable, :timeout @@ -33,5 +37,11 @@ module Ci update(timeout: timeout, timeout_source: timeout_source) end + + private + + def set_build_project + self.project_id ||= self.build.project_id + end end end diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index da08214963f..75017f224a0 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -18,7 +18,7 @@ module Ci FailedToPersistDataError = Class.new(StandardError) # Note: The ordering of this enum is related to the precedence of persist store. - # The bottom item takes the higest precedence, and the top item takes the lowest precedence. + # The bottom item takes the highest precedence, and the top item takes the lowest precedence. enum data_store: { redis: 1, database: 2, @@ -115,7 +115,7 @@ module Ci current_data = get_data unless current_data&.bytesize.to_i == CHUNK_SIZE - raise FailedToPersistDataError, 'Data is not fullfilled in a bucket' + raise FailedToPersistDataError, 'Data is not fulfilled in a bucket' end old_store_class = self.class.get_store_class(data_store) diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index 492d1d0329e..323ff560564 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -5,6 +5,7 @@ module Ci extend Gitlab::Ci::Model include HasVariable include Presentable + include Maskable belongs_to :group, class_name: "::Group" diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 11c88200c37..789bb293811 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -73,6 +73,8 @@ module Ci where(file_type: types) end + scope :expired, -> (limit) { where('expire_at < ?', Time.now).limit(limit) } + delegate :filename, :exists?, :open, to: :file enum file_type: { diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 25937065011..ca9725f7a04 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -11,6 +11,11 @@ module Ci include Gitlab::Utils::StrongMemoize include AtomicInternalId include EnumWithNil + include HasRef + include ShaAttribute + + sha_attribute :source_sha + sha_attribute :target_sha belongs_to :project, inverse_of: :all_pipelines belongs_to :user @@ -24,6 +29,8 @@ module Ci has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline + has_many :processables, -> { processables }, + class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent has_many :variables, class_name: 'Ci::PipelineVariable' @@ -32,7 +39,7 @@ module Ci # Merge requests for which the current pipeline is running against # the merge request's latest commit. - has_many :merge_requests, foreign_key: "head_pipeline_id" + has_many :merge_requests_as_head_pipeline, foreign_key: "head_pipeline_id", class_name: 'MergeRequest' has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build' has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build' @@ -44,6 +51,8 @@ module Ci has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id' has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id' + has_one :chat_data, class_name: 'Ci::PipelineChatData' + accepts_nested_attributes_for :variables, reject_if: :persisted? delegate :id, to: :project, prefix: true @@ -51,9 +60,9 @@ module Ci validates :sha, presence: { unless: :importing? } validates :ref, presence: { unless: :importing? } - validates :merge_request, presence: { if: :merge_request? } - validates :merge_request, absence: { unless: :merge_request? } - validates :tag, inclusion: { in: [false], if: :merge_request? } + validates :merge_request, presence: { if: :merge_request_event? } + validates :merge_request, absence: { unless: :merge_request_event? } + validates :tag, inclusion: { in: [false], if: :merge_request_event? } validates :status, presence: { unless: :importing? } validate :valid_commit_sha, unless: :importing? validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create @@ -170,13 +179,38 @@ module Ci scope :sort_by_merge_request_pipelines, -> do sql = 'CASE ci_pipelines.source WHEN (?) THEN 0 ELSE 1 END, ci_pipelines.id DESC' - query = ActiveRecord::Base.send(:sanitize_sql_array, [sql, sources[:merge_request]]) # rubocop:disable GitlabSecurity/PublicSend + query = ActiveRecord::Base.send(:sanitize_sql_array, [sql, sources[:merge_request_event]]) # rubocop:disable GitlabSecurity/PublicSend order(query) end scope :for_user, -> (user) { where(user: user) } + scope :for_merge_request, -> (merge_request, ref, sha) do + ## + # We have to filter out unrelated MR pipelines. + # When merge request is empty, it selects general pipelines, such as push sourced pipelines. + # When merge request is matched, it selects MR pipelines. + where(merge_request: [nil, merge_request], ref: ref, sha: sha) + .sort_by_merge_request_pipelines + end + + scope :triggered_by_merge_request, -> (merge_request) do + where(source: :merge_request_event, merge_request: merge_request) + end + + scope :detached_merge_request_pipelines, -> (merge_request) do + triggered_by_merge_request(merge_request).where(target_sha: nil) + end + + scope :merge_request_pipelines, -> (merge_request) do + triggered_by_merge_request(merge_request).where.not(target_sha: nil) + end + + scope :mergeable_merge_request_pipelines, -> (merge_request) do + triggered_by_merge_request(merge_request).where(target_sha: merge_request.target_branch_sha) + end + # Returns the pipelines in descending order (= newest first), optionally # limited to a number of references. # @@ -264,6 +298,10 @@ module Ci sources.reject { |source| source == "external" }.values end + def self.latest_for_merge_request(merge_request, ref, sha) + for_merge_request(merge_request, ref, sha).first + end + def self.ci_sources_values config_sources.values_at(:repository_source, :auto_devops_source, :unknown_source) end @@ -299,7 +337,7 @@ module Ci def ordered_stages return legacy_stages unless complete? - if Feature.enabled?('ci_pipeline_persisted_stages') + if Feature.enabled?('ci_pipeline_persisted_stages', default_enabled: true) stages else legacy_stages @@ -379,10 +417,6 @@ module Ci @commit ||= Commit.lazy(project, sha) end - def branch? - !tag? && !merge_request? - end - def stuck? pending_builds.any?(&:stuck?) end @@ -495,7 +529,7 @@ module Ci return @config_processor if defined?(@config_processor) @config_processor ||= begin - ::Gitlab::Ci::YamlProcessor.new(ci_yaml_file, { project: project, sha: sha }) + ::Gitlab::Ci::YamlProcessor.new(ci_yaml_file, { project: project, sha: sha, user: user }) rescue Gitlab::Ci::YamlProcessor::ValidationError => e self.yaml_errors = e.message nil @@ -580,7 +614,7 @@ module Ci end def protected_ref? - strong_memoize(:protected_ref) { project.protected_for?(ref) } + strong_memoize(:protected_ref) { project.protected_for?(git_ref) } end def legacy_trigger @@ -605,7 +639,9 @@ module Ci 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) - if merge_request? && merge_request + if merge_request_event? && merge_request + 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) variables.concat(merge_request.predefined_variables) end end @@ -633,10 +669,10 @@ module Ci # All the merge requests for which the current pipeline runs/ran against def all_merge_requests @all_merge_requests ||= - if merge_request? - project.merge_requests.where(id: merge_request.id) + if merge_request_event? + MergeRequest.where(id: merge_request_id) else - project.merge_requests.where(source_branch: ref) + MergeRequest.where(source_project_id: project_id, source_branch: ref) end end @@ -671,9 +707,18 @@ module Ci end end + # Returns the modified paths. + # + # The returned value is + # * Array: List of modified paths that should be evaluated + # * nil: Modified path can not be evaluated def modified_paths strong_memoize(:modified_paths) do - push_details.modified_paths + if merge_request_event? + merge_request.modified_paths + elsif branch_updated? + push_details.modified_paths + end end end @@ -681,6 +726,22 @@ module Ci ref == project.default_branch end + def triggered_by_merge_request? + merge_request_event? && merge_request_id.present? + end + + def detached_merge_request_pipeline? + triggered_by_merge_request? && target_sha.nil? + end + + def merge_request_pipeline? + triggered_by_merge_request? && target_sha.present? + end + + def mergeable_merge_request_pipeline? + triggered_by_merge_request? && target_sha == merge_request.target_branch_sha + end + private def ci_yaml_from_repo @@ -712,14 +773,16 @@ module Ci end def git_ref - if branch? - Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s - elsif merge_request? + if merge_request_event? + ## + # In the future, we're going to change this ref to + # merge request's merged reference, such as "refs/merge-requests/:iid/merge". + # In order to do that, we have to update GitLab-Runner's source pulling + # logic. + # See https://gitlab.com/gitlab-org/gitlab-runner/merge_requests/1092 Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s - elsif tag? - Gitlab::Git::TAG_REF_PREFIX + ref.to_s else - raise ArgumentError, 'Invalid pipeline type!' + super end end diff --git a/app/models/ci/pipeline_chat_data.rb b/app/models/ci/pipeline_chat_data.rb new file mode 100644 index 00000000000..8d37500fec5 --- /dev/null +++ b/app/models/ci/pipeline_chat_data.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ci + class PipelineChatData < ActiveRecord::Base + self.table_name = 'ci_pipeline_chat_data' + + belongs_to :chat_name + + validates :pipeline_id, presence: true + validates :chat_name_id, presence: true + validates :response_url, presence: true + end +end diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb index 2994aaae4aa..571c4271475 100644 --- a/app/models/ci/pipeline_enums.rb +++ b/app/models/ci/pipeline_enums.rb @@ -22,7 +22,8 @@ module Ci schedule: 4, api: 5, external: 6, - merge_request: 10 + chat: 8, + merge_request_event: 10 } end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 8249199e76f..ce26ee168ef 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -10,7 +10,7 @@ module Ci include FromUnion include TokenAuthenticatable - add_authentication_token_field :token, encrypted: true, migrating: true + add_authentication_token_field :token, encrypted: -> { Feature.enabled?(:ci_runners_tokens_optional_encryption) ? :optional : :required } enum access_level: { not_protected: 0, @@ -97,6 +97,7 @@ module Ci scope :order_contacted_at_asc, -> { order(contacted_at: :asc) } scope :order_created_at_desc, -> { order(created_at: :desc) } + scope :with_tags, -> { preload(:tags) } validate :tag_constraints validates :access_level, presence: true @@ -256,6 +257,10 @@ module Ci end end + def uncached_contacted_at + read_attribute(:contacted_at) + end + private def cleanup_runner_queue diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 58f3fe2460a..0389945191e 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -14,6 +14,7 @@ module Ci has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id has_many :builds, foreign_key: :stage_id + has_many :bridges, foreign_key: :stage_id with_options unless: :importing? do validates :project, presence: true diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 55db42162ca..637148c4ce4 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -4,6 +4,7 @@ module Ci class Trigger < ActiveRecord::Base extend Gitlab::Ci::Model include IgnorableColumn + include Presentable ignore_column :deleted_at @@ -29,7 +30,7 @@ module Ci end def short_token - token[0...4] + token[0...4] if token.present? end def legacy? diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index 524d79014f8..64836ea4fa4 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -5,6 +5,7 @@ module Ci extend Gitlab::Ci::Model include HasVariable include Presentable + include Maskable belongs_to :project diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb index 74ef7c7e145..c758577815a 100644 --- a/app/models/clusters/applications/cert_manager.rb +++ b/app/models/clusters/applications/cert_manager.rb @@ -3,7 +3,7 @@ module Clusters module Applications class CertManager < ActiveRecord::Base - VERSION = 'v0.5.0'.freeze + VERSION = 'v0.5.2'.freeze self.table_name = 'clusters_applications_cert_managers' diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index 8f8790585a3..7c15aaa4825 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Ingress < ActiveRecord::Base - VERSION = '0.23.0'.freeze + VERSION = '1.1.2'.freeze self.table_name = 'clusters_applications_ingress' @@ -23,7 +23,7 @@ module Clusters FETCH_IP_ADDRESS_DELAY = 30.seconds state_machine :status do - before_transition any => [:installed] do |application| + after_transition any => [:installed] do |application| application.run_after_commit do ClusterWaitForIngressIpAddressWorker.perform_in( FETCH_IP_ADDRESS_DELAY, application.name, application.id) diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index 0c72d7d8340..8d79b041b64 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -5,7 +5,7 @@ module Clusters class Knative < ActiveRecord::Base VERSION = '0.2.2'.freeze REPOSITORY = 'https://storage.googleapis.com/triggermesh-charts'.freeze - + METRICS_CONFIG = 'https://storage.googleapis.com/triggermesh-charts/istio-metrics.yaml'.freeze FETCH_IP_ADDRESS_DELAY = 30.seconds self.table_name = 'clusters_applications_knative' @@ -19,8 +19,15 @@ module Clusters self.reactive_cache_key = ->(knative) { [knative.class.model_name.singular, knative.id] } + def set_initial_status + return unless not_installable? + return unless verify_cluster? + + self.status = 'installable' + end + state_machine :status do - before_transition any => [:installed] do |application| + after_transition any => [:installed] do |application| application.run_after_commit do ClusterWaitForIngressIpAddressWorker.perform_in( FETCH_IP_ADDRESS_DELAY, application.name, application.id) @@ -34,6 +41,8 @@ module Clusters scope :for_cluster, -> (cluster) { where(cluster: cluster) } + after_save :clear_reactive_cache! + def chart 'knative/knative' end @@ -49,7 +58,8 @@ module Clusters rbac: cluster.platform_kubernetes_rbac?, chart: chart, files: files, - repository: REPOSITORY + repository: REPOSITORY, + postinstall: install_knative_metrics ) end @@ -71,7 +81,7 @@ module Clusters end def calculate_reactive_cache - { services: read_services } + { services: read_services, pods: read_pods } end def ingress_service @@ -79,7 +89,7 @@ module Clusters end def services_for(ns: namespace) - return unless services + return [] unless services return [] unless ns services.select do |service| @@ -87,13 +97,35 @@ module Clusters end end + def service_pod_details(ns, service) + with_reactive_cache do |data| + data[:pods].select { |pod| filter_pods(pod, ns, service) } + end + end + private + def read_pods + cluster.kubeclient.core_client.get_pods.as_json + end + + def filter_pods(pod, namespace, service) + pod["metadata"]["namespace"] == namespace && pod["metadata"]["labels"]["serving.knative.dev/service"] == service + end + def read_services client.get_services.as_json rescue Kubeclient::ResourceNotFoundError [] end + + def install_knative_metrics + ["kubectl apply -f #{METRICS_CONFIG}"] if cluster.application_prometheus_available? + end + + def verify_cluster? + cluster&.application_helm_available? && cluster&.platform_kubernetes_rbac? + end end end end diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 46d0388a464..fa7ce363531 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -5,7 +5,7 @@ module Clusters class Prometheus < ActiveRecord::Base include PrometheusAdapter - VERSION = '6.7.3'.freeze + VERSION = '6.7.3' self.table_name = 'clusters_applications_prometheus' @@ -24,14 +24,6 @@ module Clusters end end - def ready_status - [:installed] - end - - def ready? - ready_status.include?(status_name) - end - def chart 'stable/prometheus' end @@ -50,10 +42,29 @@ module Clusters version: VERSION, rbac: cluster.platform_kubernetes_rbac?, chart: chart, - files: files + files: files, + postinstall: install_knative_metrics ) end + def upgrade_command(values) + ::Gitlab::Kubernetes::Helm::InstallCommand.new( + name: name, + version: VERSION, + rbac: cluster.platform_kubernetes_rbac?, + chart: chart, + files: files_with_replaced_values(values) + ) + end + + # Returns a copy of files where the values of 'values.yaml' + # are replaced by the argument. + # + # See #values for the data format required + def files_with_replaced_values(replaced_values) + files.merge('values.yaml': replaced_values) + end + def prometheus_client return unless kube_client @@ -74,6 +85,10 @@ module Clusters def kube_client cluster&.kubeclient&.core_client end + + def install_knative_metrics + ["kubectl apply -f #{Clusters::Applications::Knative::METRICS_CONFIG}"] if cluster.application_knative_available? + end end end end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index c931b340b24..941551dadaa 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 < ActiveRecord::Base - VERSION = '0.1.39'.freeze + VERSION = '0.2.0'.freeze self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 7fe43cd2de0..be3e6a05e1e 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -18,6 +18,7 @@ module Clusters Applications::Knative.application_name => Applications::Knative }.freeze DEFAULT_ENVIRONMENT = '*'.freeze + KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN'.freeze belongs_to :user @@ -49,8 +50,9 @@ module Clusters validates :name, cluster_name: true validates :cluster_type, presence: true - validate :restrict_modification, on: :update + validates :domain, allow_blank: true, hostname: { allow_numeric_hostname: true } + validate :restrict_modification, on: :update validate :no_groups, unless: :group_type? validate :no_projects, unless: :project_type? @@ -63,6 +65,10 @@ module Clusters delegate :available?, to: :application_helm, prefix: true, allow_nil: true delegate :available?, to: :application_ingress, prefix: true, allow_nil: true delegate :available?, to: :application_prometheus, prefix: true, allow_nil: true + delegate :available?, to: :application_knative, prefix: true, allow_nil: true + delegate :external_ip, to: :application_ingress, prefix: true, allow_nil: true + + alias_attribute :base_domain, :domain enum cluster_type: { instance_type: 1, @@ -93,7 +99,7 @@ module Clusters where('NOT EXISTS (?)', subquery) end - scope :with_knative_installed, -> { joins(:application_knative).merge(Clusters::Applications::Knative.installed) } + scope :with_knative_installed, -> { joins(:application_knative).merge(Clusters::Applications::Knative.available) } scope :preload_knative, -> { preload( @@ -191,8 +197,42 @@ module Clusters project_type? end + def kube_ingress_domain + @kube_ingress_domain ||= domain.presence || instance_domain || legacy_auto_devops_domain + end + + def predefined_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless kube_ingress_domain + + variables.append(key: KUBE_INGRESS_BASE_DOMAIN, value: kube_ingress_domain) + end + end + private + def instance_domain + @instance_domain ||= Gitlab::CurrentSettings.auto_devops_domain + end + + # To keep backward compatibility with AUTO_DEVOPS_DOMAIN + # environment variable, we need to ensure KUBE_INGRESS_BASE_DOMAIN + # is set if AUTO_DEVOPS_DOMAIN is set on any of the following options: + # ProjectAutoDevops#Domain, project variables or group variables, + # as the AUTO_DEVOPS_DOMAIN is needed for CI_ENVIRONMENT_URL + # + # This method should is scheduled to be removed on + # https://gitlab.com/gitlab-org/gitlab-ce/issues/56959 + def legacy_auto_devops_domain + if project_type? + project&.auto_devops&.domain.presence || + project.variables.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence || + project.group&.variables&.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence + elsif group_type? + group.variables.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence + end + end + def restrict_modification if provider&.on_creation? errors.add(:base, "cannot modify during creation") diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb index 0e74cce29b7..1273ed83abe 100644 --- a/app/models/clusters/concerns/application_status.rb +++ b/app/models/clusters/concerns/application_status.rb @@ -6,7 +6,14 @@ module Clusters extend ActiveSupport::Concern included do - scope :installed, -> { where(status: self.state_machines[:status].states[:installed].value) } + scope :available, -> do + where( + status: [ + self.state_machines[:status].states[:installed].value, + self.state_machines[:status].states[:updated].value + ] + ) + end state_machine :status, initial: :not_installable do state :not_installable, value: -2 @@ -20,7 +27,7 @@ module Clusters state :update_errored, value: 6 event :make_scheduled do - transition [:installable, :errored] => :scheduled + transition [:installable, :errored, :installed, :updated, :update_errored] => :scheduled end event :make_installing do @@ -29,18 +36,16 @@ module Clusters event :make_installed do transition [:installing] => :installed + transition [:updating] => :updated end event :make_errored do - transition any => :errored + transition any - [:updating] => :errored + transition [:updating] => :update_errored end event :make_updating do - transition [:installed, :updated, :update_errored] => :updating - end - - event :make_updated do - transition [:updating] => :updated + transition [:installed, :updated, :update_errored, :scheduled] => :updating end event :make_update_errored do @@ -74,9 +79,17 @@ module Clusters end end + def updateable? + installed? || updated? || update_errored? + end + def available? installed? || updated? end + + def update_in_progress? + updating? + end end end end diff --git a/app/models/clusters/concerns/application_version.rb b/app/models/clusters/concerns/application_version.rb index ccad74dc35a..db94e8e08c9 100644 --- a/app/models/clusters/concerns/application_version.rb +++ b/app/models/clusters/concerns/application_version.rb @@ -7,11 +7,15 @@ module Clusters included do state_machine :status do - after_transition any => [:installing] do |application| - application.update(version: application.class.const_get(:VERSION)) + before_transition any => [:installed, :updated] do |application| + application.version = application.class.const_get(:VERSION) end end end + + def update_available? + version != self.class.const_get(:VERSION) + end end end end diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 6cec497b4e4..814fc591408 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -41,8 +41,9 @@ module Clusters validate :no_namespace, unless: :allow_user_defined_namespace? # We expect to be `active?` only when enabled and cluster is created (the api_url is assigned) - validates :api_url, url: true, presence: true + validates :api_url, public_url: true, presence: true validates :token, presence: true + validates :ca_cert, certificate: true, allow_blank: true, if: :ca_cert_changed? validate :prevent_modification, on: :update @@ -65,6 +66,8 @@ module Clusters abac: 2 } + default_value_for :authorization_type, :rbac + def actual_namespace if namespace.present? namespace @@ -96,6 +99,8 @@ module Clusters .append(key: 'KUBE_NAMESPACE', value: actual_namespace) .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true) end + + variables.concat(cluster.predefined_variables) end end @@ -106,7 +111,7 @@ module Clusters def terminals(environment) with_reactive_cache do |data| pods = filter_by_label(data[:pods], app: environment.slug) - terminals = pods.flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) } + terminals = pods.flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) }.compact terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) } end end @@ -152,7 +157,7 @@ module Clusters def build_kube_client! raise "Incomplete settings" unless api_url - raise "No namespace" if cluster.project_type? && actual_namespace.empty? # can probably remove this line once we remove #actual_namespace + raise "No namespace" if cluster.project_type? && actual_namespace.empty? # can probably remove this line once we remove #actual_namespace unless (username && password) || token raise "Either username/password or token is required to access API" diff --git a/app/models/commit.rb b/app/models/commit.rb index a422a0995ff..f412d252e5c 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -11,6 +11,7 @@ class Commit include Mentionable include Referable include StaticModel + include Presentable include ::Gitlab::Utils::StrongMemoize attr_mentionable :safe_message, pipeline: :single_line @@ -304,7 +305,9 @@ class Commit end def last_pipeline - @last_pipeline ||= pipelines.last + strong_memoize(:last_pipeline) do + pipelines.last + end end def status(ref = nil) @@ -376,7 +379,7 @@ class Commit end def merge_commit? - parents.size > 1 + parent_ids.size > 1 end def merged_merge_request(current_user) @@ -469,6 +472,10 @@ class Commit !!merged_merge_request(user) end + def cache_key + "commit:#{sha}" + end + private def commit_reference(from, referable_commit_id, full: false) diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb index e349f0fe971..a9a2e9c81eb 100644 --- a/app/models/commit_collection.rb +++ b/app/models/commit_collection.rb @@ -3,6 +3,7 @@ # A collection of Commit instances for a specific project and Git reference. class CommitCollection include Enumerable + include Gitlab::Utils::StrongMemoize attr_reader :project, :ref, :commits @@ -19,6 +20,18 @@ class CommitCollection commits.each(&block) end + def authors + emails = without_merge_commits.map(&:author_email).uniq + + User.by_any_email(emails) + end + + def without_merge_commits + strong_memoize(:without_merge_commits) do + commits.reject(&:merge_commit?) + end + end + # Sets the pipeline status for every commit. # # Setting this status ahead of time removes the need for running a query for diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb index 094747ee48d..920b1d092dd 100644 --- a/app/models/commit_range.rb +++ b/app/models/commit_range.rb @@ -134,25 +134,25 @@ class CommitRange end def sha_from - return nil unless @commit_from + return unless @commit_from @commit_from.id end def sha_to - return nil unless @commit_to + return unless @commit_to @commit_to.id end def sha_start - return nil unless sha_from + return unless sha_from exclude_start? ? sha_from + '^' : sha_from end def commit_start - return nil unless sha_start + return unless sha_start if exclude_start? @commit_start ||= project.commit(sha_start) diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 0f50bd39131..7f6562b63e5 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -41,6 +41,7 @@ class CommitStatus < ActiveRecord::Base scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) } scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) } scope :after_stage, -> (index) { where('stage_idx > ?', index) } + scope :processables, -> { where(type: %w[Ci::Build Ci::Bridge]) } # We use `CommitStatusEnums.failure_reasons` here so that EE can more easily # extend this `Hash` with new values. diff --git a/app/models/concerns/blob_language_from_git_attributes.rb b/app/models/concerns/blob_language_from_git_attributes.rb index 70213d22147..56e1276a220 100644 --- a/app/models/concerns/blob_language_from_git_attributes.rb +++ b/app/models/concerns/blob_language_from_git_attributes.rb @@ -5,7 +5,7 @@ module BlobLanguageFromGitAttributes extend ActiveSupport::Concern def language_from_gitattributes - return nil unless project + return unless project repository = project.repository repository.gitattribute(path, 'gitlab-language') diff --git a/app/models/concerns/blob_like.rb b/app/models/concerns/blob_like.rb index f20f01486a5..dc80f8d62f4 100644 --- a/app/models/concerns/blob_like.rb +++ b/app/models/concerns/blob_like.rb @@ -28,7 +28,7 @@ module BlobLike nil end - def binary? + def binary_in_repo? false end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index a8c9e54f00c..1a8570b80c3 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -13,9 +13,8 @@ module CacheMarkdownField extend ActiveSupport::Concern # Increment this number every time the renderer changes its output - CACHE_REDCARPET_VERSION = 3 CACHE_COMMONMARK_VERSION_START = 10 - CACHE_COMMONMARK_VERSION = 12 + CACHE_COMMONMARK_VERSION = 14 # changes to these attributes cause the cache to be invalidates INVALIDATED_BY = %w[author project].freeze @@ -42,18 +41,6 @@ module CacheMarkdownField end end - class MarkdownEngine - def self.from_version(version = nil) - return :common_mark if version.nil? || version == 0 - - if version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START - :redcarpet - else - :common_mark - end - end - end - def skip_project_check? false end @@ -71,7 +58,7 @@ module CacheMarkdownField # Banzai is less strict about authors, so don't always have an author key context[:author] = self.author if self.respond_to?(:author) - context[:markdown_engine] = MarkdownEngine.from_version(latest_cached_markdown_version) + context[:markdown_engine] = :common_mark context end @@ -128,12 +115,27 @@ module CacheMarkdownField end def latest_cached_markdown_version - return CacheMarkdownField::CACHE_COMMONMARK_VERSION unless cached_markdown_version + @latest_cached_markdown_version ||= (CacheMarkdownField::CACHE_COMMONMARK_VERSION << 16) | local_version + end - if cached_markdown_version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START - CacheMarkdownField::CACHE_REDCARPET_VERSION + def local_version + # because local_markdown_version is stored in application_settings which + # uses cached_markdown_version too, we check explicitly to avoid + # endless loop + return local_markdown_version if has_attribute?(:local_markdown_version) + + settings = Gitlab::CurrentSettings.current_application_settings + + # Following migrations are not properly isolated and + # use real models (by calling .ghost method), in these migrations + # local_markdown_version attribute doesn't exist yet, so we + # use a default value: + # db/migrate/20170825104051_migrate_issues_to_ghost_user.rb + # db/migrate/20171114150259_merge_requests_author_id_foreign_key.rb + if settings.respond_to?(:local_markdown_version) + settings.local_markdown_version else - CacheMarkdownField::CACHE_COMMONMARK_VERSION + 0 end end @@ -178,7 +180,9 @@ module CacheMarkdownField # author and project invalidate the cache in all circumstances. define_method(invalidation_method) do changed_fields = changed_attributes.keys - invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY] + invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY] + invalidations.delete(markdown_field.to_s) if changed_fields.include?("#{markdown_field}_html") + !invalidations.empty? || !cached_html_up_to_date?(markdown_field) end end diff --git a/app/models/concerns/cacheable_attributes.rb b/app/models/concerns/cacheable_attributes.rb index 75592bb63e2..3d60f6924c1 100644 --- a/app/models/concerns/cacheable_attributes.rb +++ b/app/models/concerns/cacheable_attributes.rb @@ -23,7 +23,12 @@ module CacheableAttributes end def build_from_defaults(attributes = {}) - new(defaults.merge(attributes)) + final_attributes = defaults + .merge(attributes) + .stringify_keys + .slice(*column_names) + + new(final_attributes) end def cached diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb new file mode 100644 index 00000000000..4986a42dbd2 --- /dev/null +++ b/app/models/concerns/ci/contextable.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module Ci + ## + # This module implements methods that provide context in form of + # essential CI/CD variables that can be used by a build / bridge job. + # + module Contextable + ## + # Variables in the environment name scope. + # + def scoped_variables(environment: expanded_environment_name) + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.concat(predefined_variables) + variables.concat(project.predefined_variables) + variables.concat(pipeline.predefined_variables) + variables.concat(runner.predefined_variables) if runnable? && runner + variables.concat(project.deployment_variables(environment: environment)) if environment + variables.concat(yaml_variables) + variables.concat(user_variables) + variables.concat(secret_group_variables) + variables.concat(secret_project_variables(environment: environment)) + variables.concat(trigger_request.user_variables) if trigger_request + variables.concat(pipeline.variables) + variables.concat(pipeline.pipeline_schedule.job_variables) if pipeline.pipeline_schedule + end + end + + ## + # Regular Ruby hash of scoped variables, without duplicates that are + # possible to be present in an array of hashes returned from `variables`. + # + def scoped_variables_hash + scoped_variables.to_hash + end + + ## + # Variables that do not depend on the environment name. + # + def simple_variables + strong_memoize(:simple_variables) do + scoped_variables(environment: nil).to_runner_variables + end + end + + def user_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables if user.blank? + + variables.append(key: 'GITLAB_USER_ID', value: user.id.to_s) + variables.append(key: 'GITLAB_USER_EMAIL', value: user.email) + variables.append(key: 'GITLAB_USER_LOGIN', value: user.username) + variables.append(key: 'GITLAB_USER_NAME', value: user.name) + end + end + + def predefined_variables # rubocop:disable Metrics/AbcSize + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI', value: 'true') + variables.append(key: 'GITLAB_CI', value: 'true') + variables.append(key: 'GITLAB_FEATURES', value: project.licensed_features.join(',')) + variables.append(key: 'CI_SERVER_NAME', value: 'GitLab') + variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) + variables.append(key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s) + variables.append(key: 'CI_SERVER_VERSION_MINOR', value: Gitlab.version_info.minor.to_s) + variables.append(key: 'CI_SERVER_VERSION_PATCH', value: Gitlab.version_info.patch.to_s) + variables.append(key: 'CI_SERVER_REVISION', value: Gitlab.revision) + variables.append(key: 'CI_JOB_NAME', value: name) + variables.append(key: 'CI_JOB_STAGE', value: stage) + 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: ref) + variables.append(key: 'CI_COMMIT_REF_SLUG', value: ref_slug) + variables.append(key: "CI_COMMIT_TAG", value: ref) if tag? + variables.append(key: "CI_PIPELINE_TRIGGERED", value: 'true') if trigger_request + variables.append(key: "CI_JOB_MANUAL", value: 'true') if action? + variables.append(key: "CI_NODE_INDEX", value: self.options[:instance].to_s) if self.options&.include?(:instance) + variables.append(key: "CI_NODE_TOTAL", value: (self.options&.dig(:parallel) || 1).to_s) + variables.concat(legacy_variables) + end + end + + def legacy_variables + Gitlab::Ci::Variables::Collection.new.tap do |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: ref) + variables.append(key: 'CI_BUILD_REF_SLUG', value: ref_slug) + variables.append(key: 'CI_BUILD_NAME', value: name) + variables.append(key: 'CI_BUILD_STAGE', value: stage) + variables.append(key: "CI_BUILD_TAG", value: ref) if tag? + variables.append(key: "CI_BUILD_TRIGGERED", value: 'true') if trigger_request + variables.append(key: "CI_BUILD_MANUAL", value: 'true') if action? + end + end + + def secret_group_variables + return [] unless project.group + + project.group.ci_variables_for(git_ref, project) + end + + def secret_project_variables(environment: persisted_environment) + project.ci_variables_for(ref: git_ref, environment: environment) + end + end +end diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb new file mode 100644 index 00000000000..9eed9492b37 --- /dev/null +++ b/app/models/concerns/ci/metadatable.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Ci + ## + # This module implements methods that need to read and write + # metadata for CI/CD entities. + # + module Metadatable + extend ActiveSupport::Concern + + included do + has_one :metadata, class_name: 'Ci::BuildMetadata', + foreign_key: :build_id, + inverse_of: :build, + autosave: true + + delegate :timeout, to: :metadata, prefix: true, allow_nil: true + before_create :ensure_metadata + end + + def ensure_metadata + metadata || build_metadata(project: project) + end + + def degenerated? + self.options.blank? + end + + def degenerate! + self.class.transaction do + self.update!(options: nil, yaml_variables: nil) + self.metadata&.destroy + end + end + + def options + read_metadata_attribute(:options, :config_options, {}) + end + + def yaml_variables + read_metadata_attribute(:yaml_variables, :config_variables, []) + end + + def options=(value) + write_metadata_attribute(:options, :config_options, value) + end + + def yaml_variables=(value) + write_metadata_attribute(:yaml_variables, :config_variables, value) + end + + private + + def read_metadata_attribute(legacy_key, metadata_key, default_value = nil) + read_attribute(legacy_key) || metadata&.read_attribute(metadata_key) || default_value + end + + def write_metadata_attribute(legacy_key, metadata_key, value) + # save to metadata or this model depending on the state of feature flag + if Feature.enabled?(:ci_build_metadata_config) + ensure_metadata.write_attribute(metadata_key, value) + write_attribute(legacy_key, nil) + else + write_attribute(legacy_key, value) + metadata&.write_attribute(metadata_key, nil) + end + end + end +end diff --git a/app/models/concerns/ci/processable.rb b/app/models/concerns/ci/processable.rb new file mode 100644 index 00000000000..268fa8ec692 --- /dev/null +++ b/app/models/concerns/ci/processable.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Ci + ## + # This module implements methods that need to be implemented by CI/CD + # entities that are supposed to go through pipeline processing + # services. + # + # + module Processable + def schedulable? + raise NotImplementedError + end + + def action? + raise NotImplementedError + end + + def when + read_attribute(:when) || 'on_success' + end + + def expanded_environment_name + raise NotImplementedError + end + + def scoped_variables_hash + raise NotImplementedError + end + end +end diff --git a/app/models/concerns/closed_at_filterable.rb b/app/models/concerns/closed_at_filterable.rb new file mode 100644 index 00000000000..239c2e47611 --- /dev/null +++ b/app/models/concerns/closed_at_filterable.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module ClosedAtFilterable + extend ActiveSupport::Concern + + included do + scope :closed_before, ->(date) { where(scoped_table[:closed_at].lteq(date)) } + scope :closed_after, ->(date) { where(scoped_table[:closed_at].gteq(date)) } + + def self.scoped_table + arel_table.alias(table_name) + end + end +end diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb index 86b61248534..e4e5928f5cf 100644 --- a/app/models/concerns/discussion_on_diff.rb +++ b/app/models/concerns/discussion_on_diff.rb @@ -9,7 +9,7 @@ module DiscussionOnDiff included do delegate :line_code, :original_line_code, - :diff_file, + :note_diff_file, :diff_line, :active?, :created_at_diff?, @@ -60,6 +60,13 @@ module DiscussionOnDiff prev_lines end + def diff_file + strong_memoize(:diff_file) do + # Falling back here is important as `note_diff_files` are created async. + fetch_preloaded_diff_file || first_note.diff_file + end + end + def line_code_in_diffs(diff_refs) if active?(diff_refs) line_code @@ -67,4 +74,15 @@ module DiscussionOnDiff original_line_code end end + + private + + def fetch_preloaded_diff_file + fetch_preloaded_diff = + context_noteable && + context_noteable.preloads_discussion_diff_highlighting? && + note_diff_file + + context_noteable.discussions_diffs.find_by_id(note_diff_file.id) if fetch_preloaded_diff + end end diff --git a/app/models/concerns/fast_destroy_all.rb b/app/models/concerns/fast_destroy_all.rb index 1e3afd641ed..f862031bce0 100644 --- a/app/models/concerns/fast_destroy_all.rb +++ b/app/models/concerns/fast_destroy_all.rb @@ -11,7 +11,7 @@ # it is difficult to accomplish it. # # This module defines a format to use `delete_all` and delete associated external data. -# Here is an exmaple +# Here is an example # # Situation # - `Project` has many `Ci::BuildTraceChunk` through `Ci::Build` diff --git a/app/models/concerns/feature_gate.rb b/app/models/concerns/feature_gate.rb index 3f84de54ad5..bb095f113e2 100644 --- a/app/models/concerns/feature_gate.rb +++ b/app/models/concerns/feature_gate.rb @@ -2,7 +2,7 @@ module FeatureGate def flipper_id - return nil if new_record? + return if new_record? "#{self.class.name}:#{id}" end diff --git a/app/models/concerns/has_ref.rb b/app/models/concerns/has_ref.rb new file mode 100644 index 00000000000..413cd36dcaa --- /dev/null +++ b/app/models/concerns/has_ref.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module HasRef + extend ActiveSupport::Concern + + def branch? + !tag? && !merge_request_event? + end + + def git_ref + if branch? + Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s + elsif tag? + Gitlab::Git::TAG_REF_PREFIX + ref.to_s + end + end + + # A slugified version of the build ref, suitable for inclusion in URLs and + # domain names. Rules: + # + # * Lowercased + # * Anything not matching [a-z0-9-] is replaced with a - + # * Maximum length is 63 bytes + # * First/Last Character is not a hyphen + def ref_slug + Gitlab::Utils.slugify(ref.to_s) + end +end diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb index b92643f87f8..0d2be4c61ab 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -85,11 +85,11 @@ module HasStatus scope :running, -> { where(status: 'running') } scope :pending, -> { where(status: 'pending') } scope :success, -> { where(status: 'success') } - scope :failed, -> { where(status: 'failed') } - scope :canceled, -> { where(status: 'canceled') } - scope :skipped, -> { where(status: 'skipped') } - scope :manual, -> { where(status: 'manual') } - scope :scheduled, -> { where(status: 'scheduled') } + scope :failed, -> { where(status: 'failed') } + scope :canceled, -> { where(status: 'canceled') } + scope :skipped, -> { where(status: 'skipped') } + scope :manual, -> { where(status: 'manual') } + scope :scheduled, -> { where(status: 'scheduled') } scope :alive, -> { where(status: [:created, :pending, :running]) } scope :created_or_pending, -> { where(status: [:created, :pending]) } scope :running_or_pending, -> { where(status: [:running, :pending]) } diff --git a/app/models/concerns/has_variable.rb b/app/models/concerns/has_variable.rb index dfbe413a878..2ec42a1029b 100644 --- a/app/models/concerns/has_variable.rb +++ b/app/models/concerns/has_variable.rb @@ -21,9 +21,9 @@ module HasVariable def key=(new_key) super(new_key.to_s.strip) end + end - def to_runner_variable - { key: key, value: value, public: false } - end + def to_runner_variable + { key: key, value: value, public: false } end end diff --git a/app/models/concerns/iid_routes.rb b/app/models/concerns/iid_routes.rb index b7f99e845ca..3eeb29b6595 100644 --- a/app/models/concerns/iid_routes.rb +++ b/app/models/concerns/iid_routes.rb @@ -4,7 +4,7 @@ module IidRoutes ## # This automagically enforces all related routes to use `iid` instead of `id` # If you want to use `iid` for some routes and `id` for other routes, this module should not to be included, - # instead you should define `iid` or `id` explictly at each route generators. e.g. pipeline_path(project.id, pipeline.iid) + # instead you should define `iid` or `id` explicitly at each route generators. e.g. pipeline_path(project.id, pipeline.iid) def to_param iid.to_s end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 0d363ec68b7..c7ad182ab82 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -23,11 +23,12 @@ module Issuable include Sortable include CreatedAtFilterable include UpdatedAtFilterable + include ClosedAtFilterable # This object is used to gather issuable meta data for displaying # upvotes, downvotes, notes and closing merge requests count for issues and merge requests # lists avoiding n+1 queries and improving performance. - IssuableMeta = Struct.new(:upvotes, :downvotes, :notes_count, :merge_requests_count) + IssuableMeta = Struct.new(:upvotes, :downvotes, :user_notes_count, :merge_requests_count) included do cache_markdown_field :title, pipeline: :single_line @@ -35,8 +36,8 @@ module Issuable redact_field :description - belongs_to :author, class_name: "User" - belongs_to :updated_by, class_name: "User" + belongs_to :author, class_name: 'User' + belongs_to :updated_by, class_name: 'User' belongs_to :last_edited_by, class_name: 'User' belongs_to :milestone @@ -74,6 +75,7 @@ module Issuable validates :author, presence: true validates :title, presence: true, length: { maximum: 255 } + validate :milestone_is_valid scope :authored, ->(user) { where(author_id: user) } scope :recent, -> { reorder(id: :desc) } @@ -117,6 +119,16 @@ module Issuable def has_multiple_assignees? assignees.count > 1 end + + def milestone_available? + project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group) + end + + private + + def milestone_is_valid + errors.add(:milestone_id, message: "is invalid") if milestone_id.present? && !milestone_available? + end end class_methods do @@ -136,10 +148,18 @@ module Issuable # This method uses ILIKE on PostgreSQL and LIKE on MySQL. # # query - The search query as a String + # matched_columns - Modify the scope of the query. 'title', 'description' or joining them with a comma. # # Returns an ActiveRecord::Relation. - def full_search(query) - fuzzy_search(query, [:title, :description]) + def full_search(query, matched_columns: 'title,description') + allowed_columns = [:title, :description] + matched_columns = matched_columns.to_s.split(',').map(&:to_sym) + matched_columns &= allowed_columns + + # Matching title or description if the matched_columns did not contain any allowed columns. + matched_columns = [:title, :description] if matched_columns.empty? + + fuzzy_search(query, matched_columns) end def sort_by_attribute(method, excluded_labels: []) @@ -270,26 +290,29 @@ module Issuable def to_hook_data(user, old_associations: {}) changes = previous_changes - old_labels = old_associations.fetch(:labels, []) - old_assignees = old_associations.fetch(:assignees, []) - if old_labels != labels - changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)] - end + if old_associations + old_labels = old_associations.fetch(:labels, []) + old_assignees = old_associations.fetch(:assignees, []) - if old_assignees != assignees - if self.is_a?(Issue) - changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)] - else - changes[:assignee] = [old_assignees&.first&.hook_attrs, assignee&.hook_attrs] + if old_labels != labels + changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)] end - end - if self.respond_to?(:total_time_spent) - old_total_time_spent = old_associations.fetch(:total_time_spent, nil) + if old_assignees != assignees + if self.is_a?(Issue) + changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)] + else + changes[:assignee] = [old_assignees&.first&.hook_attrs, assignee&.hook_attrs] + end + end + + if self.respond_to?(:total_time_spent) + old_total_time_spent = old_associations.fetch(:total_time_spent, nil) - if old_total_time_spent != total_time_spent - changes[:total_time_spent] = [old_total_time_spent, total_time_spent] + if old_total_time_spent != total_time_spent + changes[:total_time_spent] = [old_total_time_spent, total_time_spent] + end end end diff --git a/app/models/concerns/manual_inverse_association.rb b/app/models/concerns/manual_inverse_association.rb index e18edd33ba7..ff61412767e 100644 --- a/app/models/concerns/manual_inverse_association.rb +++ b/app/models/concerns/manual_inverse_association.rb @@ -5,8 +5,8 @@ module ManualInverseAssociation class_methods do def manual_inverse_association(association, inverse) - define_method(association) do |*args| - super(*args).tap do |value| + define_method(association) do + super().tap do |value| next unless value child_association = value.association(inverse) diff --git a/app/models/concerns/maskable.rb b/app/models/concerns/maskable.rb new file mode 100644 index 00000000000..8793f0ec965 --- /dev/null +++ b/app/models/concerns/maskable.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Maskable + extend ActiveSupport::Concern + + # * Single line + # * No escape characters + # * No variables + # * No spaces + # * Minimal length of 8 characters + # * Absolutely no fun is allowed + REGEX = /\A\w{8,}\z/ + + included do + validates :masked, inclusion: { in: [true, false] } + validates :value, format: { with: REGEX }, if: :masked? + end + + def to_runner_variable + super.merge(masked: masked?) + end +end diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index e44a069b730..e65bbb8ca07 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -42,16 +42,35 @@ module Milestoneish def issues_visible_to_user(user) memoize_per_user(user, :issues_visible_to_user) do IssuesFinder.new(user, issues_finder_params) - .execute.preload(:assignees).where(milestone_id: milestoneish_ids) + .execute.preload(:assignees).where(milestone_id: milestoneish_id) end end + def issue_participants_visible_by_user(user) + User.joins(:issue_assignees) + .where('issue_assignees.issue_id' => issues_visible_to_user(user).select(:id)) + .distinct + end + + def issue_labels_visible_by_user(user) + Label.joins(:label_links) + .where('label_links.target_id' => issues_visible_to_user(user).select(:id), 'label_links.target_type' => 'Issue') + .distinct + end + def sorted_issues(user) issues_visible_to_user(user).preload_associations.sort_by_attribute('label_priority') end - def sorted_merge_requests - merge_requests.sort_by_attribute('label_priority') + def sorted_merge_requests(user) + merge_requests_visible_to_user(user).sort_by_attribute('label_priority') + end + + def merge_requests_visible_to_user(user) + memoize_per_user(user, :merge_requests_visible_to_user) do + MergeRequestsFinder.new(user, issues_finder_params) + .execute.where(milestone_id: milestoneish_id) + end end def upcoming? diff --git a/app/models/concerns/mirror_authentication.rb b/app/models/concerns/mirror_authentication.rb index e3e1a0441f8..948094221e5 100644 --- a/app/models/concerns/mirror_authentication.rb +++ b/app/models/concerns/mirror_authentication.rb @@ -79,7 +79,7 @@ module MirrorAuthentication end def ssh_public_key - return nil if ssh_private_key.blank? + return if ssh_private_key.blank? comment = "git@#{::Gitlab.config.gitlab.host}" ::SSHKey.new(ssh_private_key, comment: comment).ssh_public_key diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index f2cad09e779..3c74034b527 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -1,9 +1,18 @@ # frozen_string_literal: true module Noteable - # Names of all implementers of `Noteable` that support resolvable notes. + extend ActiveSupport::Concern + + # `Noteable` class names that support resolvable notes. RESOLVABLE_TYPES = %w(MergeRequest).freeze + class_methods do + # `Noteable` class names that support replying to individual notes. + def replyable_types + %w(Issue MergeRequest) + end + end + def base_class_name self.class.base_class.name end @@ -26,6 +35,10 @@ module Noteable DiscussionNote.noteable_types.include?(base_class_name) end + def supports_replying_to_individual_notes? + supports_discussions? && self.class.replyable_types.include?(base_class_name) + end + def supports_suggestion? false end @@ -34,6 +47,10 @@ module Noteable false end + def preloads_discussion_diff_highlighting? + false + end + def discussion_notes notes end diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index d3572875fb3..d2ead7130e5 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -69,14 +69,14 @@ module ReactiveCaching def with_reactive_cache(*args, &blk) unless within_reactive_cache_lifetime?(*args) refresh_reactive_cache!(*args) - return nil + return end keep_alive_reactive_cache!(*args) begin data = Rails.cache.read(full_reactive_cache_key(*args)) - yield data if data.present? + yield data unless data.nil? rescue InvalidateReactiveCache refresh_reactive_cache!(*args) nil diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb index e51b4e22c96..a479bef993c 100644 --- a/app/models/concerns/sha_attribute.rb +++ b/app/models/concerns/sha_attribute.rb @@ -16,6 +16,8 @@ module ShaAttribute # the column is the correct type. In production it should behave like any other attribute. # See https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/5502 for more discussion def validate_binary_column_exists!(name) + return unless database_exists? + unless table_exists? warn "WARNING: sha_attribute #{name.inspect} is invalid since the table doesn't exist - you may need to run database migrations" return @@ -35,5 +37,13 @@ module ShaAttribute Gitlab::AppLogger.error "ShaAttribute initialization: #{error.message}" raise end + + def database_exists? + ActiveRecord::Base.connection + + true + rescue + false + end end end diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb index 603d4d62578..f147ce8ad6b 100644 --- a/app/models/concerns/taskable.rb +++ b/app/models/concerns/taskable.rb @@ -9,9 +9,11 @@ require 'task_list/filter' # # Used by MergeRequest and Issue module Taskable - COMPLETED = 'completed'.freeze - INCOMPLETE = 'incomplete'.freeze - ITEM_PATTERN = %r{ + COMPLETED = 'completed'.freeze + INCOMPLETE = 'incomplete'.freeze + COMPLETE_PATTERN = /(\[[xX]\])/.freeze + INCOMPLETE_PATTERN = /(\[[\s]\])/.freeze + ITEM_PATTERN = %r{ ^ \s*(?:[-+*]|(?:\d+\.)) # list prefix required - task item has to be always in a list \s+ # whitespace prefix has to be always presented for a list item diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb index 01fb194281a..df14e6e4754 100644 --- a/app/models/concerns/token_authenticatable_strategies/base.rb +++ b/app/models/concerns/token_authenticatable_strategies/base.rb @@ -39,22 +39,6 @@ module TokenAuthenticatableStrategies instance.save! if Gitlab::Database.read_write? end - def fallback? - unless options[:fallback].in?([true, false, nil]) - raise ArgumentError, 'fallback: needs to be a boolean value!' - end - - options[:fallback] == true - end - - def migrating? - unless options[:migrating].in?([true, false, nil]) - raise ArgumentError, 'migrating: needs to be a boolean value!' - end - - options[:migrating] == true - end - def self.fabricate(model, field, options) if options[:digest] && options[:encrypted] raise ArgumentError, 'Incompatible options set!' diff --git a/app/models/concerns/token_authenticatable_strategies/encrypted.rb b/app/models/concerns/token_authenticatable_strategies/encrypted.rb index 152491aa6e9..2c7fa2c5b3c 100644 --- a/app/models/concerns/token_authenticatable_strategies/encrypted.rb +++ b/app/models/concerns/token_authenticatable_strategies/encrypted.rb @@ -2,28 +2,18 @@ module TokenAuthenticatableStrategies class Encrypted < Base - def initialize(*) - super - - if migrating? && fallback? - raise ArgumentError, '`fallback` and `migrating` options are not compatible!' - end - end - def find_token_authenticatable(token, unscoped = false) return if token.blank? - if fully_encrypted? - return find_by_encrypted_token(token, unscoped) - end - - if fallback? + if required? + find_by_encrypted_token(token, unscoped) + elsif optional? find_by_encrypted_token(token, unscoped) || find_by_plaintext_token(token, unscoped) elsif migrating? find_by_plaintext_token(token, unscoped) else - raise ArgumentError, 'Unknown encryption phase!' + raise ArgumentError, "Unknown encryption strategy: #{encrypted_strategy}!" end end @@ -41,8 +31,8 @@ module TokenAuthenticatableStrategies return super if instance.has_attribute?(encrypted_field) - if fully_encrypted? - raise ArgumentError, 'Using encrypted strategy when encrypted field is missing!' + if required? + raise ArgumentError, 'Using required encryption strategy when encrypted field is missing!' else insecure_strategy.ensure_token(instance) end @@ -53,8 +43,7 @@ module TokenAuthenticatableStrategies encrypted_token = instance.read_attribute(encrypted_field) token = Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token) - - token || (insecure_strategy.get_token(instance) if fallback?) + token || (insecure_strategy.get_token(instance) if optional?) end def set_token(instance, token) @@ -62,16 +51,35 @@ module TokenAuthenticatableStrategies instance[encrypted_field] = Gitlab::CryptoHelper.aes256_gcm_encrypt(token) instance[token_field] = token if migrating? - instance[token_field] = nil if fallback? + instance[token_field] = nil if optional? token end - def fully_encrypted? - !migrating? && !fallback? + def required? + encrypted_strategy == :required + end + + def migrating? + encrypted_strategy == :migrating + end + + def optional? + encrypted_strategy == :optional end protected + def encrypted_strategy + value = options[:encrypted] + value = value.call if value.is_a?(Proc) + + unless value.in?([:required, :optional, :migrating]) + raise ArgumentError, 'encrypted: needs to be a :required, :optional or :migrating!' + end + + value + end + def find_by_plaintext_token(token, unscoped) insecure_strategy.find_token_authenticatable(token, unscoped) end @@ -89,7 +97,7 @@ module TokenAuthenticatableStrategies def token_set?(instance) raw_token = instance.read_attribute(encrypted_field) - unless fully_encrypted? + unless required? raw_token ||= insecure_strategy.get_token(instance) end diff --git a/app/models/concerns/with_uploads.rb b/app/models/concerns/with_uploads.rb index d79c0eae77e..6c6febd186c 100644 --- a/app/models/concerns/with_uploads.rb +++ b/app/models/concerns/with_uploads.rb @@ -27,40 +27,14 @@ module WithUploads included do has_many :uploads, as: :model - has_many :file_uploads, -> { where(uploader: FILE_UPLOADERS) }, class_name: 'Upload', as: :model + has_many :file_uploads, -> { where(uploader: FILE_UPLOADERS) }, + class_name: 'Upload', as: :model, + dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent - # TODO: when feature flag is removed, we can use just dependent: destroy - # option on :file_uploads - before_destroy :remove_file_uploads - - use_fast_destroy :file_uploads, if: :fast_destroy_enabled? + use_fast_destroy :file_uploads end def retrieve_upload(_identifier, paths) uploads.find_by(path: paths) end - - private - - # mounted uploads are deleted in carrierwave's after_commit hook, - # but FileUploaders which are not mounted must be deleted explicitly and - # it can not be done in after_commit because FileUploader requires loads - # associated model on destroy (which is already deleted in after_commit) - def remove_file_uploads - fast_destroy_enabled? ? delete_uploads : destroy_uploads - end - - def delete_uploads - file_uploads.delete_all(:delete_all) - end - - def destroy_uploads - file_uploads.find_each do |upload| - upload.destroy - end - end - - def fast_destroy_enabled? - Feature.enabled?(:fast_destroy_uploads, self) - end end diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 2c08a8e1acf..cf057d774cf 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ContainerRepository < ActiveRecord::Base + include Gitlab::Utils::StrongMemoize + belongs_to :project validates :name, length: { minimum: 0, allow_nil: false } @@ -8,6 +10,8 @@ class ContainerRepository < ActiveRecord::Base delegate :client, to: :registry + scope :ordered, -> { order(:name) } + # rubocop: disable CodeReuse/ServiceClass def registry @registry ||= begin @@ -39,11 +43,12 @@ class ContainerRepository < ActiveRecord::Base end def tags - return @tags if defined?(@tags) return [] unless manifest && manifest['tags'] - @tags = manifest['tags'].map do |tag| - ContainerRegistry::Tag.new(self, tag) + strong_memoize(:tags) do + manifest['tags'].sort.map do |tag| + ContainerRegistry::Tag.new(self, tag) + end end end diff --git a/app/models/dashboard_group_milestone.rb b/app/models/dashboard_group_milestone.rb index ad0bb55f0a7..74aa04ab7d0 100644 --- a/app/models/dashboard_group_milestone.rb +++ b/app/models/dashboard_group_milestone.rb @@ -6,34 +6,17 @@ class DashboardGroupMilestone < GlobalMilestone attr_reader :group_name def initialize(milestone) - super(milestone.title, Array(milestone)) + super @group_name = milestone.group.full_name end - def self.build_collection(groups) - Milestone.of_groups(groups.select(:id)) + def self.build_collection(groups, params) + milestones = Milestone.of_groups(groups.select(:id)) .reorder_by_due_date_asc .order_by_name_asc .active - .map { |m| new(m) } - end - - override :group_milestone? - def group_milestone? - @first_milestone.group_milestone? - end - - override :milestoneish_ids - def milestoneish_ids - milestones.map(&:id) - end - - def group - @first_milestone.group - end - - def iid - @first_milestone.iid + milestones = milestones.search_title(params[:search_title]) if params[:search_title].present? + milestones.map { |m| new(m) } end end diff --git a/app/models/dashboard_milestone.rb b/app/models/dashboard_milestone.rb index 96bc8090b81..9b377b70e5b 100644 --- a/app/models/dashboard_milestone.rb +++ b/app/models/dashboard_milestone.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true class DashboardMilestone < GlobalMilestone - def issues_finder_params - { authorized_only: true } + attr_reader :project_name + + def initialize(milestone) + super + + @project_name = milestone.project.full_name end - def dashboard_milestone? + def project_milestone? true end end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 279603496b0..805092e527a 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -41,6 +41,14 @@ class DiffNote < Note create_note_diff_file(creation_params) end + # Returns the diff file from `position` + def latest_diff_file + strong_memoize(:latest_diff_file) do + position.diff_file(project.repository) + end + end + + # Returns the diff file from `original_position` def diff_file strong_memoize(:diff_file) do enqueue_diff_file_creation_job if should_create_diff_file? diff --git a/app/models/diff_viewer/base.rb b/app/models/diff_viewer/base.rb index 1176861a827..527ee33b83b 100644 --- a/app/models/diff_viewer/base.rb +++ b/app/models/diff_viewer/base.rb @@ -18,7 +18,7 @@ module DiffViewer def initialize(diff_file) @diff_file = diff_file - @initially_binary = diff_file.binary? + @initially_binary = diff_file.binary_in_repo? end def self.partial_path @@ -48,7 +48,7 @@ module DiffViewer def self.can_render_blob?(blob, verify_binary: true) return true if blob.nil? - return false if verify_binary && binary? != blob.binary? + return false if verify_binary && binary? != blob.binary_in_repo? return true if extensions&.include?(blob.extension) return true if file_types&.include?(blob.file_type) @@ -70,20 +70,49 @@ module DiffViewer end def binary_detected_after_load? - !@initially_binary && diff_file.binary? + !@initially_binary && diff_file.binary_in_repo? end # This method is used on the server side to check whether we can attempt to - # render the diff_file at all. Human-readable error messages are found in the - # `BlobHelper#diff_render_error_reason` helper. + # render the diff_file at all. The human-readable error message can be + # retrieved by #render_error_message. def render_error if too_large? :too_large end end + def render_error_message + return unless render_error + + _("This %{viewer} could not be displayed because %{reason}. You can %{options} instead.") % + { + viewer: switcher_title, + reason: render_error_reason, + options: render_error_options.to_sentence(two_words_connector: _(' or '), last_word_connector: _(', or ')) + } + end + def prepare! # To be overridden by subclasses end + + private + + def render_error_options + options = [] + + blob_url = Gitlab::Routing.url_helpers.project_blob_path(diff_file.repository.project, + File.join(diff_file.content_sha, diff_file.file_path)) + options << ActionController::Base.helpers.link_to(_('view the blob'), blob_url) + + options + end + + def render_error_reason + if render_error == :too_large + _("it is too large") + end + end end end diff --git a/app/models/diff_viewer/image.rb b/app/models/diff_viewer/image.rb index c356c2ca50e..350bef1d42a 100644 --- a/app/models/diff_viewer/image.rb +++ b/app/models/diff_viewer/image.rb @@ -9,6 +9,6 @@ module DiffViewer self.extensions = UploaderHelper::IMAGE_EXT self.binary = true self.switcher_icon = 'picture-o' - self.switcher_title = 'image diff' + self.switcher_title = _('image diff') end end diff --git a/app/models/diff_viewer/rich.rb b/app/models/diff_viewer/rich.rb index 2faa1be6567..5caefa2031c 100644 --- a/app/models/diff_viewer/rich.rb +++ b/app/models/diff_viewer/rich.rb @@ -7,7 +7,7 @@ module DiffViewer included do self.type = :rich self.switcher_icon = 'file-text-o' - self.switcher_title = 'rendered diff' + self.switcher_title = _('rendered diff') end end end diff --git a/app/models/diff_viewer/server_side.rb b/app/models/diff_viewer/server_side.rb index 977204e6c97..0877c9dddec 100644 --- a/app/models/diff_viewer/server_side.rb +++ b/app/models/diff_viewer/server_side.rb @@ -24,5 +24,17 @@ module DiffViewer super end + + private + + def render_error_reason + return super unless render_error == :server_side_but_stored_externally + + if diff_file.external_storage == :lfs + _('it is stored in LFS') + else + _('it is stored externally') + end + end end end diff --git a/app/models/diff_viewer/simple.rb b/app/models/diff_viewer/simple.rb index 8d28ca5239a..929d8ad5a7e 100644 --- a/app/models/diff_viewer/simple.rb +++ b/app/models/diff_viewer/simple.rb @@ -7,7 +7,7 @@ module DiffViewer included do self.type = :simple self.switcher_icon = 'code' - self.switcher_title = 'source diff' + self.switcher_title = _('source diff') end end end diff --git a/app/models/discussion.rb b/app/models/discussion.rb index dbc7b6e67be..32529ebf71d 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -116,6 +116,10 @@ class Discussion false end + def can_convert_to_discussion? + false + end + def new_discussion? notes.length == 1 end diff --git a/app/models/email.rb b/app/models/email.rb index b6a977dfa22..3ce6e792fa8 100644 --- a/app/models/email.rb +++ b/app/models/email.rb @@ -15,7 +15,7 @@ class Email < ActiveRecord::Base after_commit :update_invalid_gpg_signatures, if: -> { previous_changes.key?('confirmed_at') } devise :confirmable - self.reconfirmable = false # currently email can't be changed, no need to reconfirm + self.reconfirmable = false # currently email can't be changed, no need to reconfirm delegate :username, to: :user diff --git a/app/models/environment.rb b/app/models/environment.rb index cdfe3b7c023..3d909cc8e5c 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -50,6 +50,14 @@ class Environment < ActiveRecord::Base end scope :in_review_folder, -> { where(environment_type: "review") } scope :for_name, -> (name) { where(name: name) } + + ## + # Search environments which have names like the given query. + # Do not set a large limit unless you've confirmed that it works on gitlab.com scale. + scope :for_name_like, -> (query, limit: 5) do + where('name LIKE ?', "#{sanitize_sql_like(query)}%").limit(limit) + end + scope :for_project, -> (project) { where(project_id: project) } scope :with_deployment, -> (sha) { where('EXISTS (?)', Deployment.select(1).where('deployments.environment_id = environments.id').where(sha: sha)) } @@ -70,6 +78,10 @@ class Environment < ActiveRecord::Base end end + def self.pluck_names + pluck(:name) + end + def predefined_variables Gitlab::Ci::Variables::Collection.new .append(key: 'CI_ENVIRONMENT_NAME', value: name) @@ -107,7 +119,7 @@ class Environment < ActiveRecord::Base def first_deployment_for(commit_sha) ref = project.repository.ref_name_for_sha(ref_path, commit_sha) - return nil unless ref + return unless ref deployment_iid = ref.split('/').last deployments.find_by(iid: deployment_iid) @@ -118,7 +130,7 @@ class Environment < ActiveRecord::Base end def formatted_external_url - return nil unless external_url + return unless external_url external_url.gsub(%r{\A.*?://}, '') end @@ -231,6 +243,10 @@ class Environment < ActiveRecord::Base self.environment_type || self.name end + def name_without_type + @name_without_type ||= name.delete_prefix("#{environment_type}/") + end + def deployment_platform strong_memoize(:deployment_platform) do project.deployment_platform(environment: self.name) diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb new file mode 100644 index 00000000000..1e2bd3bda7f --- /dev/null +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +module ErrorTracking + class ProjectErrorTrackingSetting < ActiveRecord::Base + include Gitlab::Utils::StrongMemoize + include ReactiveCaching + + API_URL_PATH_REGEXP = %r{ + \A + (?<prefix>/api/0/projects/+) + (?: + (?<organization>[^/]+)/+ + (?<project>[^/]+)/* + )? + \z + }x + + self.reactive_cache_key = ->(setting) { [setting.class.model_name.singular, setting.project_id] } + + belongs_to :project + + validates :api_url, length: { maximum: 255 }, public_url: true, url: { enforce_sanitization: true, ascii_only: true }, allow_nil: true + + validates :api_url, presence: { message: 'is a required field' }, if: :enabled + + validate :validate_api_url_path, if: :enabled + + validates :token, presence: { message: 'is a required field' }, if: :enabled + + attr_encrypted :token, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm' + + after_save :clear_reactive_cache! + + def api_url=(value) + super + clear_memoization(:api_url_slugs) + end + + def project_name + super || project_name_from_slug + end + + def organization_name + super || organization_name_from_slug + end + + def project_slug + project_slug_from_api_url + end + + def organization_slug + organization_slug_from_api_url + end + + def self.build_api_url_from(api_host:, project_slug:, organization_slug:) + return if api_host.blank? + + uri = Addressable::URI.parse("#{api_host}/api/0/projects/#{organization_slug}/#{project_slug}/") + uri.path = uri.path.squeeze('/') + + uri.to_s + rescue Addressable::URI::InvalidURIError + api_host + end + + def sentry_client + Sentry::Client.new(api_url, token) + end + + def sentry_external_url + self.class.extract_sentry_external_url(api_url) + end + + def list_sentry_issues(opts = {}) + with_reactive_cache('list_issues', opts.stringify_keys) do |result| + result + end + end + + def list_sentry_projects + { projects: sentry_client.list_projects } + end + + def calculate_reactive_cache(request, opts) + case request + when 'list_issues' + { issues: sentry_client.list_issues(**opts.symbolize_keys) } + end + rescue Sentry::Client::Error => e + { error: e.message } + end + + # http://HOST/api/0/projects/ORG/PROJECT + # -> + # http://HOST/ORG/PROJECT + def self.extract_sentry_external_url(url) + url.sub('api/0/projects/', '') + end + + def api_host + return if api_url.blank? + + # This returns http://example.com/ + Addressable::URI.join(api_url, '/').to_s + end + + private + + def project_name_from_slug + @project_name_from_slug ||= project_slug_from_api_url&.titleize + end + + def organization_name_from_slug + @organization_name_from_slug ||= organization_slug_from_api_url&.titleize + end + + def project_slug_from_api_url + api_url_slug(:project) + end + + def organization_slug_from_api_url + api_url_slug(:organization) + end + + def api_url_slug(capture) + slugs = strong_memoize(:api_url_slugs) { extract_api_url_slugs || {} } + slugs[capture] + end + + def extract_api_url_slugs + return if api_url.blank? + + begin + url = Addressable::URI.parse(api_url) + rescue Addressable::URI::InvalidURIError + return + end + + url.path.match(API_URL_PATH_REGEXP) + end + + def validate_api_url_path + return if api_url.blank? + + unless api_url_slug(:prefix) + return errors.add(:api_url, 'is invalid') + end + + unless api_url_slug(:organization) + errors.add(:project, 'is a required field') + end + end + end +end diff --git a/app/models/external_issue.rb b/app/models/external_issue.rb index 4f73beaafc5..68b2353556e 100644 --- a/app/models/external_issue.rb +++ b/app/models/external_issue.rb @@ -3,6 +3,8 @@ class ExternalIssue include Referable + attr_reader :project + def initialize(issue_identifier, project) @issue_identifier, @project = issue_identifier, project end @@ -32,12 +34,8 @@ class ExternalIssue [self.class, to_s].hash end - def project - @project - end - def project_id - @project.id + project.id end def to_reference(_from = nil, full: nil) diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index 085ffd16c6a..fd17745b035 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -3,69 +3,79 @@ class GlobalMilestone include Milestoneish - EPOCH = DateTime.parse('1970-01-01') STATE_COUNT_HASH = { opened: 0, closed: 0, all: 0 }.freeze - attr_accessor :title, :milestones + attr_reader :milestone alias_attribute :name, :title + delegate :title, :state, :due_date, :start_date, :participants, :project, :group, :expires_at, :closed?, :iid, :group_milestone?, :safe_title, :milestoneish_id, to: :milestone + + def to_hash + { + name: title, + title: title, + group_name: group&.full_name, + project_name: project&.full_name + } + end + def for_display - @first_milestone + @milestone end def self.build_collection(projects, params) - params = - { project_ids: projects.map(&:id), state: params[:state] } - - child_milestones = MilestonesFinder.new(params).execute # rubocop: disable CodeReuse/Finder - - milestones = child_milestones.select(:id, :title).group_by(&:title).map do |title, grouped| - milestones_relation = Milestone.where(id: grouped.map(&:id)) - new(title, milestones_relation) - end + items = Milestone.of_projects(projects) + .reorder_by_due_date_asc + .order_by_name_asc + items = items.search_title(params[:search_title]) if params[:search_title].present? - milestones.sort_by { |milestone| milestone.due_date || EPOCH } + Milestone.filter_by_state(items, params[:state]).map { |m| new(m) } end + # necessary for legacy milestones def self.build(projects, title) - child_milestones = Milestone.of_projects(projects).where(title: title) - return if child_milestones.blank? + milestones = Milestone.of_projects(projects).where(title: title) + return if milestones.blank? - new(title, child_milestones) + new(milestones.first) end - def self.count_by_state(milestones_by_state_and_title, state) - milestones_by_state_and_title.count do |(milestone_state, _), _| - milestone_state == state + def self.states_count(projects, group = nil) + legacy_group_milestones_count = legacy_group_milestone_states_count(projects) + group_milestones_count = group_milestones_states_count(group) + + legacy_group_milestones_count.merge(group_milestones_count) do |k, legacy_group_milestones_count, group_milestones_count| + legacy_group_milestones_count + group_milestones_count end end - private_class_method :count_by_state - def initialize(title, milestones) - @title = title - @name = title - @milestones = milestones - @first_milestone = milestones.find {|m| m.description.present? } || milestones.first - end + def self.group_milestones_states_count(group) + return STATE_COUNT_HASH unless group - def milestoneish_ids - milestones.select(:id) - end + counts_by_state = Milestone.of_groups(group).count_by_state - def safe_title - @title.to_slug.normalize.to_s + { + opened: counts_by_state['active'] || 0, + closed: counts_by_state['closed'] || 0, + all: counts_by_state.values.sum + } end - def projects - @projects ||= Project.for_milestones(milestoneish_ids) - end + def self.legacy_group_milestone_states_count(projects) + return STATE_COUNT_HASH unless projects - def state - milestones.each do |milestone| - return 'active' if milestone.state != 'closed' - end + # We need to reorder(nil) on the projects, because the controller passes them in sorted. + relation = Milestone.of_projects(projects.reorder(nil)).count_by_state - 'closed' + { + opened: relation['active'] || 0, + closed: relation['closed'] || 0, + all: relation.values.sum + } + end + + def initialize(milestone) + @milestone = milestone end def active? @@ -77,37 +87,14 @@ class GlobalMilestone end def issues - @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignees, :labels) + @issues ||= Issue.of_milestones(milestone).includes(:project, :assignees, :labels) end def merge_requests - @merge_requests ||= MergeRequest.of_milestones(milestoneish_ids).includes(:target_project, :assignee, :labels) - end - - def participants - @participants ||= milestones.map(&:participants).flatten.uniq + @merge_requests ||= MergeRequest.of_milestones(milestone).includes(:target_project, :assignee, :labels) end def labels - @labels ||= GlobalLabel.build_collection(milestones.includes(:labels).map(&:labels).flatten) - .sort_by!(&:title) - end - - def due_date - return @due_date if defined?(@due_date) - - @due_date = - if @milestones.all? { |x| x.due_date == @milestones.first.due_date } - @milestones.first.due_date - end - end - - def start_date - return @start_date if defined?(@start_date) - - @start_date = - if @milestones.all? { |x| x.start_date == @milestones.first.start_date } - @milestones.first.start_date - end + @labels ||= GlobalLabel.build_collection(milestone.labels).sort_by!(&:title) end end diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb index 0816778deae..7f9ff7bbda6 100644 --- a/app/models/gpg_signature.rb +++ b/app/models/gpg_signature.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class GpgSignature < ActiveRecord::Base +class GpgSignature < ApplicationRecord include ShaAttribute sha_attribute :commit_sha @@ -33,6 +33,11 @@ class GpgSignature < ActiveRecord::Base ) end + def self.safe_create!(attributes) + create_with(attributes) + .safe_find_or_create_by!(commit_sha: attributes[:commit_sha]) + end + def gpg_key=(model) case model when GpgKey diff --git a/app/models/group.rb b/app/models/group.rb index edac2444c4d..495bfe04499 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -56,7 +56,7 @@ class Group < Namespace validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } - add_authentication_token_field :runners_token, encrypted: true, migrating: true + add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption) ? :optional : :required } after_create :post_create_hook after_destroy :post_destroy_hook @@ -98,7 +98,7 @@ class Group < Namespace def select_for_project_authorization if current_scope.joins_values.include?(:shared_projects) joins('INNER JOIN namespaces project_namespace ON project_namespace.id = projects.namespace_id') - .where('project_namespace.share_with_group_lock = ?', false) + .where('project_namespace.share_with_group_lock = ?', false) .select("projects.id AS project_id, LEAST(project_group_links.group_access, members.access_level) AS access_level") else super @@ -382,6 +382,10 @@ class Group < Namespace end end + def highest_group_member(user) + GroupMember.where(source_id: self_and_ancestors_ids, user_id: user.id).order(:access_level).last + end + def hashed_storage?(_feature) false end diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb index 9dfaebacc83..97cb26c6ea9 100644 --- a/app/models/group_milestone.rb +++ b/app/models/group_milestone.rb @@ -1,18 +1,36 @@ # frozen_string_literal: true # Group Milestones are milestones that can be shared among many projects within the same group class GroupMilestone < GlobalMilestone - attr_accessor :group + attr_reader :group, :milestones def self.build_collection(group, projects, params) - super(projects, params).each do |milestone| - milestone.group = group + params = + { state: params[:state], search_title: params[:search_title] } + + project_milestones = Milestone.of_projects(projects) + project_milestones = project_milestones.search_title(params[:search_title]) if params[:search_title].present? + child_milestones = Milestone.filter_by_state(project_milestones, params[:state]) + grouped_milestones = child_milestones.group_by(&:title) + + grouped_milestones.map do |title, grouped| + new(title, grouped, group) end end def self.build(group, projects, title) - super(projects, title).tap do |milestone| - milestone&.group = group - end + child_milestones = Milestone.of_projects(projects).where(title: title) + return if child_milestones.blank? + + new(title, child_milestones, group) + end + + def initialize(title, milestones, group) + @milestones = milestones + @group = group + end + + def milestone + @milestone ||= milestones.find { |m| m.description.present? } || milestones.first end def issues_finder_params diff --git a/app/models/identity.rb b/app/models/identity.rb index d63dd432426..acdde4f296b 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -8,7 +8,7 @@ class Identity < ActiveRecord::Base validates :provider, presence: true validates :extern_uid, allow_blank: true, uniqueness: { scope: UniquenessScopes.scopes, case_sensitive: false } - validates :user_id, uniqueness: { scope: UniquenessScopes.scopes } + validates :user, uniqueness: { scope: UniquenessScopes.scopes } before_save :ensure_normalized_extern_uid, if: :extern_uid_changed? after_destroy :clear_user_synced_attributes, if: :user_synced_attributes_metadata_from_provider? diff --git a/app/models/individual_note_discussion.rb b/app/models/individual_note_discussion.rb index 07ee7470ea2..b4a661ae5b4 100644 --- a/app/models/individual_note_discussion.rb +++ b/app/models/individual_note_discussion.rb @@ -13,6 +13,18 @@ class IndividualNoteDiscussion < Discussion true end + def can_convert_to_discussion? + noteable.supports_replying_to_individual_notes? && Feature.enabled?(:reply_to_individual_notes) + end + + def convert_to_discussion!(save: false) + first_note.becomes!(Discussion.note_class).to_discussion.tap do + # Save needs to be called on first_note instead of the transformed note + # because of https://gitlab.com/gitlab-org/gitlab-ce/issues/57324 + first_note.save if save + end + end + def reply_attributes super.tap { |attrs| attrs.delete(:discussion_id) } end diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb index e7168d49db9..e75c6eb2331 100644 --- a/app/models/internal_id.rb +++ b/app/models/internal_id.rb @@ -66,6 +66,17 @@ class InternalId < ActiveRecord::Base InternalIdGenerator.new(subject, scope, usage, init).generate end + # Flushing records is generally safe in a sense that those + # records are going to be re-created when needed. + # + # A filter condition has to be provided to not accidentally flush + # records for all projects. + def flush_records!(filter) + raise ArgumentError, "filter cannot be empty" if filter.blank? + + where(filter).delete_all + end + def available? @available_flag ||= ActiveRecord::Migrator.current_version >= REQUIRED_SCHEMA_VERSION # rubocop:disable Gitlab/PredicateMemoization end @@ -111,7 +122,7 @@ class InternalId < ActiveRecord::Base # Generates next internal id and returns it def generate - InternalId.transaction do + subject.transaction do # Create a record in internal_ids if one does not yet exist # and increment its last value # @@ -125,7 +136,7 @@ class InternalId < ActiveRecord::Base # # Note this will acquire a ROW SHARE lock on the InternalId record def track_greatest(new_value) - InternalId.transaction do + subject.transaction do (lookup || create_record).track_greatest_and_save!(new_value) end end @@ -148,7 +159,7 @@ class InternalId < ActiveRecord::Base # violation. We can safely roll-back the nested transaction and perform # a lookup instead to retrieve the record. def create_record - InternalId.transaction(requires_new: true) do + subject.transaction(requires_new: true) do InternalId.create!( **scope, usage: usage_value, diff --git a/app/models/issue.rb b/app/models/issue.rb index b7e13bcbccf..071ad50fddc 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -26,6 +26,8 @@ class Issue < ActiveRecord::Base DueThisMonth = DueDateStruct.new('Due This Month', 'month').freeze DueNextMonthAndPreviousTwoWeeks = DueDateStruct.new('Due Next Month And Previous Two Weeks', 'next_month_and_previous_two_weeks').freeze + SORTING_PREFERENCE_FIELD = :issues_sort + belongs_to :project belongs_to :moved_to, class_name: 'Issue' belongs_to :closed_by, class_name: 'User' @@ -64,6 +66,7 @@ class Issue < ActiveRecord::Base scope :preload_associations, -> { preload(:labels, project: :namespace) } scope :public_only, -> { where(confidential: false) } + scope :confidential_only, -> { where(confidential: true) } after_save :expire_etag_cache after_save :ensure_metrics, unless: :imported? @@ -230,7 +233,8 @@ class Issue < ActiveRecord::Base end def check_for_spam? - project.public? && (title_changed? || description_changed?) + publicly_visible? && + (title_changed? || description_changed? || confidential_changed?) end def as_json(options = {}) @@ -259,6 +263,10 @@ class Issue < ActiveRecord::Base end # rubocop: enable CodeReuse/ServiceClass + def merge_requests_count + merge_requests_closing_issues.count + end + private def ensure_metrics diff --git a/app/models/label.rb b/app/models/label.rb index 5d2d1afd1d9..1c3db3eb35d 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -214,6 +214,7 @@ class Label < ActiveRecord::Base super(options).tap do |json| json[:type] = self.try(:type) json[:priority] = priority(options[:project]) if options.key?(:project) + json[:textColor] = text_color end end diff --git a/app/models/label_note.rb b/app/models/label_note.rb index 680952cf421..d6814f4a948 100644 --- a/app/models/label_note.rb +++ b/app/models/label_note.rb @@ -81,7 +81,7 @@ class LabelNote < Note deleted = label_refs.count - existing_refs.count deleted_str = deleted == 0 ? nil : "#{deleted} deleted" - return nil unless refs_str || deleted_str + return unless refs_str || deleted_str label_list_str = [refs_str, deleted_str].compact.join(' + ') suffix = 'label'.pluralize(deleted > 0 ? deleted : existing_refs.count) diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb index 00dec6bb92b..e2c75bc7ee9 100644 --- a/app/models/legacy_diff_note.rb +++ b/app/models/legacy_diff_note.rb @@ -73,7 +73,7 @@ class LegacyDiffNote < Note private def find_diff - return nil unless noteable + return unless noteable return @diff if defined?(@diff) @diff = noteable.raw_diffs(Commit.max_diff_options).find do |d| diff --git a/app/models/lfs_download_object.rb b/app/models/lfs_download_object.rb new file mode 100644 index 00000000000..6383f95d546 --- /dev/null +++ b/app/models/lfs_download_object.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class LfsDownloadObject + include ActiveModel::Validations + + attr_accessor :oid, :size, :link + delegate :sanitized_url, :credentials, to: :sanitized_uri + + validates :oid, format: { with: /\A\h{64}\z/ } + validates :size, numericality: { greater_than_or_equal_to: 0 } + validates :link, public_url: { protocols: %w(http https) } + + def initialize(oid:, size:, link:) + @oid = oid + @size = size + @link = link + end + + def sanitized_uri + @sanitized_uri ||= Gitlab::UrlSanitizer.new(link) + end +end diff --git a/app/models/list.rb b/app/models/list.rb index 029685be927..682af761ba0 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -54,6 +54,6 @@ class List < ActiveRecord::Base private def can_be_destroyed - destroyable? + throw(:abort) unless destroyable? end end diff --git a/app/models/member.rb b/app/models/member.rb index 9fc95ea00c3..8e071a8ff21 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -76,14 +76,17 @@ class Member < ActiveRecord::Base scope :maintainers, -> { active.where(access_level: MAINTAINER) } scope :masters, -> { maintainers } # @deprecated scope :owners, -> { active.where(access_level: OWNER) } - scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) } + scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) } scope :owners_and_masters, -> { owners_and_maintainers } # @deprecated + scope :with_user, -> (user) { where(user: user) } scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) } scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) } scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) } scope :order_oldest_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'ASC')) } + scope :on_project_and_ancestors, ->(project) { where(source: [project] + project.ancestors) } + before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } after_create :send_invite, if: :invite?, unless: :importing? diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index fc49ee7ac8c..2c9e1ba1d80 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -12,6 +12,8 @@ class GroupMember < Member validates :source_type, format: { with: /\ANamespace\z/ } default_scope { where(source_type: SOURCE_TYPE) } + scope :in_groups, ->(groups) { where(source_id: groups.select(:id)) } + after_create :update_two_factor_requirement, unless: :invite? after_destroy :update_two_factor_requirement, unless: :invite? diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 016c18ce6c8..5372c6084f4 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -12,6 +12,10 @@ class ProjectMember < Member default_scope { where(source_type: SOURCE_TYPE) } scope :in_project, ->(project) { where(source_id: project.id) } + scope :in_namespaces, ->(groups) do + joins('INNER JOIN projects ON projects.id = members.source_id') + .where('projects.namespace_id in (?)', groups.select(:id)) + end class << self # Add users to projects with passed access option diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 944b9f72396..b67797f5404 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -21,6 +21,8 @@ class MergeRequest < ActiveRecord::Base self.reactive_cache_refresh_interval = 10.minutes self.reactive_cache_lifetime = 10.minutes + SORTING_PREFERENCE_FIELD = :merge_requests_sort + ignore_column :locked_at, :ref_fetched, :deleted_at @@ -69,7 +71,7 @@ class MergeRequest < ActiveRecord::Base serialize :merge_params, Hash # rubocop:disable Cop/ActiveRecordSerialize - after_create :ensure_merge_request_diff, unless: :importing? + after_create :ensure_merge_request_diff after_update :clear_memoized_shas after_update :reload_diff_if_branch_changed after_save :ensure_metrics @@ -105,7 +107,9 @@ class MergeRequest < ActiveRecord::Base before_transition any => :opened do |merge_request| merge_request.merge_jid = nil + end + after_transition any => :opened do |merge_request| merge_request.run_after_commit do UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id) end @@ -185,6 +189,9 @@ class MergeRequest < ActiveRecord::Base after_save :keep_around_commit + alias_attribute :project, :target_project + alias_attribute :project_id, :target_project_id + def self.reference_prefix '!' end @@ -282,6 +289,14 @@ class MergeRequest < ActiveRecord::Base work_in_progress?(title) ? title : "WIP: #{title}" end + def commit_authors + @commit_authors ||= commits.authors + end + + def authors + User.from_union([commit_authors, User.where(id: self.author_id)]) + end + # Verifies if title has changed not taking into account WIP prefix # for merge requests. def wipless_title_changed(old_title) @@ -325,13 +340,15 @@ class MergeRequest < ActiveRecord::Base end def commits - if persisted? - merge_request_diff.commits - elsif compare_commits - compare_commits.reverse - else - [] - end + return merge_request_diff.commits if persisted? + + commits_arr = if compare_commits + compare_commits.reverse + else + [] + end + + CommitCollection.new(source_project, commits_arr, source_branch) end def commits_count @@ -408,6 +425,28 @@ class MergeRequest < ActiveRecord::Base merge_request_diffs.where.not(id: merge_request_diff.id) end + def preloads_discussion_diff_highlighting? + true + end + + def preload_discussions_diff_highlight + preloadable_files = note_diff_files.for_commit_or_unresolved + + discussions_diffs.load_highlight(preloadable_files.pluck(:id)) + end + + def discussions_diffs + strong_memoize(:discussions_diffs) do + Gitlab::DiscussionsDiff::FileCollection.new(note_diff_files.to_a) + end + end + + def note_diff_files + NoteDiffFile + .where(diff_note: discussion_notes) + .includes(diff_note: :project) + end + def diff_size # Calling `merge_request_diff.diffs.real_size` will also perform # highlighting, which we don't need here. @@ -526,15 +565,19 @@ class MergeRequest < ActiveRecord::Base end def diff_refs - if persisted? - merge_request_diff.diff_refs - else - Gitlab::Diff::DiffRefs.new( - base_sha: diff_base_sha, - start_sha: diff_start_sha, - head_sha: diff_head_sha - ) - end + persisted? ? merge_request_diff.diff_refs : repository_diff_refs + end + + # Instead trying to fetch the + # persisted diff_refs, this method goes + # straight to the repository to get the + # most recent data possible. + def repository_diff_refs + Gitlab::Diff::DiffRefs.new( + base_sha: branch_merge_base_sha, + start_sha: target_branch_sha, + head_sha: source_branch_sha + ) end def branch_merge_base_sha @@ -724,6 +767,16 @@ class MergeRequest < ActiveRecord::Base true end + def mergeable_to_ref? + return false if merged? + return false if broken? + + # Given the `merge_ref_path` will have the same + # state the `target_branch` would have. Ideally + # we need to check if it can be merged to it. + project.repository.can_be_merged?(diff_head_sha, target_branch) + end + def ff_merge_possible? project.repository.ancestor?(target_branch_sha, diff_head_sha) end @@ -797,10 +850,6 @@ class MergeRequest < ActiveRecord::Base target_project != source_project end - def project - target_project - end - # If the merge request closes any issues, save this information in the # `MergeRequestsClosingIssues` model. This is a performance optimization. # Calculating this information for a number of merge requests requires @@ -899,7 +948,7 @@ class MergeRequest < ActiveRecord::Base self.target_project.repository.branch_exists?(self.target_branch) end - def merge_commit_message(include_description: false) + def default_merge_commit_message(include_description: false) closes_issues_references = visible_closing_issues_for.map do |issue| issue.to_reference(target_project) end @@ -919,6 +968,13 @@ class MergeRequest < ActiveRecord::Base message.join("\n\n") end + # Returns the oldest multi-line commit message, or the MR title if none found + def default_squash_commit_message + strong_memoize(:default_squash_commit_message) do + commits.without_merge_commits.reverse.find(&:description?)&.safe_message || title + end + end + def reset_merge_when_pipeline_succeeds return unless merge_when_pipeline_succeeds? @@ -927,6 +983,7 @@ class MergeRequest < ActiveRecord::Base if merge_params merge_params.delete('should_remove_source_branch') merge_params.delete('commit_message') + merge_params.delete('squash_commit_message') end self.save @@ -1029,6 +1086,10 @@ class MergeRequest < ActiveRecord::Base "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/head" end + def merge_ref_path + "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/merge" + end + def in_locked_state begin lock_mr @@ -1068,10 +1129,16 @@ class MergeRequest < ActiveRecord::Base def all_pipelines(shas: all_commit_shas) return Ci::Pipeline.none unless source_project - @all_pipelines ||= source_project.ci_pipelines - .where(sha: shas, ref: source_branch) - .where(merge_request: [nil, self]) - .sort_by_merge_request_pipelines + @all_pipelines ||= + source_project.ci_pipelines + .for_merge_request(self, source_branch, all_commit_shas) + end + + def update_head_pipeline + find_actual_head_pipeline.try do |pipeline| + self.head_pipeline = pipeline + update_column(:head_pipeline_id, head_pipeline.id) if head_pipeline_id_changed? + end end def merge_request_pipeline_exists? @@ -1086,35 +1153,16 @@ class MergeRequest < ActiveRecord::Base Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'CI_MERGE_REQUEST_ID', value: id.to_s) variables.append(key: 'CI_MERGE_REQUEST_IID', value: iid.to_s) - - variables.append(key: 'CI_MERGE_REQUEST_REF_PATH', - value: ref_path.to_s) - - variables.append(key: 'CI_MERGE_REQUEST_PROJECT_ID', - value: project.id.to_s) - - variables.append(key: 'CI_MERGE_REQUEST_PROJECT_PATH', - value: project.full_path) - - variables.append(key: 'CI_MERGE_REQUEST_PROJECT_URL', - value: project.web_url) - - variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME', - value: target_branch.to_s) - - if source_project - variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_ID', - value: source_project.id.to_s) - - variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_PATH', - value: source_project.full_path) - - variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_URL', - value: source_project.web_url) - - variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME', - value: source_branch.to_s) - end + variables.append(key: 'CI_MERGE_REQUEST_REF_PATH', value: ref_path.to_s) + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_ID', value: project.id.to_s) + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_PATH', value: project.full_path) + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_URL', value: project.web_url) + variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME', value: target_branch.to_s) + variables.append(key: 'CI_MERGE_REQUEST_TITLE', value: title) + variables.append(key: 'CI_MERGE_REQUEST_ASSIGNEES', value: assignee.username) if assignee + variables.append(key: 'CI_MERGE_REQUEST_MILESTONE', value: milestone.title) if milestone + variables.append(key: 'CI_MERGE_REQUEST_LABELS', value: label_names.join(',')) if labels.present? + variables.concat(source_project_variables) end end @@ -1268,7 +1316,7 @@ class MergeRequest < ActiveRecord::Base def base_pipeline @base_pipeline ||= project.ci_pipelines .order(id: :desc) - .find_by(sha: diff_base_sha) + .find_by(sha: diff_base_sha, ref: target_branch) end def discussions_rendered_on_frontend? @@ -1314,4 +1362,22 @@ class MergeRequest < ActiveRecord::Base source_project.repository.squash_in_progress?(id) end + + private + + def find_actual_head_pipeline + source_project&.ci_pipelines + &.latest_for_merge_request(self, source_branch, diff_head_sha) + end + + def source_project_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless source_project + + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_ID', value: source_project.id.to_s) + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_PATH', value: source_project.full_path) + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_URL', value: source_project.web_url) + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME', value: source_branch.to_s) + end + end end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index a3029a54604..351a662ae83 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -7,19 +7,23 @@ class MergeRequestDiff < ActiveRecord::Base include IgnorableColumn include EachBatch include Gitlab::Utils::StrongMemoize + include ObjectStorage::BackgroundMove # Don't display more than 100 commits at once COMMITS_SAFE_SIZE = 100 - ignore_column :st_commits, - :st_diffs - belongs_to :merge_request + manual_inverse_association :merge_request, :merge_request_diff - has_many :merge_request_diff_files, -> { order(:merge_request_diff_id, :relative_order) } + has_many :merge_request_diff_files, + -> { order(:merge_request_diff_id, :relative_order) }, + inverse_of: :merge_request_diff + has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) } + validates :base_commit_sha, :head_commit_sha, :start_commit_sha, sha: true + state_machine :state, initial: :empty do event :clean do transition any => :without_files @@ -45,10 +49,14 @@ class MergeRequestDiff < ActiveRecord::Base scope :recent, -> { order(id: :desc).limit(100) } + mount_uploader :external_diff, ExternalDiffUploader + # All diff information is collected from repository after object is created. # It allows you to override variables like head_commit_sha before getting diff. after_create :save_git_content, unless: :importing? + after_save :update_external_diff_store, if: :external_diff_changed? + def self.find_by_diff_refs(diff_refs) find_by(start_commit_sha: diff_refs.start_sha, head_commit_sha: diff_refs.head_sha, base_commit_sha: diff_refs.base_sha) end @@ -241,10 +249,97 @@ class MergeRequestDiff < ActiveRecord::Base end end + # Carrierwave defines `write_uploader` dynamically on this class, so `super` + # does not work. Alias the carrierwave method so we can call it when needed + alias_method :carrierwave_write_uploader, :write_uploader + + # The `external_diff`, `external_diff_store`, and `stored_externally` + # columns were introduced in GitLab 11.8, but some background migration specs + # use factories that rely on current code with an old schema. Without these + # `has_attribute?` guards, they fail with a `MissingAttributeError`. + # + # For more details, see: https://gitlab.com/gitlab-org/gitlab-ce/issues/44990 + + def write_uploader(column, identifier) + carrierwave_write_uploader(column, identifier) if has_attribute?(column) + end + + def update_external_diff_store + update_column(:external_diff_store, external_diff.object_store) if + has_attribute?(:external_diff_store) + end + + def external_diff_changed? + super if has_attribute?(:external_diff) + end + + def stored_externally + super if has_attribute?(:stored_externally) + end + alias_method :stored_externally?, :stored_externally + + # If enabled, yields the external file containing the diff. Otherwise, yields + # nil. This method is not thread-safe, but it *is* re-entrant, which allows + # multiple merge_request_diff_files to load their data efficiently + def opening_external_diff + return yield(nil) unless stored_externally? + return yield(@external_diff_file) if @external_diff_file + + external_diff.open do |file| + begin + @external_diff_file = file + + yield(@external_diff_file) + ensure + @external_diff_file = nil + end + end + end + private def create_merge_request_diff_files(diffs) - rows = diffs.map.with_index do |diff, index| + rows = + if has_attribute?(:external_diff) && Gitlab.config.external_diffs.enabled + build_external_merge_request_diff_files(diffs) + else + build_merge_request_diff_files(diffs) + end + + # Faster inserts + Gitlab::Database.bulk_insert('merge_request_diff_files', rows) + end + + def build_external_merge_request_diff_files(diffs) + rows = build_merge_request_diff_files(diffs) + tempfile = build_external_diff_tempfile(rows) + + self.external_diff = tempfile + self.stored_externally = true + + rows + ensure + tempfile&.unlink + end + + def build_external_diff_tempfile(rows) + Tempfile.open(external_diff.filename) do |file| + rows.inject(0) do |offset, row| + data = row.delete(:diff) + row[:external_diff_offset] = offset + row[:external_diff_size] = data.size + + file.write(data) + + offset + data.size + end + + file + end + end + + def build_merge_request_diff_files(diffs) + diffs.map.with_index do |diff, index| diff_hash = diff.to_hash.merge( binary: false, merge_request_diff_id: self.id, @@ -261,18 +356,20 @@ class MergeRequestDiff < ActiveRecord::Base end end end - - Gitlab::Database.bulk_insert('merge_request_diff_files', rows) end def load_diffs(options) - collection = merge_request_diff_files + # Ensure all diff files operate on the same external diff file instance if + # present. This reduces file open/close overhead. + opening_external_diff do + collection = merge_request_diff_files - if paths = options[:paths] - collection = collection.where('old_path IN (?) OR new_path IN (?)', paths, paths) - end + if paths = options[:paths] + collection = collection.where('old_path IN (?) OR new_path IN (?)', paths, paths) + end - Gitlab::Git::DiffCollection.new(collection.map(&:to_hash), options) + Gitlab::Git::DiffCollection.new(collection.map(&:to_hash), options) + end end def load_commits diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb index a9f110bec5c..e8d936e265c 100644 --- a/app/models/merge_request_diff_file.rb +++ b/app/models/merge_request_diff_file.rb @@ -4,7 +4,7 @@ class MergeRequestDiffFile < ActiveRecord::Base include Gitlab::EncodingHelper include DiffFile - belongs_to :merge_request_diff + belongs_to :merge_request_diff, inverse_of: :merge_request_diff_files def utf8_diff return '' if diff.blank? @@ -13,6 +13,16 @@ class MergeRequestDiffFile < ActiveRecord::Base end def diff - binary? ? super.unpack('m0').first : super + content = + if merge_request_diff&.stored_externally? + merge_request_diff.opening_external_diff do |file| + file.seek(external_diff_offset) + file.read(external_diff_size) + end + else + super + end + + binary? ? content.unpack('m0').first : content end end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 3cc8e2c44bb..26cfdc5ef30 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -28,7 +28,7 @@ class Milestone < ActiveRecord::Base has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.milestones&.maximum(:iid) } has_many :issues - has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues + has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :merge_requests has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -38,12 +38,14 @@ class Milestone < ActiveRecord::Base scope :closed, -> { with_state(:closed) } scope :for_projects, -> { where(group: nil).includes(:project) } - scope :for_projects_and_groups, -> (project_ids, group_ids) do - conditions = [] - conditions << arel_table[:project_id].in(project_ids) if project_ids&.compact&.any? - conditions << arel_table[:group_id].in(group_ids) if group_ids&.compact&.any? + scope :for_projects_and_groups, -> (projects, groups) do + projects = projects.compact if projects.is_a? Array + projects = [] if projects.nil? - where(conditions.reduce(:or)) + groups = groups.compact if groups.is_a? Array + groups = [] if groups.nil? + + where(project_id: projects).or(where(group_id: groups)) end scope :order_by_name_asc, -> { order(Arel::Nodes::Ascending.new(arel_table[:title].lower)) } @@ -75,7 +77,7 @@ class Milestone < ActiveRecord::Base alias_attribute :name, :title class << self - # Searches for milestones matching the given query. + # Searches for milestones with a matching title or description. # # This method uses ILIKE on PostgreSQL and LIKE on MySQL. # @@ -86,6 +88,17 @@ class Milestone < ActiveRecord::Base fuzzy_search(query, [:title, :description]) end + # Searches for milestones with a matching title. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. + def search_title(query) + fuzzy_search(query, [:title]) + end + def filter_by_state(milestones, state) case state when 'closed' then milestones.closed @@ -94,6 +107,10 @@ class Milestone < ActiveRecord::Base end end + def count_by_state + reorder(nil).group(:state).count + end + def predefined?(milestone) milestone == Any || milestone == None || @@ -129,18 +146,29 @@ class Milestone < ActiveRecord::Base @link_reference_pattern ||= super("milestones", /(?<milestone>\d+)/) end - def self.upcoming_ids_by_projects(projects) - rel = unscoped.of_projects(projects).active.where('due_date > ?', Time.now) + def self.upcoming_ids(projects, groups) + rel = unscoped + .for_projects_and_groups(projects, groups) + .active.where('milestones.due_date > NOW()') if Gitlab::Database.postgresql? - rel.order(:project_id, :due_date).select('DISTINCT ON (project_id) id') + rel.order(:project_id, :group_id, :due_date).select('DISTINCT ON (project_id, group_id) id') else + # We need to use MySQL's NULL-safe comparison operator `<=>` here + # because one of `project_id` or `group_id` is always NULL + join_clause = <<~HEREDOC + LEFT OUTER JOIN milestones earlier_milestones + ON milestones.project_id <=> earlier_milestones.project_id + AND milestones.group_id <=> earlier_milestones.group_id + AND milestones.due_date > earlier_milestones.due_date + AND earlier_milestones.due_date > NOW() + AND earlier_milestones.state = 'active' + HEREDOC + rel - .group(:project_id, :due_date, :id) - .having('due_date = MIN(due_date)') - .pluck(:id, :project_id, :due_date) - .uniq(&:second) - .map(&:first) + .joins(join_clause) + .where('earlier_milestones.id IS NULL') + .select(:id) end end @@ -174,7 +202,7 @@ class Milestone < ActiveRecord::Base return STATE_COUNT_HASH unless projects || groups counts = Milestone - .for_projects_and_groups(projects&.map(&:id), groups&.map(&:id)) + .for_projects_and_groups(projects, groups) .reorder(nil) .group(:state) .count @@ -212,10 +240,10 @@ class Milestone < ActiveRecord::Base end def reference_link_text(from = nil) - self.title + self.class.reference_prefix + self.title end - def milestoneish_ids + def milestoneish_id id end @@ -258,8 +286,7 @@ class Milestone < ActiveRecord::Base if project relation = Milestone.for_projects_and_groups([project_id], [project.group&.id]) elsif group - project_ids = group.projects.map(&:id) - relation = Milestone.for_projects_and_groups(project_ids, [group.id]) + relation = Milestone.for_projects_and_groups(group.projects.select(:id), [group.id]) end title_exists = relation.find_by_title(title) diff --git a/app/models/namespace.rb b/app/models/namespace.rb index a0bebc5e9a2..a5c479bdc0c 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Namespace < ActiveRecord::Base +class Namespace < ApplicationRecord include CacheMarkdownField include Sortable include Gitlab::VisibilityLevel @@ -149,7 +149,7 @@ class Namespace < ActiveRecord::Base end def find_fork_of(project) - return nil unless project.fork_network + return unless project.fork_network if Gitlab::SafeRequestStore.active? forks_in_namespace = Gitlab::SafeRequestStore.fetch("namespaces:#{id}:forked_projects") do diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb index 6da3bb7bfb7..ecbeb24ee0a 100644 --- a/app/models/network/graph.rb +++ b/app/models/network/graph.rb @@ -40,9 +40,12 @@ module Network # Get commits from repository # def collect_commits - find_commits(count_to_display_commit_in_center).map do |commit| - # Decorate with app/model/network/commit.rb - Network::Commit.new(commit) + # https://gitlab.com/gitlab-org/gitlab-ce/issues/58013 + Gitlab::GitalyClient.allow_n_plus_1_calls do + find_commits(count_to_display_commit_in_center).map do |commit| + # Decorate with app/model/network/commit.rb + Network::Commit.new(commit) + end end end diff --git a/app/models/note.rb b/app/models/note.rb index becf14e9785..1578ae9c4cc 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -456,6 +456,10 @@ class Note < ActiveRecord::Base Upload.find_by(model: self, path: paths) end + def parent + project + end + private def keep_around_commit diff --git a/app/models/note_diff_file.rb b/app/models/note_diff_file.rb index 27aef7adc48..e369122003e 100644 --- a/app/models/note_diff_file.rb +++ b/app/models/note_diff_file.rb @@ -3,7 +3,22 @@ class NoteDiffFile < ActiveRecord::Base include DiffFile + scope :for_commit_or_unresolved, -> do + joins(:diff_note).where("resolved_at IS NULL OR noteable_type = 'Commit'") + end + + delegate :original_position, :project, to: :diff_note + belongs_to :diff_note, inverse_of: :note_diff_file validates :diff_note, presence: true + + def raw_diff_file + raw_diff = Gitlab::Git::Diff.new(to_hash) + + Gitlab::Diff::File.new(raw_diff, + repository: project.repository, + diff_refs: original_position.diff_refs, + unique_identifier: id) + end end diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index 9f16eefe074..793cce191fa 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -119,7 +119,7 @@ class NotificationRecipient private def read_ability - return nil if @skip_read_ability + return if @skip_read_ability return @read_ability if instance_variable_defined?(:@read_ability) @read_ability = @@ -136,7 +136,7 @@ class NotificationRecipient end def default_project - return nil if @target.nil? + return if @target.nil? return @target if @target.is_a?(Project) return @target.project if @target.respond_to?(:project) end @@ -153,7 +153,7 @@ class NotificationRecipient user.global_notification_setting end - # Returns the notificaton_setting of the lowest group in hierarchy with non global level + # Returns the notification_setting of the lowest group in hierarchy with non global level def closest_non_global_group_notification_settting return unless @group return if indexed_group_notification_settings.empty? diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 73a58f2420e..ed78a46eaf3 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -2,8 +2,11 @@ class PersonalAccessToken < ActiveRecord::Base include Expirable + include IgnorableColumn include TokenAuthenticatable - add_authentication_token_field :token, digest: true, fallback: true + + add_authentication_token_field :token, digest: true + ignore_column :token REDIS_EXPIRY_TIME = 3.minutes diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb index ad6a008dee8..4635fc72dc7 100644 --- a/app/models/pool_repository.rb +++ b/app/models/pool_repository.rb @@ -85,14 +85,20 @@ class PoolRepository < ActiveRecord::Base def unlink_repository(repository) object_pool.unlink_repository(repository.raw) - mark_obsolete unless member_projects.where.not(id: repository.project.id).exists? + if member_projects.where.not(id: repository.project.id).exists? + true + else + mark_obsolete + end end def object_pool @object_pool ||= Gitlab::Git::ObjectPool.new( shard.name, disk_path + '.git', - source_project.repository.raw) + source_project.repository.raw, + source_project.full_path + ) end def inspect diff --git a/app/models/programming_language.rb b/app/models/programming_language.rb index 0e667dac21e..5f0f313b7f9 100644 --- a/app/models/programming_language.rb +++ b/app/models/programming_language.rb @@ -3,4 +3,10 @@ class ProgrammingLanguage < ActiveRecord::Base validates :name, presence: true validates :color, allow_blank: false, color: true + + # Returns all programming languages which match the given name (case + # insensitively). + scope :with_name_case_insensitive, ->(name) do + where(arel_table[:name].matches(sanitize_sql_like(name))) + end end diff --git a/app/models/project.rb b/app/models/project.rb index 09e2a6114fe..4cc13f372c1 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -73,7 +73,7 @@ class Project < ActiveRecord::Base delegate :no_import?, to: :import_state, allow_nil: true default_value_for :archived, false - default_value_for :visibility_level, gitlab_config_features.visibility_level + default_value_for(:visibility_level) { Gitlab::CurrentSettings.default_project_visibility } default_value_for :resolve_outdated_diff_discussions, false default_value_for :container_registry_enabled, gitlab_config_features.container_registry default_value_for(:repository_storage) { Gitlab::CurrentSettings.pick_repository_storage } @@ -85,7 +85,7 @@ class Project < ActiveRecord::Base default_value_for :snippets_enabled, gitlab_config_features.snippets default_value_for :only_allow_merge_if_all_discussions_are_resolved, false - add_authentication_token_field :runners_token, encrypted: true, migrating: true + add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:projects_tokens_optional_encryption) ? :optional : :required } before_validation :mark_remote_mirrors_for_removal, if: -> { RemoteMirror.table_exists? } @@ -147,7 +147,6 @@ class Project < ActiveRecord::Base has_one :pipelines_email_service has_one :irker_service has_one :pivotaltracker_service - has_one :hipchat_service has_one :flowdock_service has_one :assembla_service has_one :asana_service @@ -161,6 +160,7 @@ class Project < ActiveRecord::Base has_one :pushover_service has_one :jira_service has_one :redmine_service + has_one :youtrack_service has_one :custom_issue_tracker_service has_one :bugzilla_service has_one :gitlab_issue_tracker_service, inverse_of: :project @@ -187,6 +187,7 @@ class Project < ActiveRecord::Base has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_one :project_repository, inverse_of: :project + has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting' # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project @@ -248,10 +249,10 @@ class Project < ActiveRecord::Base has_many :container_repositories, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :commit_statuses - # The relation :all_pipelines is intented to be used when we want to get the + # The relation :all_pipelines is intended to be used when we want to get the # whole list of pipelines associated to the project has_many :all_pipelines, class_name: 'Ci::Pipeline', inverse_of: :project - # The relation :ci_pipelines is intented to be used when we want to get only + # The relation :ci_pipelines is intended to be used when we want to get only # those pipeline which are directly related to CI. There are # other pipelines, like webide ones, that we won't retrieve # if we use this relation. @@ -295,6 +296,8 @@ class Project < ActiveRecord::Base allow_destroy: true, reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? } + accepts_nested_attributes_for :error_tracking_setting, update_only: true + delegate :name, to: :owner, allow_nil: true, prefix: true delegate :members, to: :team, prefix: true delegate :add_user, :add_users, to: :team @@ -303,6 +306,7 @@ class Project < ActiveRecord::Base delegate :group_runners_enabled, :group_runners_enabled=, :group_runners_enabled?, to: :ci_cd_settings delegate :group_clusters_enabled?, to: :group, allow_nil: true delegate :root_ancestor, to: :namespace, allow_nil: true + delegate :last_pipeline, to: :commit, allow_nil: true # Validations validates :creator, presence: true, on: :create @@ -324,15 +328,14 @@ class Project < ActiveRecord::Base validates :namespace, presence: true validates :name, uniqueness: { scope: :namespace_id } - validates :import_url, url: { protocols: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS }, - ports: ->(project) { project.persisted? ? VALID_MIRROR_PORTS : VALID_IMPORT_PORTS }, - allow_localhost: false, - enforce_user: true }, if: [:external_import?, :import_url_changed?] + validates :import_url, public_url: { protocols: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS }, + ports: ->(project) { project.persisted? ? VALID_MIRROR_PORTS : VALID_IMPORT_PORTS }, + enforce_user: true }, if: [:external_import?, :import_url_changed?] validates :star_count, numericality: { greater_than_or_equal_to: 0 } - validate :check_limit, on: :create + validate :check_personal_projects_limit, on: :create validate :check_repository_path_availability, on: :update, if: ->(project) { project.renamed? } - validate :visibility_level_allowed_by_group - validate :visibility_level_allowed_as_fork + validate :visibility_level_allowed_by_group, if: -> { changes.has_key?(:visibility_level) } + validate :visibility_level_allowed_as_fork, if: -> { changes.has_key?(:visibility_level) } validate :check_wiki_path_conflict validate :validate_pages_https_only, if: -> { changes.has_key?(:pages_https_only) } validates :repository_storage, @@ -375,8 +378,10 @@ class Project < ActiveRecord::Base # "enabled" here means "not disabled". It includes private features! scope :with_feature_enabled, ->(feature) { - access_level_attribute = ProjectFeature.access_level_attribute(feature) - with_project_feature.where(project_features: { access_level_attribute => [nil, ProjectFeature::PRIVATE, ProjectFeature::ENABLED, ProjectFeature::PUBLIC] }) + access_level_attribute = ProjectFeature.arel_table[ProjectFeature.access_level_attribute(feature)] + enabled_feature = access_level_attribute.gt(ProjectFeature::DISABLED).or(access_level_attribute.eq(nil)) + + with_project_feature.where(enabled_feature) } # Picks a feature where the level is exactly that given. @@ -385,6 +390,16 @@ class Project < ActiveRecord::Base with_project_feature.where(project_features: { access_level_attribute => level }) } + # Picks projects which use the given programming language + scope :with_programming_language, ->(language_name) do + lang_id_query = ProgrammingLanguage + .with_name_case_insensitive(language_name) + .select(:id) + + joins(:repository_languages) + .where(repository_languages: { programming_language_id: lang_id_query }) + end + scope :with_builds_enabled, -> { with_feature_enabled(:builds) } scope :with_issues_enabled, -> { with_feature_enabled(:issues) } scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) } @@ -463,7 +478,8 @@ class Project < ActiveRecord::Base # logged in users to more efficiently get private projects with the given # feature. def self.with_feature_available_for_user(feature, user) - visible = [nil, ProjectFeature::ENABLED, ProjectFeature::PUBLIC] + visible = [ProjectFeature::ENABLED, ProjectFeature::PUBLIC] + min_access_level = ProjectFeature.required_minimum_access_level(feature) if user&.admin? with_feature_enabled(feature) @@ -471,10 +487,15 @@ class Project < ActiveRecord::Base column = ProjectFeature.quoted_access_level_column(feature) with_project_feature - .where("#{column} IN (?) OR (#{column} = ? AND EXISTS (?))", - visible, - ProjectFeature::PRIVATE, - user.authorizations_for_projects) + .where( + "(projects.visibility_level > :private AND (#{column} IS NULL OR #{column} >= (:public_visible) OR (#{column} = :private_visible AND EXISTS(:authorizations))))"\ + " OR (projects.visibility_level = :private AND (#{column} IS NULL OR #{column} >= :private_visible) AND EXISTS(:authorizations))", + { + private: Gitlab::VisibilityLevel::PRIVATE, + public_visible: ProjectFeature::ENABLED, + private_visible: ProjectFeature::PRIVATE, + authorizations: user.authorizations_for_projects(min_access_level: min_access_level) + }) else with_feature_access_level(feature, visible) end @@ -528,6 +549,7 @@ class Project < ActiveRecord::Base def reference_pattern %r{ + (?<!#{Gitlab::PathRegex::PATH_START_CHAR}) ((?<namespace>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})\/)? (?<project>#{Gitlab::PathRegex::PROJECT_PATH_FORMAT_REGEX}) }x @@ -567,6 +589,22 @@ class Project < ActiveRecord::Base end end + def all_pipelines + if builds_enabled? + super + else + super.external + end + end + + def ci_pipelines + if builds_enabled? + super + else + super.external + end + end + # returns all ancestor-groups upto but excluding the given namespace # when no namespace is given, all ancestors upto the top are returned def ancestors_upto(top = nil, hierarchy_order: nil) @@ -601,6 +639,10 @@ class Project < ActiveRecord::Base auto_devops&.enabled.nil? && !(Gitlab::CurrentSettings.auto_devops_enabled? || Feature.enabled?(:force_autodevops_on_by_default, self)) end + def daily_statistics_enabled? + Feature.enabled?(:project_daily_statistics, self, default_enabled: true) + end + def empty_repo? repository.empty? end @@ -645,19 +687,15 @@ class Project < ActiveRecord::Base end # ref can't be HEAD, can only be branch/tag name or SHA - def latest_successful_builds_for(ref = default_branch) + def latest_successful_build_for(job_name, ref = default_branch) latest_pipeline = ci_pipelines.latest_successful_for(ref) + return unless latest_pipeline - if latest_pipeline - latest_pipeline.builds.latest.with_artifacts_archive - else - builds.none - end + latest_pipeline.builds.latest.with_artifacts_archive.find_by(name: job_name) end - def latest_successful_build_for(job_name, ref = default_branch) - builds = latest_successful_builds_for(ref) - builds.find_by!(name: job_name) + def latest_successful_build_for!(job_name, ref = default_branch) + latest_successful_build_for(job_name, ref) || raise(ActiveRecord::RecordNotFound.new("Couldn't find job #{job_name}")) end def merge_base_commit(first_commit_id, second_commit_id) @@ -723,11 +761,13 @@ class Project < ActiveRecord::Base end def import_url=(value) - return super(value) unless Gitlab::UrlSanitizer.valid?(value) - - import_url = Gitlab::UrlSanitizer.new(value) - super(import_url.sanitized_url) - create_or_update_import_data(credentials: import_url.credentials) + if Gitlab::UrlSanitizer.valid?(value) + import_url = Gitlab::UrlSanitizer.new(value) + super(import_url.sanitized_url) + create_or_update_import_data(credentials: import_url.credentials) + else + super(value) + end end def import_url @@ -811,18 +851,22 @@ class Project < ActiveRecord::Base ::Gitlab::CurrentSettings.mirror_available end - def check_limit - unless creator.can_create_project? || namespace.kind == 'group' - projects_limit = creator.projects_limit + def check_personal_projects_limit + # Since this method is called as validation hook, `creator` might not be + # present. Since the validation for that will fail, we can just return + # early. + return if !creator || creator.can_create_project? || + namespace.kind == 'group' - if projects_limit == 0 - self.errors.add(:limit_reached, "Personal project creation is not allowed. Please contact your administrator with questions") + limit = creator.projects_limit + error = + if limit.zero? + _('Personal project creation is not allowed. Please contact your administrator with questions') else - self.errors.add(:limit_reached, "Your project limit is #{projects_limit} projects! Please contact your administrator to increase it") + _('Your project limit is %{limit} projects! Please contact your administrator to increase it') end - end - rescue - self.errors.add(:base, "Can't check your ability to create project") + + self.errors.add(:limit_reached, error % { limit: limit }) end def visibility_level_allowed_by_group @@ -913,11 +957,16 @@ class Project < ActiveRecord::Base def new_issuable_address(author, address_type) return unless Gitlab::IncomingEmail.supports_issue_creation? && author + # check since this can come from a request parameter + return unless %w(issue merge_request).include?(address_type) + author.ensure_incoming_email_token! - suffix = address_type == 'merge_request' ? '+merge-request' : '' - Gitlab::IncomingEmail.reply_address( - "#{full_path}#{suffix}+#{author.incoming_email_token}") + suffix = address_type.dasherize + + # example: incoming+h5bp-html5-boilerplate-8-1234567890abcdef123456789-issue@localhost.com + # example: incoming+h5bp-html5-boilerplate-8-1234567890abcdef123456789-merge-request@localhost.com + Gitlab::IncomingEmail.reply_address("#{full_path_slug}-#{project_id}-#{author.incoming_email_token}-#{suffix}") end def build_commit_note(commit) @@ -1042,7 +1091,7 @@ class Project < ActiveRecord::Base # rubocop: disable CodeReuse/ServiceClass def create_labels Label.templates.each do |label| - params = label.attributes.except('id', 'template', 'created_at', 'updated_at') + params = label.attributes.except('id', 'template', 'created_at', 'updated_at', 'type') Labels::FindOrCreateService.new(nil, self, params).execute(skip_authorization: true) end end @@ -1171,7 +1220,7 @@ class Project < ActiveRecord::Base "#{web_url}.git" end - # Is overriden in EE + # Is overridden in EE def lfs_http_url_to_repo(_) http_url_to_repo end @@ -1181,7 +1230,7 @@ class Project < ActiveRecord::Base end def fork_source - return nil unless forked? + return unless forked? forked_from_project || fork_network&.root_project end @@ -1252,7 +1301,7 @@ class Project < ActiveRecord::Base # Forked import is handled asynchronously return if forked? && !force - if gitlab_shell.create_repository(repository_storage, disk_path) + if gitlab_shell.create_project_repository(self) repository.after_create true else @@ -1528,7 +1577,7 @@ class Project < ActiveRecord::Base end def pages_available? - Gitlab.config.pages.enabled && !namespace.subgroup? + Gitlab.config.pages.enabled end def remove_private_deploy_keys @@ -1578,6 +1627,13 @@ class Project < ActiveRecord::Base def after_import repository.after_import wiki.repository.after_import + + # The import assigns iid values on its own, e.g. by re-using GitHub ids. + # Flush existing InternalId records for this project for consistency reasons. + # Those records are going to be recreated with the next normal creation + # of a model instance (e.g. an Issue). + InternalId.flush_records!(project: self) + import_state.finish import_state.remove_jid update_project_counter_caches @@ -1599,24 +1655,7 @@ class Project < ActiveRecord::Base # rubocop: disable CodeReuse/ServiceClass def after_create_default_branch - return unless default_branch - - # Ensure HEAD points to the default branch in case it is not master - change_head(default_branch) - - if Gitlab::CurrentSettings.default_branch_protection != Gitlab::Access::PROTECTION_NONE && !ProtectedBranch.protected?(self, default_branch) - params = { - name: default_branch, - push_access_levels_attributes: [{ - access_level: Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_PUSH ? Gitlab::Access::DEVELOPER : Gitlab::Access::MAINTAINER - }], - merge_access_levels_attributes: [{ - access_level: Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE ? Gitlab::Access::DEVELOPER : Gitlab::Access::MAINTAINER - }] - } - - ProtectedBranches::CreateService.new(self, creator, params).execute(skip_authorization: true) - end + Projects::ProtectDefaultBranchService.new(self).execute end # rubocop: enable CodeReuse/ServiceClass @@ -1640,7 +1679,7 @@ class Project < ActiveRecord::Base end def export_path - return nil unless namespace.present? || hashed_storage?(:repository) + return unless namespace.present? || hashed_storage?(:repository) import_export_shared.archive_path end @@ -1699,8 +1738,23 @@ class Project < ActiveRecord::Base .append(key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path) .append(key: 'CI_PROJECT_URL', value: web_url) .append(key: 'CI_PROJECT_VISIBILITY', value: visibility) + .concat(pages_variables) .concat(container_registry_variables) .concat(auto_devops_variables) + .concat(api_variables) + end + + def pages_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_PAGES_DOMAIN', value: Gitlab.config.pages.host) + variables.append(key: 'CI_PAGES_URL', value: pages_url) + end + end + + def api_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_API_V4_URL', value: API::Helpers::Version.new('v4').root_url) + end end def container_registry_variables @@ -1734,10 +1788,21 @@ class Project < ActiveRecord::Base end def protected_for?(ref) - if repository.branch_exists?(ref) - ProtectedBranch.protected?(self, ref) - elsif repository.tag_exists?(ref) - ProtectedTag.protected?(self, ref) + raise Repository::AmbiguousRefError if repository.ambiguous_ref?(ref) + + resolved_ref = repository.expand_ref(ref) || ref + return false unless Gitlab::Git.tag_ref?(resolved_ref) || Gitlab::Git.branch_ref?(resolved_ref) + + ref_name = if resolved_ref == ref + Gitlab::Git.ref_name(resolved_ref) + else + ref + end + + if Gitlab::Git.branch_ref?(resolved_ref) + ProtectedBranch.protected?(self, ref_name) + elsif Gitlab::Git.tag_ref?(resolved_ref) + ProtectedTag.protected?(self, ref_name) end end @@ -1764,6 +1829,24 @@ class Project < ActiveRecord::Base handle_update_attribute_error(e, value) end + # Tries to set repository as read_only, checking for existing Git transfers in progress beforehand + # + # @return [Boolean] true when set to read_only or false when an existing git transfer is in progress + def set_repository_read_only! + with_lock do + break false if git_transfer_in_progress? + + update_column(:repository_read_only, true) + end + end + + # Set repository as writable again + def set_repository_writable! + with_lock do + update_column(:repository_read_only, false) + end + end + def pushes_since_gc Gitlab::Redis::SharedState.with { |redis| redis.get(pushes_since_gc_redis_shared_state_key).to_i } end @@ -1847,6 +1930,14 @@ class Project < ActiveRecord::Base persisted? && path_changed? end + def human_merge_method + if merge_method == :ff + 'Fast-forward' + else + merge_method.to_s.humanize + end + end + def merge_method if self.merge_requests_ff_only_enabled :ff @@ -1878,15 +1969,27 @@ class Project < ActiveRecord::Base def migrate_to_hashed_storage! return unless storage_upgradable? - update!(repository_read_only: true) + if git_transfer_in_progress? + HashedStorage::ProjectMigrateWorker.perform_in(Gitlab::ReferenceCounter::REFERENCE_EXPIRE_TIME, id) + else + HashedStorage::ProjectMigrateWorker.perform_async(id) + end + end + + def rollback_to_legacy_storage! + return if legacy_storage? - if repo_reference_count > 0 || wiki_reference_count > 0 - ProjectMigrateHashedStorageWorker.perform_in(Gitlab::ReferenceCounter::REFERENCE_EXPIRE_TIME, id) + if git_transfer_in_progress? + HashedStorage::ProjectRollbackWorker.perform_in(Gitlab::ReferenceCounter::REFERENCE_EXPIRE_TIME, id) else - ProjectMigrateHashedStorageWorker.perform_async(id) + HashedStorage::ProjectRollbackWorker.perform_async(id) end end + def git_transfer_in_progress? + repo_reference_count > 0 || wiki_reference_count > 0 + end + def storage_version=(value) super @@ -1918,23 +2021,15 @@ class Project < ActiveRecord::Base .where('project_authorizations.project_id = merge_requests.target_project_id') .limit(1) .select(1) - source_of_merge_requests.opened - .where(allow_collaboration: true) - .where('EXISTS (?)', developer_access_exists) + merge_requests_allowing_collaboration.where('EXISTS (?)', developer_access_exists) end - def branch_allows_collaboration?(user, branch_name) - return false unless user - - cache_key = "user:#{user.id}:#{branch_name}:branch_allows_push" - - memoized_results = strong_memoize(:branch_allows_collaboration) do - Hash.new do |result, cache_key| - result[cache_key] = fetch_branch_allows_collaboration?(user, branch_name) - end - end + def any_branch_allows_collaboration?(user) + fetch_branch_allows_collaboration(user) + end - memoized_results[cache_key] + def branch_allows_collaboration?(user, branch_name) + fetch_branch_allows_collaboration(user, branch_name) end def licensed_features @@ -2003,11 +2098,25 @@ class Project < ActiveRecord::Base end def leave_pool_repository - pool_repository&.unlink_repository(repository) + pool_repository&.unlink_repository(repository) && update_column(:pool_repository_id, nil) + end + + def link_pool_repository + pool_repository&.link_repository(repository) + end + + def has_pool_repository? + pool_repository.present? end private + def merge_requests_allowing_collaboration(source_branch = nil) + relation = source_of_merge_requests.opened.where(allow_collaboration: true) + relation = relation.where(source_branch: source_branch) if source_branch + relation + end + def create_new_pool_repository pool = begin create_pool_repository!(shard: Shard.by_name(repository_storage), source_project: self) @@ -2132,26 +2241,19 @@ class Project < ActiveRecord::Base raise ex end - def fetch_branch_allows_collaboration?(user, branch_name) - check_access = -> do - next false if empty_repo? + def fetch_branch_allows_collaboration(user, branch_name = nil) + return false unless user - merge_requests = source_of_merge_requests.opened - .where(allow_collaboration: true) + Gitlab::SafeRequestStore.fetch("project-#{id}:branch-#{branch_name}:user-#{user.id}:branch_allows_collaboration") do + next false if empty_repo? # Issue for N+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/49322 Gitlab::GitalyClient.allow_n_plus_1_calls do - if branch_name - merge_requests.find_by(source_branch: branch_name)&.can_be_merged_by?(user) - else - merge_requests.any? { |merge_request| merge_request.can_be_merged_by?(user) } + merge_requests_allowing_collaboration(branch_name).any? do |merge_request| + merge_request.can_be_merged_by?(user) end end end - - Gitlab::SafeRequestStore.fetch("project-#{id}:branch-#{branch_name}:user-#{user.id}:branch_allows_collaboration") do - check_access.call - end end def services_templates diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb index 2253ad7b543..e353a6443c4 100644 --- a/app/models/project_auto_devops.rb +++ b/app/models/project_auto_devops.rb @@ -24,6 +24,12 @@ class ProjectAutoDevops < ActiveRecord::Base domain.present? || instance_domain.present? end + # From 11.8, AUTO_DEVOPS_DOMAIN has been replaced by KUBE_INGRESS_BASE_DOMAIN. + # See Clusters::Cluster#predefined_variables and https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24580 + # for more info. + # + # Suppport AUTO_DEVOPS_DOMAIN is scheduled to be removed on + # https://gitlab.com/gitlab-org/gitlab-ce/issues/52363 def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| if has_domain? diff --git a/app/models/project_daily_statistic.rb b/app/models/project_daily_statistic.rb new file mode 100644 index 00000000000..ff115dd010f --- /dev/null +++ b/app/models/project_daily_statistic.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class ProjectDailyStatistic < ActiveRecord::Base + belongs_to :project + + scope :of_project, -> (project) { where(project: project) } + scope :of_last_30_days, -> { where('date >= ?', 29.days.ago.utc.to_date) } + scope :sorted_by_date_desc, -> { order(project_id: :desc, date: :desc) } + scope :sum_fetch_count, -> { sum(:fetch_count) } +end diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 39f2b8fe0de..e6787236c4e 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -23,11 +23,11 @@ class ProjectFeature < ActiveRecord::Base PUBLIC = 30 FEATURES = %i(issues merge_requests wiki snippets builds repository pages).freeze + PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER }.freeze class << self def access_level_attribute(feature) - feature = feature.model_name.plural.to_sym if feature.respond_to?(:model_name) - raise ArgumentError, "invalid project feature: #{feature}" unless FEATURES.include?(feature) + feature = ensure_feature!(feature) "#{feature}_access_level".to_sym end @@ -38,6 +38,21 @@ class ProjectFeature < ActiveRecord::Base "#{table}.#{attribute}" end + + def required_minimum_access_level(feature) + feature = ensure_feature!(feature) + + PRIVATE_FEATURES_MIN_ACCESS_LEVEL.fetch(feature, Gitlab::Access::GUEST) + end + + private + + def ensure_feature!(feature) + feature = feature.model_name.plural.to_sym if feature.respond_to?(:model_name) + raise ArgumentError, "invalid project feature: #{feature}" unless FEATURES.include?(feature) + + feature + end end # Default scopes force us to unscope here since a service may need to check @@ -61,7 +76,7 @@ class ProjectFeature < ActiveRecord::Base # This feature might not be behind a feature flag at all, so default to true return false unless ::Feature.enabled?(feature, user, default_enabled: true) - get_permission(user, access_level(feature)) + get_permission(user, feature) end def access_level(feature) @@ -119,12 +134,12 @@ class ProjectFeature < ActiveRecord::Base (FEATURES - %i(pages)).each {|f| validator.call("#{f}_access_level")} end - def get_permission(user, level) - case level + def get_permission(user, feature) + case access_level(feature) when DISABLED false when PRIVATE - user && (project.team.member?(user) || user.full_private_access?) + team_access?(user, feature) when ENABLED true when PUBLIC @@ -133,4 +148,11 @@ class ProjectFeature < ActiveRecord::Base true end end + + def team_access?(user, feature) + return unless user + return true if user.full_private_access? + + project.team.member?(user, ProjectFeature.required_minimum_access_level(feature)) + end end diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb index 525725034a5..aa0c121fe99 100644 --- a/app/models/project_import_data.rb +++ b/app/models/project_import_data.rb @@ -30,4 +30,8 @@ class ProjectImportData < ActiveRecord::Base def merge_credentials(hash) self.credentials = credentials.to_h.merge(hash) unless hash.empty? end + + def clear_credentials + self.credentials = {} + end end diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb index a252052200a..71f5607dbdb 100644 --- a/app/models/project_services/bamboo_service.rb +++ b/app/models/project_services/bamboo_service.rb @@ -80,19 +80,27 @@ class BambooService < CiService private - def get_build_result_index - # When Bamboo returns multiple results for a given changeset, arbitrarily assume the most relevant result to be the last one. - -1 + def get_build_result(response) + return if response.code != 200 + + # May be nil if no result, a single result hash, or an array if multiple results for a given changeset. + result = response.dig('results', 'results', 'result') + + # In case of multiple results, arbitrarily assume the last one is the most relevant. + return result.last if result.is_a?(Array) + + result end def read_build_page(response) + result = get_build_result(response) key = - if response.code != 200 || response.dig('results', 'results', 'size') == '0' + if result.blank? # If actual build link can't be determined, send user to build summary page. build_key else # If actual build link is available, go to build result page. - response.dig('results', 'results', 'result', get_build_result_index, 'planResultKey', 'key') + result.dig('planResultKey', 'key') end build_url("browse/#{key}") @@ -101,11 +109,15 @@ class BambooService < CiService def read_commit_status(response) return :error unless response.code == 200 || response.code == 404 - status = if response.code == 404 || response.dig('results', 'results', 'size') == '0' - 'Pending' - else - response.dig('results', 'results', 'result', get_build_result_index, 'buildState') - end + result = get_build_result(response) + status = + if result.blank? + 'Pending' + else + result.dig('buildState') + end + + return :error unless status.present? if status.include?('Success') 'success' diff --git a/app/models/project_services/campfire_service.rb b/app/models/project_services/campfire_service.rb index 1d7877a1fb5..ad26e42a21b 100644 --- a/app/models/project_services/campfire_service.rb +++ b/app/models/project_services/campfire_service.rb @@ -57,7 +57,7 @@ class CampfireService < Service # https://github.com/basecamp/campfire-api/blob/master/sections/messages.md#create-message def speak(room_name, message, auth) room = rooms(auth).find { |r| r["name"] == room_name } - return nil unless room + return unless room path = "/room/#{room["id"]}/speak.json" body = { diff --git a/app/models/project_services/deployment_service.rb b/app/models/project_services/deployment_service.rb index 6dae4f3a4a6..80aa2101509 100644 --- a/app/models/project_services/deployment_service.rb +++ b/app/models/project_services/deployment_service.rb @@ -11,7 +11,7 @@ class DeploymentService < Service %w() end - def predefined_variables + def predefined_variables(project:) [] end diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb deleted file mode 100644 index a69b7b4c4b6..00000000000 --- a/app/models/project_services/hipchat_service.rb +++ /dev/null @@ -1,311 +0,0 @@ -# frozen_string_literal: true - -class HipchatService < Service - include ActionView::Helpers::SanitizeHelper - - MAX_COMMITS = 3 - HIPCHAT_ALLOWED_TAGS = %w[ - a b i strong em br img pre code - table th tr td caption colgroup col thead tbody tfoot - ul ol li dl dt dd - ].freeze - - prop_accessor :token, :room, :server, :color, :api_version - boolean_accessor :notify_only_broken_pipelines, :notify - validates :token, presence: true, if: :activated? - - def initialize_properties - if properties.nil? - self.properties = {} - self.notify_only_broken_pipelines = true - end - end - - def title - 'HipChat' - end - - def description - 'Private group chat and IM' - end - - def self.to_param - 'hipchat' - end - - def fields - [ - { type: 'text', name: 'token', placeholder: 'Room token', required: true }, - { type: 'text', name: 'room', placeholder: 'Room name or ID' }, - { type: 'checkbox', name: 'notify' }, - { type: 'select', name: 'color', choices: %w(yellow red green purple gray random) }, - { type: 'text', name: 'api_version', - placeholder: 'Leave blank for default (v2)' }, - { type: 'text', name: 'server', - placeholder: 'Leave blank for default. https://hipchat.example.com' }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' } - ] - end - - def self.supported_events - %w(push issue confidential_issue merge_request note confidential_note tag_push pipeline) - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - message = create_message(data) - return unless message.present? - - gate[room].send('GitLab', message, message_options(data)) # rubocop:disable GitlabSecurity/PublicSend - end - - def test(data) - begin - result = execute(data) - rescue StandardError => error - return { success: false, result: error } - end - - { success: true, result: result } - end - - private - - def gate - options = { api_version: api_version.present? ? api_version : 'v2' } - options[:server_url] = server unless server.blank? - @gate ||= HipChat::Client.new(token, options) - end - - def message_options(data = nil) - { notify: notify.present? && Gitlab::Utils.to_boolean(notify), color: message_color(data) } - end - - def create_message(data) - object_kind = data[:object_kind] - - case object_kind - when "push", "tag_push" - create_push_message(data) - when "issue" - create_issue_message(data) unless update?(data) - when "merge_request" - create_merge_request_message(data) unless update?(data) - when "note" - create_note_message(data) - when "pipeline" - create_pipeline_message(data) if should_pipeline_be_notified?(data) - end - end - - def render_line(text) - markdown(text.lines.first.chomp, pipeline: :single_line) if text - end - - def create_push_message(push) - ref_type = Gitlab::Git.tag_ref?(push[:ref]) ? 'tag' : 'branch' - ref = Gitlab::Git.ref_name(push[:ref]) - - before = push[:before] - after = push[:after] - - message = [] - message << "#{push[:user_name]} " - - if Gitlab::Git.blank_ref?(before) - message << "pushed new #{ref_type} <a href=\""\ - "#{project_url}/commits/#{CGI.escape(ref)}\">#{ref}</a>"\ - " to #{project_link}\n" - elsif Gitlab::Git.blank_ref?(after) - message << "removed #{ref_type} <b>#{ref}</b> from <a href=\"#{project.web_url}\">#{project_name}</a> \n" - else - message << "pushed to #{ref_type} <a href=\""\ - "#{project.web_url}/commits/#{CGI.escape(ref)}\">#{ref}</a> " - message << "of <a href=\"#{project.web_url}\">#{project.full_name.gsub!(/\s/, '')}</a> " - message << "(<a href=\"#{project.web_url}/compare/#{before}...#{after}\">Compare changes</a>)" - - push[:commits].take(MAX_COMMITS).each do |commit| - message << "<br /> - #{render_line(commit[:message])} (<a href=\"#{commit[:url]}\">#{commit[:id][0..5]}</a>)" - end - - if push[:commits].count > MAX_COMMITS - message << "<br />... #{push[:commits].count - MAX_COMMITS} more commits" - end - end - - message.join - end - - def markdown(text, options = {}) - return "" unless text - - context = { - project: project, - pipeline: :email - } - - Banzai.render(text, context) - - context.merge!(options) - - html = Banzai.render_and_post_process(text, context) - sanitized_html = sanitize(html, tags: HIPCHAT_ALLOWED_TAGS, attributes: %w[href title alt]) - - sanitized_html.truncate(200, separator: ' ', omission: '...') - end - - def create_issue_message(data) - user_name = data[:user][:name] - - obj_attr = data[:object_attributes] - obj_attr = HashWithIndifferentAccess.new(obj_attr) - title = render_line(obj_attr[:title]) - state = obj_attr[:state] - issue_iid = obj_attr[:iid] - issue_url = obj_attr[:url] - description = obj_attr[:description] - - issue_link = "<a href=\"#{issue_url}\">issue ##{issue_iid}</a>" - - message = ["#{user_name} #{state} #{issue_link} in #{project_link}: <b>#{title}</b>"] - message << "<pre>#{markdown(description)}</pre>" - - message.join - end - - def create_merge_request_message(data) - user_name = data[:user][:name] - - obj_attr = data[:object_attributes] - obj_attr = HashWithIndifferentAccess.new(obj_attr) - merge_request_id = obj_attr[:iid] - state = obj_attr[:state] - description = obj_attr[:description] - title = render_line(obj_attr[:title]) - - merge_request_url = "#{project_url}/merge_requests/#{merge_request_id}" - merge_request_link = "<a href=\"#{merge_request_url}\">merge request !#{merge_request_id}</a>" - message = ["#{user_name} #{state} #{merge_request_link} in " \ - "#{project_link}: <b>#{title}</b>"] - - message << "<pre>#{markdown(description)}</pre>" - message.join - end - - def format_title(title) - "<b>#{render_line(title)}</b>" - end - - def create_note_message(data) - data = HashWithIndifferentAccess.new(data) - user_name = data[:user][:name] - - obj_attr = HashWithIndifferentAccess.new(data[:object_attributes]) - note = obj_attr[:note] - note_url = obj_attr[:url] - noteable_type = obj_attr[:noteable_type] - commit_id = nil - - case noteable_type - when "Commit" - commit_attr = HashWithIndifferentAccess.new(data[:commit]) - commit_id = commit_attr[:id] - subject_desc = commit_id - subject_desc = Commit.truncate_sha(subject_desc) - subject_type = "commit" - title = format_title(commit_attr[:message]) - when "Issue" - subj_attr = HashWithIndifferentAccess.new(data[:issue]) - subject_id = subj_attr[:iid] - subject_desc = "##{subject_id}" - subject_type = "issue" - title = format_title(subj_attr[:title]) - when "MergeRequest" - subj_attr = HashWithIndifferentAccess.new(data[:merge_request]) - subject_id = subj_attr[:iid] - subject_desc = "!#{subject_id}" - subject_type = "merge request" - title = format_title(subj_attr[:title]) - when "Snippet" - subj_attr = HashWithIndifferentAccess.new(data[:snippet]) - subject_id = subj_attr[:id] - subject_desc = "##{subject_id}" - subject_type = "snippet" - title = format_title(subj_attr[:title]) - end - - subject_html = "<a href=\"#{note_url}\">#{subject_type} #{subject_desc}</a>" - message = ["#{user_name} commented on #{subject_html} in #{project_link}: "] - message << title - - message << "<pre>#{markdown(note, ref: commit_id)}</pre>" - message.join - end - - def create_pipeline_message(data) - pipeline_attributes = data[:object_attributes] - pipeline_id = pipeline_attributes[:id] - ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' - ref = pipeline_attributes[:ref] - user_name = (data[:user] && data[:user][:name]) || 'API' - status = pipeline_attributes[:status] - duration = pipeline_attributes[:duration] - - branch_link = "<a href=\"#{project_url}/commits/#{CGI.escape(ref)}\">#{ref}</a>" - pipeline_url = "<a href=\"#{project_url}/pipelines/#{pipeline_id}\">##{pipeline_id}</a>" - - "#{project_link}: Pipeline #{pipeline_url} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status(status)} in #{duration} second(s)" - end - - def message_color(data) - pipeline_status_color(data) || color || 'yellow' - end - - def pipeline_status_color(data) - return unless data && data[:object_kind] == 'pipeline' - - case data[:object_attributes][:status] - when 'success' - 'green' - else - 'red' - end - end - - def project_name - project.full_name.gsub(/\s/, '') - end - - def project_url - project.web_url - end - - def project_link - "<a href=\"#{project_url}\">#{project_name}</a>" - end - - def update?(data) - data[:object_attributes][:action] == 'update' - end - - def humanized_status(status) - case status - when 'success' - 'passed' - else - status - end - end - - def should_pipeline_be_notified?(data) - case data[:object_attributes][:status] - when 'success' - !notify_only_broken_pipelines? - when 'failed' - true - else - false - end - end -end diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb index a15780c14f9..fb76bc89c98 100644 --- a/app/models/project_services/irker_service.rb +++ b/app/models/project_services/irker_service.rb @@ -59,7 +59,7 @@ class IrkerService < Service ' append "?key=secretpassword" to the URI (Note that due to a bug, if you ' \ ' want to use a password, you have to omit the "#" on the channel). If you ' \ ' specify a default IRC URI to prepend before each recipient, you can just ' \ - ' give a channel name.' }, + ' give a channel name.' }, { type: 'checkbox', name: 'colorize_messages' } ] end @@ -112,7 +112,7 @@ class IrkerService < Service end def consider_uri(uri) - return nil if uri.scheme.nil? + return if uri.scheme.nil? # Authorize both irc://domain.com/#chan and irc://domain.com/chan if uri.is_a?(URI) && uri.scheme[/^ircs?\z/] && !uri.path.nil? diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 9066a0b7f1d..f7064d5aaea 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -215,7 +215,7 @@ class JiraService < IssueTrackerService end def add_issue_solved_comment(issue, commit_id, commit_url) - link_title = "GitLab: Solved by commit #{commit_id}." + link_title = "Solved by commit #{commit_id}." comment = "Issue solved with [#{commit_id}|#{commit_url}]." link_props = build_remote_link_props(url: commit_url, title: link_title, resolved: true) send_message(issue, comment, link_props) @@ -230,7 +230,7 @@ class JiraService < IssueTrackerService project_name = data[:project][:name] message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title.chomp}'" - link_title = "GitLab: Mentioned on #{entity_name} - #{entity_title}" + link_title = "#{entity_name.capitalize} - #{entity_title}" link_props = build_remote_link_props(url: entity_url, title: link_title) unless comment_exists?(issue, message) @@ -278,6 +278,7 @@ class JiraService < IssueTrackerService { GlobalID: 'GitLab', + relationship: 'mentioned on', object: { url: url, title: title, diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index b801fd84a07..f69edd60003 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -53,7 +53,7 @@ class KubernetesService < DeploymentService end def description - 'Kubernetes / Openshift integration' + 'Kubernetes / OpenShift integration' end def help diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 60cb2d380d5..c68a9d923c8 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -71,7 +71,7 @@ class PrometheusService < MonitoringService end def prometheus_client - RestClient::Resource.new(api_url, max_redirects: 0) if api_url && manual_configuration? && active? + RestClient::Resource.new(api_url, max_redirects: 0) if should_return_client? end def prometheus_available? @@ -83,6 +83,10 @@ class PrometheusService < MonitoringService private + def should_return_client? + api_url && manual_configuration? && active? && valid? + end + def synchronize_service_state self.active = prometheus_available? || manual_configuration? diff --git a/app/models/project_services/slack_slash_commands_service.rb b/app/models/project_services/slack_slash_commands_service.rb index 6c82e088231..6a454070fe2 100644 --- a/app/models/project_services/slack_slash_commands_service.rb +++ b/app/models/project_services/slack_slash_commands_service.rb @@ -22,6 +22,10 @@ class SlackSlashCommandsService < SlashCommandsService end end + def chat_responder + ::Gitlab::Chat::Responder::Slack + end + private def format(text) diff --git a/app/models/project_services/teamcity_service.rb b/app/models/project_services/teamcity_service.rb index b8e17087db5..3245cd22e73 100644 --- a/app/models/project_services/teamcity_service.rb +++ b/app/models/project_services/teamcity_service.rb @@ -39,9 +39,7 @@ class TeamcityService < CiService end def help - 'The build configuration in Teamcity must use the build format '\ - 'number %build.vcs.number% '\ - 'you will also want to configure monitoring of all branches so merge '\ + 'You will want to configure monitoring of all branches so merge '\ 'requests build, that setting is in the vsc root advanced settings.' end @@ -70,7 +68,7 @@ class TeamcityService < CiService end def calculate_reactive_cache(sha, ref) - response = get_path("httpAuth/app/rest/builds/branch:unspecified:any,number:#{sha}") + response = get_path("httpAuth/app/rest/builds/branch:unspecified:any,revision:#{sha}") { build_page: read_build_page(response), commit_status: read_commit_status(response) } end diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb new file mode 100644 index 00000000000..957be685aea --- /dev/null +++ b/app/models/project_services/youtrack_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class YoutrackService < IssueTrackerService + validates :project_url, :issues_url, presence: true, public_url: true, if: :activated? + + prop_accessor :description, :project_url, :issues_url + + # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1 + def self.reference_pattern(only_long: false) + if only_long + /(?<issue>\b[A-Z][A-Za-z0-9_]*-\d+)/ + else + /(?<issue>\b[A-Z][A-Za-z0-9_]*-\d+)|(#{Issue.reference_prefix}(?<issue>\d+))/ + end + end + + def title + 'YouTrack' + end + + def description + if self.properties && self.properties['description'].present? + self.properties['description'] + else + 'YouTrack issue tracker' + end + end + + def self.to_param + 'youtrack' + end + + def fields + [ + { type: 'text', name: 'description', placeholder: description }, + { type: 'text', name: 'project_url', placeholder: 'Project url', required: true }, + { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true } + ] + end +end diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 33bc6a561f9..aeba2843e5d 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -74,6 +74,14 @@ class ProjectTeam end alias_method :users, :members + # `members` method uses project_authorizations table which + # is updated asynchronously, on project move it still contains + # old members who may not have access to the new location, + # so we filter out only members of project or project's group + def members_in_project_and_ancestors + members.where(id: member_user_ids) + end + def guests @guests ||= fetch_members(Gitlab::Access::GUEST) end @@ -191,4 +199,8 @@ class ProjectTeam def group project.group end + + def member_user_ids + Member.on_project_and_ancestors(project).select(:user_id) + end end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 559e4f99294..c43bd45a62f 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -60,7 +60,7 @@ class ProjectWiki def wiki @wiki ||= begin gl_repository = Gitlab::GlRepository.gl_repository(project, true) - raw_repository = Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', gl_repository) + raw_repository = Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', gl_repository, full_path) create_repo!(raw_repository) unless raw_repository.exists? @@ -175,7 +175,7 @@ class ProjectWiki private def create_repo!(raw_repository) - gitlab_shell.create_repository(project.repository_storage, disk_path) + gitlab_shell.create_wiki_repository(project) raise CouldNotCreateWikiError unless raw_repository.exists? diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb index defbade1ed6..5594594a48d 100644 --- a/app/models/prometheus_metric.rb +++ b/app/models/prometheus_metric.rb @@ -18,6 +18,54 @@ class PrometheusMetric < ActiveRecord::Base system: 2 } + GROUP_DETAILS = { + # built-in groups + nginx_ingress_vts: { + group_title: _('Response metrics (NGINX Ingress VTS)'), + required_metrics: %w(nginx_upstream_responses_total nginx_upstream_response_msecs_avg), + priority: 10 + }.freeze, + nginx_ingress: { + group_title: _('Response metrics (NGINX Ingress)'), + required_metrics: %w(nginx_ingress_controller_requests nginx_ingress_controller_ingress_upstream_latency_seconds_sum), + priority: 10 + }.freeze, + ha_proxy: { + group_title: _('Response metrics (HA Proxy)'), + required_metrics: %w(haproxy_frontend_http_requests_total haproxy_frontend_http_responses_total), + priority: 10 + }.freeze, + aws_elb: { + group_title: _('Response metrics (AWS ELB)'), + required_metrics: %w(aws_elb_request_count_sum aws_elb_latency_average aws_elb_httpcode_backend_5_xx_sum), + priority: 10 + }.freeze, + nginx: { + group_title: _('Response metrics (NGINX)'), + required_metrics: %w(nginx_server_requests nginx_server_requestMsec), + priority: 10 + }.freeze, + kubernetes: { + group_title: _('System metrics (Kubernetes)'), + required_metrics: %w(container_memory_usage_bytes container_cpu_usage_seconds_total), + priority: 5 + }.freeze, + + # custom/user groups + business: { + group_title: _('Business metrics (Custom)'), + priority: 0 + }.freeze, + response: { + group_title: _('Response metrics (Custom)'), + priority: -5 + }.freeze, + system: { + group_title: _('System metrics (Custom)'), + priority: -10 + }.freeze + }.freeze + validates :title, presence: true validates :query, presence: true validates :group, presence: true @@ -29,36 +77,16 @@ class PrometheusMetric < ActiveRecord::Base scope :common, -> { where(common: true) } - GROUP_TITLES = { - # built-in groups - nginx_ingress_vts: _('Response metrics (NGINX Ingress VTS)'), - nginx_ingress: _('Response metrics (NGINX Ingress)'), - ha_proxy: _('Response metrics (HA Proxy)'), - aws_elb: _('Response metrics (AWS ELB)'), - nginx: _('Response metrics (NGINX)'), - kubernetes: _('System metrics (Kubernetes)'), - - # custom/user groups - business: _('Business metrics (Custom)'), - response: _('Response metrics (Custom)'), - system: _('System metrics (Custom)') - }.freeze - - REQUIRED_METRICS = { - nginx_ingress_vts: %w(nginx_upstream_responses_total nginx_upstream_response_msecs_avg), - nginx_ingress: %w(nginx_ingress_controller_requests nginx_ingress_controller_ingress_upstream_latency_seconds_sum), - ha_proxy: %w(haproxy_frontend_http_requests_total haproxy_frontend_http_responses_total), - aws_elb: %w(aws_elb_request_count_sum aws_elb_latency_average aws_elb_httpcode_backend_5_xx_sum), - nginx: %w(nginx_server_requests nginx_server_requestMsec), - kubernetes: %w(container_memory_usage_bytes container_cpu_usage_seconds_total) - }.freeze + def priority + group_details(group).fetch(:priority) + end def group_title - GROUP_TITLES[group.to_sym] + group_details(group).fetch(:group_title) end def required_metrics - REQUIRED_METRICS[group.to_sym].to_a.map(&:to_s) + group_details(group).fetch(:required_metrics, []).map(&:to_s) end def to_query_metric @@ -89,4 +117,10 @@ class PrometheusMetric < ActiveRecord::Base }] end end + + private + + def group_details(group) + GROUP_DETAILS.fetch(group.to_sym) + end end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index d075440b147..597431be65a 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -18,13 +18,23 @@ class ProtectedBranch < ActiveRecord::Base def self.protected?(project, ref_name) return true if project.empty_repo? && default_branch_protected? - refs = project.protected_branches.select(:name) + self.matching(ref_name, protected_refs: protected_refs(project)).present? + end - self.matching(ref_name, protected_refs: refs).present? + def self.any_protected?(project, ref_names) + protected_refs(project).any? do |protected_ref| + ref_names.any? do |ref_name| + protected_ref.matches?(ref_name) + end + end end def self.default_branch_protected? Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_FULL || Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE end + + def self.protected_refs(project) + project.protected_branches.select(:name) + end end diff --git a/app/models/release.rb b/app/models/release.rb index 7a09ee459a6..0dae5c90394 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -2,11 +2,53 @@ class Release < ActiveRecord::Base include CacheMarkdownField + include Gitlab::Utils::StrongMemoize cache_markdown_field :description belongs_to :project + # releases prior to 11.7 have no author belongs_to :author, class_name: 'User' + has_many :links, class_name: 'Releases::Link' + + accepts_nested_attributes_for :links, allow_destroy: true + validates :description, :project, :tag, presence: true + + scope :sorted, -> { order(created_at: :desc) } + + delegate :repository, to: :project + + def commit + strong_memoize(:commit) do + repository.commit(actual_sha) + end + end + + def tag_missing? + actual_tag.nil? + end + + def assets_count + links.count + sources.count + end + + def sources + strong_memoize(:sources) do + Releases::Source.all(project, tag) + end + end + + private + + def actual_sha + sha || actual_tag&.dereferenced_target + end + + def actual_tag + strong_memoize(:actual_tag) do + repository.find_tag(tag) + end + end end diff --git a/app/models/releases/link.rb b/app/models/releases/link.rb new file mode 100644 index 00000000000..6c507c47752 --- /dev/null +++ b/app/models/releases/link.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Releases + class Link < ActiveRecord::Base + self.table_name = 'release_links' + + belongs_to :release + + validates :url, presence: true, url: { protocols: %w(http https ftp) }, uniqueness: { scope: :release } + validates :name, presence: true, uniqueness: { scope: :release } + + scope :sorted, -> { order(created_at: :desc) } + + def internal? + url.start_with?(release.project.web_url) + end + + def external? + !internal? + end + end +end diff --git a/app/models/releases/source.rb b/app/models/releases/source.rb new file mode 100644 index 00000000000..4d3d54457af --- /dev/null +++ b/app/models/releases/source.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Releases + class Source + include ActiveModel::Model + + attr_accessor :project, :tag_name, :format + + FORMATS = %w(zip tar.gz tar.bz2 tar).freeze + + class << self + def all(project, tag_name) + Releases::Source::FORMATS.map do |format| + Releases::Source.new(project: project, + tag_name: tag_name, + format: format) + end + end + end + + def url + Gitlab::Routing + .url_helpers + .project_archive_url(project, + id: File.join(tag_name, archive_prefix), + format: format) + end + + private + + def archive_prefix + "#{project.path}-#{tag_name.tr('/', '-')}" + end + end +end diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index 5a6895aefab..5eba7ddd75c 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -17,7 +17,7 @@ class RemoteMirror < ActiveRecord::Base belongs_to :project, inverse_of: :remote_mirrors - validates :url, presence: true, url: { protocols: %w(ssh git http https), allow_blank: true, enforce_user: true } + validates :url, presence: true, public_url: { protocols: %w(ssh git http https), allow_blank: true, enforce_user: true } before_save :set_new_remote_name, if: :mirror_url_changed? @@ -61,7 +61,10 @@ class RemoteMirror < ActiveRecord::Base timestamp = Time.now remote_mirror.update!( - last_update_at: timestamp, last_successful_update_at: timestamp, last_error: nil + last_update_at: timestamp, + last_successful_update_at: timestamp, + last_error: nil, + error_notification_sent: false ) end @@ -179,6 +182,10 @@ class RemoteMirror < ActiveRecord::Base project.repository.add_remote(remote_name, remote_url) end + def after_sent_notification + update_column(:error_notification_sent, true) + end + private def store_credentials @@ -221,7 +228,8 @@ class RemoteMirror < ActiveRecord::Base last_error: nil, last_update_at: nil, last_successful_update_at: nil, - update_status: 'finished' + update_status: 'finished', + error_notification_sent: false ) end diff --git a/app/models/repository.rb b/app/models/repository.rb index 015a179f374..851175a61b7 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -25,6 +25,7 @@ class Repository delegate :bundle_to_disk, to: :raw_repository CreateTreeError = Class.new(StandardError) + AmbiguousRefError = Class.new(StandardError) # Methods that cache data from the Git repository. # @@ -78,7 +79,7 @@ class Repository end def raw_repository - return nil unless full_path + return unless full_path @raw_repository ||= initialize_raw_repository end @@ -102,7 +103,7 @@ class Repository end def commit(ref = nil) - return nil unless exists? + return unless exists? return ref if ref.is_a?(::Commit) find_commit(ref || root_ref) @@ -181,6 +182,18 @@ class Repository tags.find { |tag| tag.name == name } end + def ambiguous_ref?(ref) + tag_exists?(ref) && branch_exists?(ref) + end + + def expand_ref(ref) + if tag_exists?(ref) + Gitlab::Git::TAG_REF_PREFIX + ref + elsif branch_exists?(ref) + Gitlab::Git::BRANCH_REF_PREFIX + ref + end + end + def add_branch(user, branch_name, ref) branch = raw_repository.add_branch(branch_name, user: user, target: ref) @@ -275,13 +288,16 @@ class Repository # Rugged seems to throw a `ReferenceError` when given branch_names rather # than SHA-1 hashes number_commits_behind, number_commits_ahead = - raw_repository.count_commits_between( + raw_repository.diverging_commit_count( @root_ref_hash, branch.dereferenced_target.sha, - left_right: true, max_count: MAX_DIVERGING_COUNT) - { behind: number_commits_behind, ahead: number_commits_ahead } + if number_commits_behind + number_commits_ahead >= MAX_DIVERGING_COUNT + { distance: MAX_DIVERGING_COUNT } + else + { behind: number_commits_behind, ahead: number_commits_ahead } + end end end @@ -512,6 +528,8 @@ class Repository # items is an Array like: [[oid, path], [oid1, path1]] def blobs_at(items) + return [] unless exists? + raw_repository.batch_blobs(items).map { |blob| Blob.decorate(blob, project) } end @@ -599,7 +617,6 @@ class Repository return unless readme context = { project: project } - context[:markdown_engine] = :redcarpet unless MarkupHelper.commonmark_for_repositories_enabled? MarkupHelper.markup_unsafe(readme.name, readme.data, context) end @@ -837,6 +854,12 @@ class Repository end end + def merge_to_ref(user, source_sha, merge_request, target_ref, message) + branch = merge_request.target_branch + + raw.merge_to_ref(user, source_sha, branch, target_ref, message) + end + def ff_merge(user, source, target_branch, merge_request: nil) their_commit_id = commit(source)&.id raise 'Invalid merge source' if their_commit_id.nil? @@ -1016,12 +1039,12 @@ class Repository remote_branch: merge_request.target_branch) end - def squash(user, merge_request) + def squash(user, merge_request, message) raw.squash(user, merge_request.id, branch: merge_request.target_branch, start_sha: merge_request.diff_start_sha, end_sha: merge_request.diff_head_sha, author: merge_request.author, - message: merge_request.title) + message: message) end def update_submodule(user, submodule, commit_sha, message:, branch:) @@ -1059,19 +1082,11 @@ class Repository end def cache - @cache ||= if is_wiki - Gitlab::RepositoryCache.new(self, extra_namespace: 'wiki') - else - Gitlab::RepositoryCache.new(self) - end + @cache ||= Gitlab::RepositoryCache.new(self) end def request_store_cache - @request_store_cache ||= if is_wiki - Gitlab::RepositoryCache.new(self, extra_namespace: 'wiki', backend: Gitlab::SafeRequestStore) - else - Gitlab::RepositoryCache.new(self, backend: Gitlab::SafeRequestStore) - end + @request_store_cache ||= Gitlab::RepositoryCache.new(self, backend: Gitlab::SafeRequestStore) end def tags_sorted_by_committed_date @@ -1098,6 +1113,9 @@ class Repository end def initialize_raw_repository - Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', Gitlab::GlRepository.gl_repository(project, is_wiki)) + Gitlab::Git::Repository.new(project.repository_storage, + disk_path + '.git', + Gitlab::GlRepository.gl_repository(project, is_wiki), + project.full_path) end end diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index e65b3df0fb6..6caab24143b 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -48,7 +48,7 @@ class SentNotification < ActiveRecord::Base end def record_note(note, recipient_id, reply_key = self.reply_key, attrs = {}) - attrs[:in_reply_to_discussion_id] = note.discussion_id + attrs[:in_reply_to_discussion_id] = note.discussion_id if note.part_of_discussion? record(note.noteable, recipient_id, reply_key, attrs) end @@ -99,29 +99,12 @@ class SentNotification < ActiveRecord::Base private def reply_params - attrs = { + { noteable_type: self.noteable_type, noteable_id: self.noteable_id, - commit_id: self.commit_id + commit_id: self.commit_id, + in_reply_to_discussion_id: self.in_reply_to_discussion_id } - - if self.in_reply_to_discussion_id.present? - attrs[:in_reply_to_discussion_id] = self.in_reply_to_discussion_id - else - # Remove in GitLab 10.0, when we will not support replying to SentNotifications - # that don't have `in_reply_to_discussion_id` anymore. - attrs.merge!( - type: self.note_type, - - # LegacyDiffNote - line_code: self.line_code, - - # DiffNote - position: self.position.to_json - ) - end - - attrs end def note_valid diff --git a/app/models/service.rb b/app/models/service.rb index 9dcb0aab0a3..da523bfa426 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -255,7 +255,6 @@ class Service < ActiveRecord::Base external_wiki flowdock hangouts_chat - hipchat irker jira kubernetes @@ -267,6 +266,7 @@ class Service < ActiveRecord::Base prometheus pushover redmine + youtrack slack_slash_commands slack teamcity diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 11856b55902..f23ddd64fe3 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -50,11 +50,11 @@ class Snippet < ActiveRecord::Base validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values } # Scopes - scope :are_internal, -> { where(visibility_level: Snippet::INTERNAL) } + scope :are_internal, -> { where(visibility_level: Snippet::INTERNAL) } scope :are_private, -> { where(visibility_level: Snippet::PRIVATE) } scope :are_public, -> { where(visibility_level: Snippet::PUBLIC) } scope :public_and_internal, -> { where(visibility_level: [Snippet::PUBLIC, Snippet::INTERNAL]) } - scope :fresh, -> { order("created_at DESC") } + scope :fresh, -> { order("created_at DESC") } scope :inc_relations_for_view, -> { includes(author: :status) } participant :author @@ -175,6 +175,12 @@ class Snippet < ActiveRecord::Base :visibility_level end + def embeddable? + ability = project_id? ? :read_project_snippet : :read_personal_snippet + + Ability.allowed?(nil, ability, self) + end + def notes_with_associations notes.includes(:author) end diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb index b6844dbe870..b6fb39ee81f 100644 --- a/app/models/ssh_host_key.rb +++ b/app/models/ssh_host_key.rb @@ -26,7 +26,8 @@ class SshHostKey self.reactive_cache_lifetime = 10.minutes def self.find_by(opts = {}) - return nil unless opts.key?(:id) + opts = HashWithIndifferentAccess.new(opts) + return unless opts.key?(:id) project_id, url = opts[:id].split(':', 2) project = Project.find_by(id: project_id) @@ -52,6 +53,11 @@ class SshHostKey @compare_host_keys = compare_host_keys end + # Needed for reactive caching + def self.primary_key + :id + end + def id [project.id, url].join(':') end diff --git a/app/models/storage/hashed_project.rb b/app/models/storage/hashed_project.rb index 911fb7e9ce9..f5d0d6fab3b 100644 --- a/app/models/storage/hashed_project.rb +++ b/app/models/storage/hashed_project.rb @@ -31,7 +31,7 @@ module Storage gitlab_shell.add_namespace(repository_storage, base_dir) end - def rename_repo + def rename_repo(old_full_path: nil, new_full_path: nil) true end diff --git a/app/models/storage/legacy_project.rb b/app/models/storage/legacy_project.rb index 9f6f19acb41..76ac5c13c18 100644 --- a/app/models/storage/legacy_project.rb +++ b/app/models/storage/legacy_project.rb @@ -29,18 +29,19 @@ module Storage gitlab_shell.add_namespace(repository_storage, base_dir) end - def rename_repo - new_full_path = project.build_full_path + def rename_repo(old_full_path: nil, new_full_path: nil) + old_full_path ||= project.full_path_was + new_full_path ||= project.build_full_path - if gitlab_shell.mv_repository(repository_storage, project.full_path_was, new_full_path) + if gitlab_shell.mv_repository(repository_storage, old_full_path, new_full_path) # If repository moved successfully we need to send update instructions to users. # However we cannot allow rollback since we moved repository # So we basically we mute exceptions in next actions begin - gitlab_shell.mv_repository(repository_storage, "#{project.full_path_was}.wiki", "#{new_full_path}.wiki") + gitlab_shell.mv_repository(repository_storage, "#{old_full_path}.wiki", "#{new_full_path}.wiki") return true rescue => e - Rails.logger.error "Exception renaming #{project.full_path_was} -> #{new_full_path}: #{e}" + Rails.logger.error "Exception renaming #{old_full_path} -> #{new_full_path}: #{e}" # Returning false does not rollback after_* transaction but gives # us information about failing some of tasks return false diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb index c76b8e71507..09034646bff 100644 --- a/app/models/suggestion.rb +++ b/app/models/suggestion.rb @@ -5,8 +5,7 @@ class Suggestion < ApplicationRecord validates :note, presence: true validates :commit_id, presence: true, if: :applied? - delegate :original_position, :position, :diff_file, - :noteable, to: :note + delegate :original_position, :position, :noteable, to: :note def project noteable.source_project @@ -16,6 +15,10 @@ class Suggestion < ApplicationRecord noteable.source_branch end + def file_path + position.file_path + end + # For now, suggestions only serve as a way to send patches that # will change a single line (being able to apply multiple in the same place), # which explains `from_line` and `to_line` being the same line. diff --git a/app/models/todo.rb b/app/models/todo.rb index 7b64615f699..d9b86d941b6 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -4,6 +4,11 @@ class Todo < ActiveRecord::Base include Sortable include FromUnion + # Time to wait for todos being removed when not visible for user anymore. + # Prevents TODOs being removed by mistake, for example, removing access from a user + # and giving it back again. + WAIT_FOR_DELETE = 1.hour + ASSIGNED = 1 MENTIONED = 2 BUILD_FAILED = 3 diff --git a/app/models/user.rb b/app/models/user.rb index 26fd2d903a1..778c9e631bd 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,7 +2,7 @@ require 'carrierwave/orm/activerecord' -class User < ActiveRecord::Base +class User < ApplicationRecord extend Gitlab::ConfigHelper include Gitlab::ConfigHelper @@ -145,7 +145,7 @@ class User < ActiveRecord::Base has_many :issue_assignees has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue - has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # rubocop:disable Cop/ActiveRecordDependent + has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # rubocop:disable Cop/ActiveRecordDependent has_many :custom_attributes, class_name: 'UserCustomAttribute' has_many :callouts, class_name: 'UserCallout' @@ -228,6 +228,9 @@ class User < ActiveRecord::Base delegate :path, to: :namespace, allow_nil: true, prefix: true delegate :notes_filter_for, to: :user_preference delegate :set_notes_filter, to: :user_preference + delegate :first_day_of_week, :first_day_of_week=, to: :user_preference + + accepts_nested_attributes_for :user_preference, update_only: true state_machine :state, initial: :active do event :block do @@ -267,9 +270,12 @@ class User < ActiveRecord::Base scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) } scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) } scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) } + 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 :confirmed, -> { where.not(confirmed_at: nil) } scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) } scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) } + scope :with_emails, -> { preload(:emails) } # Limits the users to those that have TODOs, optionally in the given state. # @@ -337,6 +343,8 @@ class User < ActiveRecord::Base case order_method.to_s when 'recent_sign_in' then order_recent_sign_in when 'oldest_sign_in' then order_oldest_sign_in + when 'last_activity_on_desc' then order_recent_last_activity + when 'last_activity_on_asc' then order_oldest_last_activity else order_by(order_method) end @@ -380,7 +388,7 @@ class User < ActiveRecord::Base find_by(id: user_id) end - def filter(filter_name) + def filter_items(filter_name) case filter_name when 'admins' admins @@ -462,7 +470,7 @@ class User < ActiveRecord::Base end def by_login(login) - return nil unless login + return unless login if login.include?('@'.freeze) unscoped.iwhere(email: login).take @@ -754,8 +762,12 @@ class User < ActiveRecord::Base # # Example use: # `Project.where('EXISTS(?)', user.authorizations_for_projects)` - def authorizations_for_projects - project_authorizations.select(1).where('project_authorizations.project_id = projects.id') + def authorizations_for_projects(min_access_level: nil) + authorizations = project_authorizations.select(1).where('project_authorizations.project_id = projects.id') + + return authorizations unless min_access_level.present? + + authorizations.where('project_authorizations.access_level >= ?', min_access_level) end # Returns the projects this user has reporter (or greater) access to, limited @@ -1156,6 +1168,10 @@ class User < ActiveRecord::Base Gitlab::ObjectHierarchy.new(owned_or_maintainers_groups).base_and_descendants end + def manageable_groups_with_routes + manageable_groups.eager_load(:route).order('routes.path') + end + def namespaces namespace_ids = groups.pluck(:id) namespace_ids.push(namespace.id) diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index b1d6d461928..64daba81dcf 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -132,7 +132,7 @@ class WikiPage # The GitLab Commit instance for this page. def version - return nil unless persisted? + return unless persisted? @version ||= @page.version end diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 0d0f1c28bad..72de04203a6 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -7,6 +7,10 @@ class BasePolicy < DeclarativePolicy::Base with_options scope: :user, score: 0 condition(:admin) { @user&.admin? } + desc "User has access to all private groups & projects" + with_options scope: :user, score: 0 + condition(:full_private_access) { @user&.full_private_access? } + with_options scope: :user, score: 0 condition(:external_user) { @user.nil? || @user.external? } diff --git a/app/policies/board_policy.rb b/app/policies/board_policy.rb new file mode 100644 index 00000000000..4bf1e7bd3e1 --- /dev/null +++ b/app/policies/board_policy.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class BoardPolicy < BasePolicy + delegate { @subject.parent } + + condition(:is_group_board) { @subject.group_board? } + condition(:is_project_board) { @subject.project_board? } + + rule { is_project_board & can?(:read_project) }.enable :read_parent + + rule { is_group_board & can?(:read_group) }.policy do + enable :read_parent + enable :read_milestone + enable :read_issue + end +end diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb index e42d78f47c5..2c90b8a73cd 100644 --- a/app/policies/ci/pipeline_policy.rb +++ b/app/policies/ci/pipeline_policy.rb @@ -10,6 +10,15 @@ module Ci @subject.project.branch_allows_collaboration?(@user, @subject.ref) end + condition(:external_pipeline, scope: :subject, score: 0) do + @subject.external? + end + + # Disallow users without permissions from accessing internal pipelines + rule { ~can?(:read_build) & ~external_pipeline }.policy do + prevent :read_pipeline + end + rule { protected_ref }.prevent :update_pipeline rule { can?(:public_access) & branch_allows_collaboration }.policy do diff --git a/app/policies/concerns/clusterable_actions.rb b/app/policies/concerns/clusterable_actions.rb new file mode 100644 index 00000000000..08ddd742ea9 --- /dev/null +++ b/app/policies/concerns/clusterable_actions.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module ClusterableActions + private + + # Overridden on EE module + def multiple_clusters_available? + false + end + + def clusterable_has_clusters? + !subject.clusters.empty? + end +end diff --git a/app/policies/container_repository_policy.rb b/app/policies/container_repository_policy.rb new file mode 100644 index 00000000000..6781c845142 --- /dev/null +++ b/app/policies/container_repository_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ContainerRepositoryPolicy < BasePolicy + delegate { @subject.project } +end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 16c58730878..d412a591fdc 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -68,6 +68,10 @@ class GlobalPolicy < BasePolicy enable :read_users_list end + rule { ~anonymous }.policy do + enable :read_instance_metadata + end + rule { admin }.policy do enable :read_custom_attribute enable :update_custom_attribute diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index f07bb188265..e74e5f008d7 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class GroupPolicy < BasePolicy + include ClusterableActions + desc "Group is public" with_options scope: :subject, score: 0 condition(:public_group) { @subject.public? } @@ -27,6 +29,9 @@ class GroupPolicy < BasePolicy GroupProjectsFinder.new(group: @subject, current_user: @user, options: { include_subgroups: true }).execute.any? end + condition(:has_clusters, scope: :subject) { clusterable_has_clusters? } + condition(:can_have_multiple_clusters) { multiple_clusters_available? } + with_options scope: :subject, score: 0 condition(:request_access_enabled) { @subject.request_access_enabled } @@ -45,10 +50,10 @@ class GroupPolicy < BasePolicy enable :read_label end - rule { admin } .enable :read_group + rule { admin }.enable :read_group rule { has_projects }.policy do - enable :read_group + enable :read_list enable :read_label end @@ -67,6 +72,7 @@ class GroupPolicy < BasePolicy enable :admin_pipeline enable :admin_build enable :read_cluster + enable :add_cluster enable :create_cluster enable :update_cluster enable :admin_cluster @@ -106,6 +112,8 @@ class GroupPolicy < BasePolicy rule { owner & (~share_with_group_locked | ~has_parent | ~parent_share_with_group_locked | can_change_parent_share_with_group_lock) }.enable :change_share_with_group_lock + rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster + def access_level return GroupMember::NO_ACCESS if @user.nil? diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb index 6d8b575102e..ecb2797d1d9 100644 --- a/app/policies/issuable_policy.rb +++ b/app/policies/issuable_policy.rb @@ -11,7 +11,7 @@ class IssuablePolicy < BasePolicy @user && @subject.assignee_or_author?(@user) end - rule { assignee_or_author }.policy do + rule { can?(:guest_access) & assignee_or_author }.policy do enable :read_issue enable :update_issue enable :reopen_issue diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index a0706eaa46c..dd8c5d49cf4 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -18,6 +18,7 @@ class IssuePolicy < IssuablePolicy prevent :read_issue_iid prevent :update_issue prevent :admin_issue + prevent :create_note end rule { locked }.policy do diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb index f22843b6463..8d23e3abed3 100644 --- a/app/policies/note_policy.rb +++ b/app/policies/note_policy.rb @@ -18,6 +18,7 @@ class NotePolicy < BasePolicy prevent :read_note prevent :admin_note prevent :resolve_note + prevent :award_emoji end rule { is_author }.policy do diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb index 777f933cdcd..2b5cca76c20 100644 --- a/app/policies/personal_snippet_policy.rb +++ b/app/policies/personal_snippet_policy.rb @@ -28,5 +28,10 @@ class PersonalSnippetPolicy < BasePolicy rule { anonymous }.prevent :comment_personal_snippet - rule { can?(:comment_personal_snippet) }.enable :award_emoji + rule { can?(:comment_personal_snippet) }.policy do + enable :create_note + enable :award_emoji + end + + rule { full_private_access }.enable :read_personal_snippet end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 1c082945299..cf257ed47c8 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -2,6 +2,7 @@ class ProjectPolicy < BasePolicy extend ClassMethods + include ClusterableActions READONLY_FEATURES_WHEN_ARCHIVED = %i[ issue @@ -22,6 +23,7 @@ class ProjectPolicy < BasePolicy container_image pages cluster + release ].freeze desc "User is a project owner" @@ -103,6 +105,13 @@ class ProjectPolicy < BasePolicy @subject.feature_available?(:merge_requests, @user) end + condition(:has_clusters, scope: :subject) { clusterable_has_clusters? } + condition(:can_have_multiple_clusters) { multiple_clusters_available? } + + condition(:internal_builds_disabled) do + !@subject.builds_enabled? + end + features = %w[ merge_requests issues @@ -143,7 +152,6 @@ class ProjectPolicy < BasePolicy enable :remove_fork_project enable :destroy_merge_request enable :destroy_issue - enable :remove_pages enable :set_issue_iid enable :set_issue_created_at @@ -169,6 +177,7 @@ class ProjectPolicy < BasePolicy enable :read_cycle_analytics enable :award_emoji enable :read_pages_content + enable :read_release end # These abilities are not allowed to admins that are not members of the project, @@ -190,10 +199,10 @@ class ProjectPolicy < BasePolicy enable :read_build enable :read_container_image enable :read_pipeline - enable :read_pipeline_schedule enable :read_environment enable :read_deployment enable :read_merge_request + enable :read_sentry_issue end # We define `:public_user_access` separately because there are cases in gitlab-ee @@ -216,6 +225,8 @@ class ProjectPolicy < BasePolicy rule { owner | admin | guest | group_member }.prevent :request_access rule { ~request_access_enabled }.prevent :request_access + rule { can?(:developer_access) & can?(:create_issue) }.enable :import_issues + rule { can?(:developer_access) }.policy do enable :admin_merge_request enable :admin_milestone @@ -226,6 +237,7 @@ class ProjectPolicy < BasePolicy enable :update_build enable :create_pipeline enable :update_pipeline + enable :read_pipeline_schedule enable :create_pipeline_schedule enable :create_merge_request_from enable :create_wiki @@ -235,6 +247,8 @@ class ProjectPolicy < BasePolicy enable :update_container_image enable :create_environment enable :create_deployment + enable :create_release + enable :update_release end rule { can?(:maintainer_access) }.policy do @@ -256,11 +270,16 @@ class ProjectPolicy < BasePolicy enable :admin_pages enable :read_pages enable :update_pages + enable :remove_pages enable :read_cluster + enable :add_cluster enable :create_cluster enable :update_cluster enable :admin_cluster enable :create_environment_terminal + enable :destroy_release + enable :destroy_artifacts + enable :daily_statistics end rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror @@ -282,6 +301,8 @@ class ProjectPolicy < BasePolicy rule { issues_disabled }.policy do prevent(*create_read_update_admin_destroy(:issue)) + prevent(*create_read_update_admin_destroy(:board)) + prevent(*create_read_update_admin_destroy(:list)) end rule { merge_requests_disabled | repository_disabled }.policy do @@ -301,13 +322,12 @@ class ProjectPolicy < BasePolicy prevent(*create_read_update_admin_destroy(:project_snippet)) end - rule { wiki_disabled & ~has_external_wiki }.policy do + rule { wiki_disabled }.policy do prevent(*create_read_update_admin_destroy(:wiki)) prevent(:download_wiki_code) end rule { builds_disabled | repository_disabled }.policy do - prevent(*create_update_admin_destroy(:pipeline)) prevent(*create_read_update_admin_destroy(:build)) prevent(*create_read_update_admin_destroy(:pipeline_schedule)) prevent(*create_read_update_admin_destroy(:environment)) @@ -315,11 +335,23 @@ class ProjectPolicy < BasePolicy prevent(*create_read_update_admin_destroy(:deployment)) end + # There's two separate cases when builds_disabled is true: + # 1. When internal CI is disabled - builds_disabled && internal_builds_disabled + # - We do not prevent the user from accessing Pipelines to allow him to access external CI + # 2. When the user is not allowed to access CI - builds_disabled && ~internal_builds_disabled + # - We prevent the user from accessing Pipelines + rule { (builds_disabled & ~internal_builds_disabled) | repository_disabled }.policy do + prevent(*create_read_update_admin_destroy(:pipeline)) + prevent(*create_read_update_admin_destroy(:commit_status)) + end + rule { repository_disabled }.policy do prevent :push_code prevent :download_code prevent :fork_project prevent :read_commit_status + prevent :read_pipeline + prevent(*create_read_update_admin_destroy(:release)) end rule { container_registry_disabled }.policy do @@ -345,10 +377,10 @@ class ProjectPolicy < BasePolicy enable :read_merge_request enable :read_note enable :read_pipeline - enable :read_pipeline_schedule enable :read_commit_status enable :read_container_image enable :download_code + enable :read_release enable :download_wiki_code enable :read_cycle_analytics enable :read_pages_content @@ -363,7 +395,6 @@ class ProjectPolicy < BasePolicy rule { public_builds & can?(:guest_access) }.policy do enable :read_pipeline - enable :read_pipeline_schedule end # These rules are included to allow maintainers of projects to push to certain @@ -378,9 +409,11 @@ class ProjectPolicy < BasePolicy end.enable :read_issue_iid rule do - (can?(:read_project_for_iids) & merge_requests_visible_to_user) | can?(:read_merge_request) + (~guest & can?(:read_project_for_iids) & merge_requests_visible_to_user) | can?(:read_merge_request) end.enable :read_merge_request_iid + rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster + private def team_member? @@ -433,7 +466,7 @@ class ProjectPolicy < BasePolicy when ProjectFeature::DISABLED false when ProjectFeature::PRIVATE - guest? || admin? + admin? || team_access_level >= ProjectFeature.required_minimum_access_level(feature) else true end diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb index 288bf070cfc..e5e005cee6d 100644 --- a/app/policies/project_snippet_policy.rb +++ b/app/policies/project_snippet_policy.rb @@ -5,13 +5,12 @@ class ProjectSnippetPolicy < BasePolicy desc "Snippet is public" condition(:public_snippet, scope: :subject) { @subject.public? } + condition(:internal_snippet, scope: :subject) { @subject.internal? } condition(:private_snippet, scope: :subject) { @subject.private? } condition(:public_project, scope: :subject) { @subject.project.public? } condition(:is_author) { @user && @subject.author == @user } - condition(:internal, scope: :subject) { @subject.internal? } - # We have to check both project feature visibility and a snippet visibility and take the stricter one # This will be simplified - check https://gitlab.com/gitlab-org/gitlab-ce/issues/27573 rule { ~can?(:read_project) }.policy do @@ -26,13 +25,13 @@ class ProjectSnippetPolicy < BasePolicy # is used to hide/show various snippet-related controls, so we can't just move # all of the handling here. rule do - all?(private_snippet | (internal & external_user), + all?(private_snippet | (internal_snippet & external_user), ~project.guest, - ~admin, - ~is_author) + ~is_author, + ~full_private_access) end.prevent :read_project_snippet - rule { internal & ~is_author & ~admin }.policy do + rule { internal_snippet & ~is_author & ~admin }.policy do prevent :update_project_snippet prevent :admin_project_snippet end @@ -44,4 +43,6 @@ class ProjectSnippetPolicy < BasePolicy enable :update_project_snippet enable :admin_project_snippet end + + rule { ~can?(:read_project_snippet) }.prevent :create_note end diff --git a/app/policies/release_policy.rb b/app/policies/release_policy.rb new file mode 100644 index 00000000000..d7f9e5d7445 --- /dev/null +++ b/app/policies/release_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ReleasePolicy < BasePolicy + delegate { @subject.project } +end diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index 300f85e1e9d..29656b17183 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -2,6 +2,11 @@ module Ci class BuildRunnerPresenter < SimpleDelegator + include Gitlab::Utils::StrongMemoize + + RUNNER_REMOTE_TAG_PREFIX = 'refs/tags/'.freeze + RUNNER_REMOTE_BRANCH_PREFIX = 'refs/remotes/origin/'.freeze + def artifacts return unless options[:artifacts] @@ -11,6 +16,35 @@ module Ci list.flatten.compact end + def ref_type + if tag + 'tag' + else + 'branch' + end + end + + def git_depth + strong_memoize(:git_depth) do + git_depth = variables&.find { |variable| variable[:key] == 'GIT_DEPTH' }&.dig(:value) + git_depth.to_i + end + end + + def refspecs + specs = [] + + if git_depth > 0 + specs << refspec_for_branch(ref) if branch? || merge_request_event? + specs << refspec_for_tag(ref) if tag? + else + specs << refspec_for_branch + specs << refspec_for_tag + end + + specs + end + private def create_archive(artifacts) @@ -41,5 +75,13 @@ module Ci } end end + + def refspec_for_branch(ref = '*') + "+#{Gitlab::Git::BRANCH_REF_PREFIX}#{ref}:#{RUNNER_REMOTE_BRANCH_PREFIX}#{ref}" + end + + def refspec_for_tag(ref = '*') + "+#{Gitlab::Git::TAG_REF_PREFIX}#{ref}:#{RUNNER_REMOTE_TAG_PREFIX}#{ref}" + end end end diff --git a/app/presenters/ci/trigger_presenter.rb b/app/presenters/ci/trigger_presenter.rb new file mode 100644 index 00000000000..605c8f328a4 --- /dev/null +++ b/app/presenters/ci/trigger_presenter.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Ci + class TriggerPresenter < Gitlab::View::Presenter::Delegated + presents :trigger + + def has_token_exposed? + can?(current_user, :admin_trigger, trigger) + end + + def token + if has_token_exposed? + trigger.token + else + trigger.short_token + end + end + end +end diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb index 9cc137aa3bd..d94d9118eee 100644 --- a/app/presenters/clusterable_presenter.rb +++ b/app/presenters/clusterable_presenter.rb @@ -12,6 +12,10 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated .fabricate! end + def can_add_cluster? + can?(current_user, :add_cluster, clusterable) + end + def can_create_cluster? can?(current_user, :create_cluster, clusterable) end diff --git a/app/presenters/commit_presenter.rb b/app/presenters/commit_presenter.rb new file mode 100644 index 00000000000..05adbe1d4f5 --- /dev/null +++ b/app/presenters/commit_presenter.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CommitPresenter < Gitlab::View::Presenter::Simple + presents :commit + + def status_for(ref) + can?(current_user, :read_commit_status, commit.project) && commit.status(ref) + end + + def any_pipelines? + can?(current_user, :read_pipeline, commit.project) && commit.pipelines.any? + end +end diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index 1db6c9eff36..af164858408 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -98,6 +98,12 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end end + def target_branch_path + if target_branch_exists? + project_branch_path(project, target_branch) + end + end + def source_branch_path if source_branch_exists? project_branch_path(source_project, source_branch) @@ -170,6 +176,10 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated source_branch_exists? && merge_request.can_remove_source_branch?(current_user) end + def can_read_pipeline? + pipeline && can?(current_user, :read_pipeline, pipeline) + end + def mergeable_discussions_state # This avoids calling MergeRequest#mergeable_discussions_state without # considering the state of the MR first. If a MR isn't mergeable, we can @@ -189,6 +199,10 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated merge_request.subscribed?(current_user, merge_request.target_project) end + def conflicts_docs_path + help_page_path('user/project/merge_requests/resolve_conflicts.md') + end + private def cached_can_be_reverted? diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 9bd64ea217e..000b7c433a2 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -13,7 +13,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated presents :project AnchorData = Struct.new(:is_link, :label, :link, :class_modifier, :icon) - MAX_TAGS_TO_SHOW = 3 + MAX_TOPICS_TO_SHOW = 3 def statistic_icon(icon_name = 'plus-square-o') sprite_icon(icon_name, size: 16, css_class: 'icon append-right-4') @@ -256,7 +256,8 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated elsif repository.contribution_guide.present? AnchorData.new(false, statistic_icon('doc-text') + _('CONTRIBUTING'), - contribution_guide_path) + contribution_guide_path, + 'default') end end @@ -310,20 +311,24 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end end - def tags_to_show - project.tag_list.take(MAX_TAGS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord + def topics_to_show + project.tag_list.take(MAX_TOPICS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord end - def count_of_extra_tags_not_shown - if project.tag_list.count > MAX_TAGS_TO_SHOW - project.tag_list.count - MAX_TAGS_TO_SHOW + def topics_not_shown + project.tag_list - topics_to_show + end + + def count_of_extra_topics_not_shown + if project.tag_list.count > MAX_TOPICS_TO_SHOW + project.tag_list.count - MAX_TOPICS_TO_SHOW else 0 end end - def has_extra_tags? - count_of_extra_tags_not_shown > 0 + def has_extra_topics? + count_of_extra_topics_not_shown > 0 end private diff --git a/app/serializers/acts_as_taggable_on/tag_entity.rb b/app/serializers/acts_as_taggable_on/tag_entity.rb new file mode 100644 index 00000000000..d4e4b69f8fa --- /dev/null +++ b/app/serializers/acts_as_taggable_on/tag_entity.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class ActsAsTaggableOn::TagEntity < Grape::Entity + expose :id + expose :name +end diff --git a/app/serializers/acts_as_taggable_on/tag_serializer.rb b/app/serializers/acts_as_taggable_on/tag_serializer.rb new file mode 100644 index 00000000000..87f53606aa1 --- /dev/null +++ b/app/serializers/acts_as_taggable_on/tag_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ActsAsTaggableOn::TagSerializer < BaseSerializer + entity ActsAsTaggableOn::TagEntity +end diff --git a/app/serializers/base_serializer.rb b/app/serializers/base_serializer.rb index 7b65bd22f54..4744a7c1cc8 100644 --- a/app/serializers/base_serializer.rb +++ b/app/serializers/base_serializer.rb @@ -16,11 +16,11 @@ class BaseSerializer .as_json end - def self.entity(entity_class) - @entity_class ||= entity_class - end + class << self + attr_reader :entity_class - def self.entity_class - @entity_class + def entity(entity_class) + @entity_class ||= entity_class + end end end diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb index 7b1a0be75ca..02df1480828 100644 --- a/app/serializers/cluster_application_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -4,7 +4,9 @@ class ClusterApplicationEntity < Grape::Entity expose :name expose :status_name, as: :status expose :status_reason + expose :version expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) } expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) } expose :email, if: -> (e, _) { e.respond_to?(:email) } + expose :update_available?, as: :update_available, if: -> (e, _) { e.respond_to?(:update_available?) } end diff --git a/app/serializers/concerns/user_status_tooltip.rb b/app/serializers/concerns/user_status_tooltip.rb index aa6e67e3351..633b117d392 100644 --- a/app/serializers/concerns/user_status_tooltip.rb +++ b/app/serializers/concerns/user_status_tooltip.rb @@ -11,7 +11,7 @@ module UserStatusTooltip expose :user_status_if_loaded, as: :status_tooltip_html def user_status_if_loaded - return nil unless object.association(:status).loaded? + return unless object.association(:status).loaded? user_status(object) end diff --git a/app/serializers/container_repository_entity.rb b/app/serializers/container_repository_entity.rb index 59bf35f5aff..cc746698a05 100644 --- a/app/serializers/container_repository_entity.rb +++ b/app/serializers/container_repository_entity.rb @@ -3,7 +3,7 @@ class ContainerRepositoryEntity < Grape::Entity include RequestAwareEntity - expose :id, :path, :location + expose :id, :name, :path, :location, :created_at expose :tags_path do |repository| project_registry_repository_tags_path(project, repository, format: :json) diff --git a/app/serializers/container_tag_entity.rb b/app/serializers/container_tag_entity.rb index 637294877f8..361c073e22e 100644 --- a/app/serializers/container_tag_entity.rb +++ b/app/serializers/container_tag_entity.rb @@ -3,7 +3,7 @@ class ContainerTagEntity < Grape::Entity include RequestAwareEntity - expose :name, :location, :revision, :short_revision, :total_size, :created_at + expose :name, :path, :location, :digest, :revision, :short_revision, :total_size, :created_at expose :destroy_path, if: -> (*) { can_destroy? } do |tag| project_registry_repository_tag_path(project, tag.repository, tag.name) diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index aa1d9e6292c..34ae06278c8 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -24,6 +24,12 @@ class DeploymentEntity < Grape::Entity expose :user, using: UserEntity expose :commit, using: CommitEntity expose :deployable, using: JobEntity - expose :manual_actions, using: JobEntity - expose :scheduled_actions, using: JobEntity + expose :manual_actions, using: JobEntity, if: -> (*) { can_create_deployment? } + expose :scheduled_actions, using: JobEntity, if: -> (*) { can_create_deployment? } + + private + + def can_create_deployment? + can?(request.current_user, :create_deployment, request.project) + end end diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb index 06a8db78476..d8630165e49 100644 --- a/app/serializers/diff_file_base_entity.rb +++ b/app/serializers/diff_file_base_entity.rb @@ -27,9 +27,13 @@ class DiffFileBaseEntity < Grape::Entity next unless merge_request.source_project - project_edit_blob_path(merge_request.source_project, - tree_join(merge_request.source_branch, diff_file.new_path), - options) + if Feature.enabled?(:web_ide_default) + ide_edit_path(merge_request.source_project, merge_request.source_branch, diff_file.new_path) + else + project_edit_blob_path(merge_request.source_project, + tree_join(merge_request.source_branch, diff_file.new_path), + options) + end end expose :old_path_html do |diff_file| @@ -72,17 +76,20 @@ class DiffFileBaseEntity < Grape::Entity expose :old_path expose :new_path expose :new_file?, as: :new_file - expose :collapsed?, as: :collapsed - expose :text?, as: :text + expose :renamed_file?, as: :renamed_file + expose :deleted_file?, as: :deleted_file + expose :diff_refs + expose :stored_externally?, as: :stored_externally expose :external_storage - expose :renamed_file?, as: :renamed_file - expose :deleted_file?, as: :deleted_file + expose :mode_changed?, as: :mode_changed expose :a_mode expose :b_mode + expose :viewer, using: DiffViewerEntity + private def memoized_submodule_links(diff_file) diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb index b0aaec3326d..01ee7af37ed 100644 --- a/app/serializers/diff_file_entity.rb +++ b/app/serializers/diff_file_entity.rb @@ -4,12 +4,10 @@ class DiffFileEntity < DiffFileBaseEntity include CommitsHelper include IconsHelper - expose :too_large?, as: :too_large - expose :empty?, as: :empty expose :added_lines expose :removed_lines - expose :load_collapsed_diff_url, if: -> (diff_file, options) { diff_file.text? && options[:merge_request] } do |diff_file| + expose :load_collapsed_diff_url, if: -> (diff_file, options) { diff_file.viewer.collapsed? && options[:merge_request] } do |diff_file| merge_request = options[:merge_request] project = merge_request.target_project @@ -36,10 +34,6 @@ class DiffFileEntity < DiffFileBaseEntity project_blob_path(project, tree_join(diff_file.content_sha, diff_file.new_path)) end - expose :viewer, using: DiffViewerEntity do |diff_file| - diff_file.rich_viewer || diff_file.simple_viewer - end - expose :replaced_view_path, if: -> (_, options) { options[:merge_request] } do |diff_file| image_diff = diff_file.rich_viewer && diff_file.rich_viewer.partial_name == 'image' image_replaced = diff_file.old_content_sha && diff_file.old_content_sha != diff_file.content_sha diff --git a/app/serializers/diff_viewer_entity.rb b/app/serializers/diff_viewer_entity.rb index 27fba03cb3f..45faca6cb2f 100644 --- a/app/serializers/diff_viewer_entity.rb +++ b/app/serializers/diff_viewer_entity.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class DiffViewerEntity < Grape::Entity - # Partial name refers directly to a Rails feature, let's avoid - # using this on the frontend. expose :partial_name, as: :name + expose :render_error, as: :error + expose :render_error_message, as: :error_message + expose :collapsed?, as: :collapsed end diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index 4a7d13915dd..76248e6470e 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -8,6 +8,7 @@ class EnvironmentEntity < Grape::Entity expose :state expose :external_url expose :environment_type + expose :name_without_type expose :last_deployment, using: DeploymentEntity expose :stop_action_available?, as: :has_stop_action diff --git a/app/serializers/error_tracking/error_entity.rb b/app/serializers/error_tracking/error_entity.rb new file mode 100644 index 00000000000..91388e7c3ad --- /dev/null +++ b/app/serializers/error_tracking/error_entity.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ErrorTracking + class ErrorEntity < Grape::Entity + expose :id, :title, :type, :user_count, :count, + :first_seen, :last_seen, :message, :culprit, + :external_url, :project_id, :project_name, :project_slug, + :short_id, :status, :frequency + end +end diff --git a/app/serializers/error_tracking/error_serializer.rb b/app/serializers/error_tracking/error_serializer.rb new file mode 100644 index 00000000000..ff9a645eb16 --- /dev/null +++ b/app/serializers/error_tracking/error_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ErrorTracking + class ErrorSerializer < BaseSerializer + entity ErrorEntity + end +end diff --git a/app/serializers/error_tracking/project_entity.rb b/app/serializers/error_tracking/project_entity.rb new file mode 100644 index 00000000000..405d87ca0d0 --- /dev/null +++ b/app/serializers/error_tracking/project_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ErrorTracking + class ProjectEntity < Grape::Entity + expose(*Gitlab::ErrorTracking::Project::ACCESSORS) + end +end diff --git a/app/serializers/error_tracking/project_serializer.rb b/app/serializers/error_tracking/project_serializer.rb new file mode 100644 index 00000000000..68724088fff --- /dev/null +++ b/app/serializers/error_tracking/project_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ErrorTracking + class ProjectSerializer < BaseSerializer + entity ErrorTracking::ProjectEntity + end +end diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb index 084627f9dbe..178e72f4f0a 100644 --- a/app/serializers/merge_request_basic_entity.rb +++ b/app/serializers/merge_request_basic_entity.rb @@ -11,4 +11,5 @@ class MergeRequestBasicEntity < Grape::Entity expose :labels, using: LabelEntity expose :assignee, using: API::Entities::UserBasic expose :task_status, :task_status_short + expose :lock_version, :lock_version end diff --git a/app/serializers/merge_request_diff_entity.rb b/app/serializers/merge_request_diff_entity.rb index 433bfe60474..7e3053e5881 100644 --- a/app/serializers/merge_request_diff_entity.rb +++ b/app/serializers/merge_request_diff_entity.rb @@ -24,6 +24,14 @@ class MergeRequestDiffEntity < Grape::Entity short_sha(merge_request_diff.head_commit_sha) end + expose :base_version_path do |merge_request_diff| + project = merge_request.target_project + + next unless project + + merge_request_version_path(project, merge_request, merge_request_diff) + end + expose :version_path do |merge_request_diff| start_sha = options[:start_sha] project = merge_request.target_project diff --git a/app/serializers/merge_request_for_pipeline_entity.rb b/app/serializers/merge_request_for_pipeline_entity.rb new file mode 100644 index 00000000000..7779ddfd65a --- /dev/null +++ b/app/serializers/merge_request_for_pipeline_entity.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class MergeRequestForPipelineEntity < Grape::Entity + include RequestAwareEntity + + expose :iid + + expose :path do |merge_request| + project_merge_request_path(merge_request.project, merge_request) + end + + expose :title + expose :source_branch + expose :source_branch_path + expose :target_branch + expose :target_branch_path +end diff --git a/app/serializers/merge_request_widget_commit_entity.rb b/app/serializers/merge_request_widget_commit_entity.rb new file mode 100644 index 00000000000..50a5c44a6ad --- /dev/null +++ b/app/serializers/merge_request_widget_commit_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class MergeRequestWidgetCommitEntity < Grape::Entity + expose :safe_message, as: :message + expose :short_id + expose :title +end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 9731b52f1ad..2142ceb6122 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -11,6 +11,9 @@ class MergeRequestWidgetEntity < IssuableEntity expose :merge_user_id expose :merge_when_pipeline_succeeds expose :source_branch + expose :source_branch_protected do |merge_request| + merge_request.source_project.present? && ProtectedBranch.protected?(merge_request.source_project, merge_request.source_branch) + end expose :source_project_id expose :source_project_full_path do |merge_request| merge_request.source_project&.full_path @@ -53,10 +56,23 @@ class MergeRequestWidgetEntity < IssuableEntity merge_request.diff_head_sha.presence end - expose :merge_commit_message - expose :actual_head_pipeline, with: PipelineDetailsEntity, as: :pipeline + expose :actual_head_pipeline, with: PipelineDetailsEntity, as: :pipeline, if: -> (mr, _) { presenter(mr).can_read_pipeline? } + expose :merge_pipeline, with: PipelineDetailsEntity, if: ->(mr, _) { mr.merged? && can?(request.current_user, :read_pipeline, mr.target_project)} + expose :default_squash_commit_message + expose :default_merge_commit_message + + expose :default_merge_commit_message_with_description do |merge_request| + merge_request.default_merge_commit_message(include_description: true) + end + + expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity do |merge_request| + merge_request.commits.without_merge_commits + end + + expose :commits_count + # Booleans expose :merge_ongoing?, as: :merge_ongoing expose :work_in_progress?, as: :work_in_progress @@ -74,7 +90,6 @@ class MergeRequestWidgetEntity < IssuableEntity end expose :branch_missing?, as: :branch_missing - expose :commits_count expose :cannot_be_merged?, as: :has_conflicts expose :can_be_merged?, as: :can_be_merged expose :mergeable?, as: :mergeable @@ -202,10 +217,6 @@ class MergeRequestWidgetEntity < IssuableEntity ci_environments_status_project_merge_request_path(merge_request.project, merge_request) end - expose :merge_commit_message_with_description do |merge_request| - merge_request.merge_commit_message(include_description: true) - end - expose :diverged_commits_count do |merge_request| if merge_request.open? && merge_request.diverged_from_target_branch? merge_request.diverged_commits_count @@ -240,6 +251,10 @@ class MergeRequestWidgetEntity < IssuableEntity expose :supports_suggestion?, as: :can_receive_suggestion + expose :conflicts_docs_path do |merge_request| + presenter(merge_request).conflicts_docs_path + end + private delegate :current_user, to: :request diff --git a/app/serializers/namespace_basic_entity.rb b/app/serializers/namespace_basic_entity.rb new file mode 100644 index 00000000000..8bcbb2bca60 --- /dev/null +++ b/app/serializers/namespace_basic_entity.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class NamespaceBasicEntity < Grape::Entity + expose :id + expose :full_path +end diff --git a/app/serializers/namespace_serializer.rb b/app/serializers/namespace_serializer.rb new file mode 100644 index 00000000000..bf3f154b558 --- /dev/null +++ b/app/serializers/namespace_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class NamespaceSerializer < BaseSerializer + entity NamespaceBasicEntity +end diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index c9669e59199..5ac1e590d39 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -23,11 +23,12 @@ class PipelineEntity < Grape::Entity expose :latest?, as: :latest expose :stuck?, as: :stuck expose :auto_devops_source?, as: :auto_devops - expose :merge_request?, as: :merge_request + expose :merge_request_event?, as: :merge_request expose :has_yaml_errors?, as: :yaml_errors expose :can_retry?, as: :retryable expose :can_cancel?, as: :cancelable expose :failure_reason?, as: :failure_reason + expose :detached_merge_request_pipeline?, as: :detached end expose :details do @@ -36,6 +37,10 @@ class PipelineEntity < Grape::Entity expose :finished_at end + expose :merge_request, if: -> (*) { has_presentable_merge_request? }, with: MergeRequestForPipelineEntity do |pipeline| + pipeline.merge_request.present(current_user: request.current_user) + end + expose :ref do expose :name do |pipeline| pipeline.ref @@ -49,7 +54,7 @@ class PipelineEntity < Grape::Entity expose :tag?, as: :tag expose :branch?, as: :branch - expose :merge_request?, as: :merge_request + expose :merge_request_event?, as: :merge_request end expose :commit, using: CommitEntity @@ -59,7 +64,7 @@ class PipelineEntity < Grape::Entity pipeline.present.failure_reason end - expose :retry_path, if: -> (*) { can_retry? } do |pipeline| + expose :retry_path, if: -> (*) { can_retry? } do |pipeline| retry_project_pipeline_path(pipeline.project, pipeline) end @@ -81,6 +86,11 @@ class PipelineEntity < Grape::Entity pipeline.cancelable? end + def has_presentable_merge_request? + pipeline.triggered_by_merge_request? && + can?(request.current_user, :read_merge_request, pipeline.merge_request) + end + def detailed_status pipeline.detailed_status(request.current_user) end diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index 7451433a841..dbbeca9431d 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -15,6 +15,7 @@ class PipelineSerializer < BaseSerializer :manual_actions, :scheduled_actions, :artifacts, + :merge_request, { pending_builds: :project, project: [:route, { namespace: :route }], diff --git a/app/serializers/project_import_entity.rb b/app/serializers/project_import_entity.rb new file mode 100644 index 00000000000..9b51af685e7 --- /dev/null +++ b/app/serializers/project_import_entity.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ProjectImportEntity < ProjectEntity + include ImportHelper + + expose :import_source + expose :import_status + expose :human_import_status_name + + expose :provider_link do |project, options| + provider_project_link_url(options[:provider_url], project[:import_source]) + end +end diff --git a/app/serializers/project_serializer.rb b/app/serializers/project_serializer.rb index 23b96c2fc9e..52ac2fa0e09 100644 --- a/app/serializers/project_serializer.rb +++ b/app/serializers/project_serializer.rb @@ -1,5 +1,15 @@ # frozen_string_literal: true class ProjectSerializer < BaseSerializer - entity ProjectEntity + def represent(project, opts = {}) + entity = + case opts[:serializer] + when :import + ProjectImportEntity + else + ProjectEntity + end + + super(project, opts, entity) + end end diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb index 4f1f62d145b..c98dc1a1c4a 100644 --- a/app/serializers/projects/serverless/service_entity.rb +++ b/app/serializers/projects/serverless/service_entity.rb @@ -13,6 +13,25 @@ module Projects service.dig('metadata', 'namespace') end + expose :environment_scope do |service| + service.dig('environment_scope') + end + + expose :cluster_id do |service| + service.dig('cluster_id') + end + + expose :detail_url do |service| + project_serverless_path( + request.project, + service.dig('environment_scope'), + service.dig('metadata', 'name')) + end + + expose :podcount do |service| + service.dig('podcount') + end + expose :created_at do |service| service.dig('metadata', 'creationTimestamp') end @@ -22,11 +41,24 @@ module Projects end expose :description do |service| - service.dig('spec', 'runLatest', 'configuration', 'revisionTemplate', 'metadata', 'annotations', 'Description') + service.dig( + 'spec', + 'runLatest', + 'configuration', + 'revisionTemplate', + 'metadata', + 'annotations', + 'Description') end expose :image do |service| - service.dig('spec', 'runLatest', 'configuration', 'build', 'template', 'name') + service.dig( + 'spec', + 'runLatest', + 'configuration', + 'build', + 'template', + 'name') end end end diff --git a/app/serializers/provider_repo_entity.rb b/app/serializers/provider_repo_entity.rb new file mode 100644 index 00000000000..d70aaa91324 --- /dev/null +++ b/app/serializers/provider_repo_entity.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ProviderRepoEntity < Grape::Entity + include ImportHelper + + expose :id + expose :full_name + expose :owner_name do |provider_repo, options| + owner_name(provider_repo, options[:provider]) + end + + expose :sanitized_name do |provider_repo| + sanitize_project_name(provider_repo[:name]) + end + + expose :provider_link do |provider_repo, options| + provider_project_link_url(options[:provider_url], provider_repo[:full_name]) + end + + private + + def owner_name(provider_repo, provider) + provider_repo.dig(:owner, :login) if provider == :github + end +end diff --git a/app/serializers/provider_repo_serializer.rb b/app/serializers/provider_repo_serializer.rb new file mode 100644 index 00000000000..8a73f6fe6df --- /dev/null +++ b/app/serializers/provider_repo_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ProviderRepoSerializer < BaseSerializer + entity ProviderRepoEntity +end diff --git a/app/serializers/tree_entity.rb b/app/serializers/tree_entity.rb deleted file mode 100644 index 9b7dc80e1d9..00000000000 --- a/app/serializers/tree_entity.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -class TreeEntity < Grape::Entity - include RequestAwareEntity - - expose :id, :path, :name, :mode - - expose :icon do |tree| - IconsHelper.file_type_icon_class('folder', tree.mode, tree.name) - end - - expose :url do |tree| - project_tree_path(request.project, File.join(request.ref, tree.path)) - end -end diff --git a/app/serializers/tree_root_entity.rb b/app/serializers/tree_root_entity.rb deleted file mode 100644 index f1cfcd943d8..00000000000 --- a/app/serializers/tree_root_entity.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -# TODO: Inherit from TreeEntity, when `Tree` implements `id` and `name` like `Gitlab::Git::Tree`. -class TreeRootEntity < Grape::Entity - include RequestAwareEntity - - expose :path - - expose :trees, using: TreeEntity - expose :blobs, using: BlobEntity - expose :submodules, using: SubmoduleEntity - - expose :parent_tree_url do |tree| - path = tree.path.sub(%r{\A/}, '') - next unless path.present? - - path_segments = path.split('/') - path_segments.pop - parent_tree_path = path_segments.join('/') - - project_tree_path(request.project, File.join(request.ref, parent_tree_path)) - end - - expose :last_commit_path do |tree| - logs_file_project_ref_path(request.project, request.ref, tree.path) - end -end diff --git a/app/serializers/tree_serializer.rb b/app/serializers/tree_serializer.rb deleted file mode 100644 index 536b8ab1ae2..00000000000 --- a/app/serializers/tree_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class TreeSerializer < BaseSerializer - entity TreeRootEntity -end diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb index 2e4643ed668..9146eb96533 100644 --- a/app/services/application_settings/update_service.rb +++ b/app/services/application_settings/update_service.rb @@ -38,7 +38,7 @@ module ApplicationSettings def performance_bar_allowed_group_id performance_bar_enabled = !params.key?(:performance_bar_enabled) || params.delete(:performance_bar_enabled) group_full_path = params.delete(:performance_bar_allowed_group_path) - return nil unless Gitlab::Utils.to_boolean(performance_bar_enabled) + return unless Gitlab::Utils.to_boolean(performance_bar_enabled) Group.find_by_full_path(group_full_path)&.id if group_full_path.present? end diff --git a/app/services/applications/create_service.rb b/app/services/applications/create_service.rb index b6c30da4d3a..dff0d9696f8 100644 --- a/app/services/applications/create_service.rb +++ b/app/services/applications/create_service.rb @@ -2,16 +2,16 @@ module Applications class CreateService - # rubocop: disable CodeReuse/ActiveRecord + attr_reader :current_user, :params + def initialize(current_user, params) @current_user = current_user - @params = params.except(:ip_address) + @params = params.except(:ip_address) # rubocop: disable CodeReuse/ActiveRecord end - # rubocop: enable CodeReuse/ActiveRecord # EE would override and use `request` arg def execute(request) - Doorkeeper::Application.create(@params) + Doorkeeper::Application.create(params) end end end diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index f764536e762..e95ba09c006 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -160,7 +160,8 @@ module Auth ## # We still support legacy pipeline triggers which do not have associated # actor. New permissions model and new triggers are always associated with - # an actor, so this should be improved in 10.0 version of GitLab. + # an actor. So this should be improved once + # https://gitlab.com/gitlab-org/gitlab-ce/issues/37452 is resolved. # def build_can_push?(requested_project) # Build can push only to the project from which it originates diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb index 43a26f4264e..834baeb9643 100644 --- a/app/services/boards/issues/move_service.rb +++ b/app/services/boards/issues/move_service.rb @@ -50,7 +50,7 @@ module Boards if move_between_ids attrs[:move_between_ids] = move_between_ids - attrs[:board_group_id] = board.group&.id + attrs[:board_group_id] = board.group&.id end attrs diff --git a/app/services/boards/lists/destroy_service.rb b/app/services/boards/lists/destroy_service.rb index 609c430caed..e20805d0405 100644 --- a/app/services/boards/lists/destroy_service.rb +++ b/app/services/boards/lists/destroy_service.rb @@ -20,7 +20,7 @@ module Boards # rubocop: disable CodeReuse/ActiveRecord def decrement_higher_lists(list) - board.lists.movable.where('position > ?', list.position) + board.lists.movable.where('position > ?', list.position) .update_all('position = position - 1') end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/boards/visits/latest_service.rb b/app/services/boards/visits/latest_service.rb index 9e4c77a6317..d13e25b4f12 100644 --- a/app/services/boards/visits/latest_service.rb +++ b/app/services/boards/visits/latest_service.rb @@ -4,13 +4,15 @@ module Boards module Visits class LatestService < Boards::BaseService def execute - return nil unless current_user + return unless current_user - if parent.is_a?(Group) - BoardGroupRecentVisit.latest(current_user, parent) - else - BoardProjectRecentVisit.latest(current_user, parent) - end + recent_visit_model.latest(current_user, parent, count: params[:count]) + end + + private + + def recent_visit_model + parent.is_a?(Group) ? BoardGroupRecentVisit : BoardProjectRecentVisit end end end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 19b5552887f..8973c5ffc9e 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -7,14 +7,17 @@ module Ci CreateError = Class.new(StandardError) SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build, + Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs, Gitlab::Ci::Pipeline::Chain::Validate::Abilities, Gitlab::Ci::Pipeline::Chain::Validate::Repository, Gitlab::Ci::Pipeline::Chain::Validate::Config, Gitlab::Ci::Pipeline::Chain::Skip, + Gitlab::Ci::Pipeline::Chain::Limit::Size, Gitlab::Ci::Pipeline::Chain::Populate, - Gitlab::Ci::Pipeline::Chain::Create].freeze + Gitlab::Ci::Pipeline::Chain::Create, + Gitlab::Ci::Pipeline::Chain::Limit::Activity].freeze - def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, &block) + def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, **options, &block) @pipeline = Ci::Pipeline.new command = Gitlab::Ci::Pipeline::Chain::Command.new( @@ -22,7 +25,9 @@ module Ci origin_ref: params[:ref], checkout_sha: params[:checkout_sha], after_sha: params[:after], - before_sha: params[:before], + before_sha: params[:before], # The base SHA of the source branch (i.e merge_request.diff_base_sha). + source_sha: params[:source_sha], # The HEAD SHA of the source branch (i.e merge_request.diff_head_sha). + target_sha: params[:target_sha], # The HEAD SHA of the target branch. trigger_request: trigger_request, schedule: schedule, merge_request: merge_request, @@ -31,7 +36,10 @@ module Ci seeds_block: block, variables_attributes: params[:variables_attributes], project: project, - current_user: current_user) + current_user: current_user, + push_options: params[:push_options], + chat_data: params[:chat_data], + **extra_options(options)) sequence = Gitlab::Ci::Pipeline::Chain::Sequence .new(pipeline, command, SEQUENCE) @@ -102,5 +110,14 @@ module Ci pipeline.project.source_of_merge_requests.opened.where(source_branch: pipeline.ref) end # rubocop: enable CodeReuse/ActiveRecord + + def extra_options(options = {}) + # In Ruby 2.4, even when options is empty, f(**options) doesn't work when f + # doesn't have any parameters. We reproduce the Ruby 2.5 behavior by + # checking explicitly that no arguments are given. + raise ArgumentError if options.any? + + {} # overridden in EE + end end end diff --git a/app/services/ci/destroy_expired_job_artifacts_service.rb b/app/services/ci/destroy_expired_job_artifacts_service.rb new file mode 100644 index 00000000000..7d2f5d33fed --- /dev/null +++ b/app/services/ci/destroy_expired_job_artifacts_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Ci + class DestroyExpiredJobArtifactsService + include ::Gitlab::ExclusiveLeaseHelpers + include ::Gitlab::LoopHelpers + + BATCH_SIZE = 100 + LOOP_TIMEOUT = 45.minutes + LOOP_LIMIT = 1000 + EXCLUSIVE_LOCK_KEY = 'expired_job_artifacts:destroy:lock' + LOCK_TIMEOUT = 50.minutes + + ## + # Destroy expired job artifacts on GitLab instance + # + # This destroy process cannot run for more than 45 minutes. This is for + # preventing multiple `ExpireBuildArtifactsWorker` CRON jobs run concurrently, + # which is scheduled at every hour. + def execute + in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do + loop_until(timeout: LOOP_TIMEOUT, limit: LOOP_LIMIT) do + destroy_batch + end + end + end + + private + + def destroy_batch + artifacts = Ci::JobArtifact.expired(BATCH_SIZE).to_a + + return false if artifacts.empty? + + artifacts.each(&:destroy!) + end + end +end diff --git a/app/services/ci/destroy_pipeline_service.rb b/app/services/ci/destroy_pipeline_service.rb index 13f892aabb8..5c4a34043c1 100644 --- a/app/services/ci/destroy_pipeline_service.rb +++ b/app/services/ci/destroy_pipeline_service.rb @@ -5,8 +5,6 @@ module Ci def execute(pipeline) raise Gitlab::Access::AccessDeniedError unless can?(current_user, :destroy_pipeline, pipeline) - AuditEventService.new(current_user, pipeline).security_event - pipeline.destroy! end end diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb index f54574b026b..2dbb7c3917d 100644 --- a/app/services/ci/pipeline_trigger_service.rb +++ b/app/services/ci/pipeline_trigger_service.rb @@ -7,6 +7,8 @@ module Ci def execute if trigger_from_token create_pipeline_from_trigger(trigger_from_token) + elsif job_from_token + create_pipeline_from_job(job_from_token) end end @@ -35,6 +37,14 @@ module Ci end end + def create_pipeline_from_job(job) + # overridden in EE + end + + def job_from_token + # overridden in EE + end + def variables params[:variables].to_h.map do |key, value| { key: key, value: value } diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index 446188347df..4a7ce00b8e2 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -10,7 +10,7 @@ module Ci update_retried new_builds = - stage_indexes_of_created_builds.map do |index| + stage_indexes_of_created_processables.map do |index| process_stage(index) end @@ -27,7 +27,7 @@ module Ci return if HasStatus::BLOCKED_STATUS.include?(current_status) if HasStatus::COMPLETED_STATUSES.include?(current_status) - created_builds_in_stage(index).select do |build| + created_processables_in_stage(index).select do |build| Gitlab::OptimisticLocking.retry_lock(build) do |subject| Ci::ProcessBuildService.new(project, @user) .execute(build, current_status) @@ -43,19 +43,19 @@ module Ci # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord - def stage_indexes_of_created_builds - created_builds.order(:stage_idx).pluck('distinct stage_idx') + def stage_indexes_of_created_processables + created_processables.order(:stage_idx).pluck('distinct stage_idx') end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord - def created_builds_in_stage(index) - created_builds.where(stage_idx: index) + def created_processables_in_stage(index) + created_processables.where(stage_idx: index) end # rubocop: enable CodeReuse/ActiveRecord - def created_builds - pipeline.builds.created + def created_processables + pipeline.processables.created end # This method is for compatibility and data consistency and should be removed with 9.3 version of GitLab diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index 218f1e63d08..fab8a179843 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -2,7 +2,7 @@ module Ci class RetryBuildService < ::BaseService - CLONE_ACCESSORS = %i[pipeline project ref tag options commands name + CLONE_ACCESSORS = %i[pipeline project ref tag options name allow_failure stage stage_id stage_idx trigger_request yaml_variables when environment coverage_regex description tag_list protected].freeze diff --git a/app/services/clusters/applications/base_helm_service.rb b/app/services/clusters/applications/base_helm_service.rb index e86ca8cf1d0..8a71730d5ec 100644 --- a/app/services/clusters/applications/base_helm_service.rb +++ b/app/services/clusters/applications/base_helm_service.rb @@ -45,6 +45,10 @@ module Clusters def install_command @install_command ||= app.install_command end + + def upgrade_command(new_values = "") + app.upgrade_command(new_values) + end end end end diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb index 21ec26ea233..c592d608b89 100644 --- a/app/services/clusters/applications/check_installation_progress_service.rb +++ b/app/services/clusters/applications/check_installation_progress_service.rb @@ -4,7 +4,7 @@ module Clusters module Applications class CheckInstallationProgressService < BaseHelmService def execute - return unless app.installing? + return unless operation_in_progress? case installation_phase when Gitlab::Kubernetes::Pod::SUCCEEDED @@ -16,11 +16,16 @@ module Clusters end rescue Kubeclient::HttpError => e log_error(e) - app.make_errored!("Kubernetes error: #{e.error_code}") unless app.errored? + + app.make_errored!("Kubernetes error: #{e.error_code}") end private + def operation_in_progress? + app.installing? || app.updating? + end + def on_success app.make_installed! ensure @@ -28,13 +33,13 @@ module Clusters end def on_failed - app.make_errored!("Installation failed. Check pod logs for #{install_command.pod_name} for more details.") + app.make_errored!("Operation failed. Check pod logs for #{pod_name} for more details.") end def check_timeout if timeouted? begin - app.make_errored!("Installation timed out. Check pod logs for #{install_command.pod_name} for more details.") + app.make_errored!("Operation timed out. Check pod logs for #{pod_name} for more details.") end else ClusterWaitForAppInstallationWorker.perform_in( @@ -42,20 +47,24 @@ module Clusters end end + def pod_name + install_command.pod_name + end + def timeouted? Time.now.utc - app.updated_at.to_time.utc > ClusterWaitForAppInstallationWorker::TIMEOUT end def remove_installation_pod - helm_api.delete_pod!(install_command.pod_name) + helm_api.delete_pod!(pod_name) end def installation_phase - helm_api.status(install_command.pod_name) + helm_api.status(pod_name) end def installation_errors - helm_api.log(install_command.pod_name) + helm_api.log(pod_name) end end end diff --git a/app/services/clusters/applications/create_service.rb b/app/services/clusters/applications/create_service.rb index 92c2c1b9834..12f8c849d41 100644 --- a/app/services/clusters/applications/create_service.rb +++ b/app/services/clusters/applications/create_service.rb @@ -27,9 +27,11 @@ module Clusters application.oauth_application = create_oauth_application(application, request) end - application.save! + worker = application.updateable? ? ClusterUpgradeAppWorker : ClusterInstallAppWorker - Clusters::Applications::ScheduleInstallationService.new(application).execute + application.make_scheduled! + + worker.perform_async(application.name, application.id) end end diff --git a/app/services/clusters/applications/schedule_installation_service.rb b/app/services/clusters/applications/schedule_installation_service.rb deleted file mode 100644 index d75ba70c27e..00000000000 --- a/app/services/clusters/applications/schedule_installation_service.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class ScheduleInstallationService - attr_reader :application - - def initialize(application) - @application = application - end - - def execute - application.make_scheduled! - - ClusterInstallAppWorker.perform_async(application.name, application.id) - end - end - end -end diff --git a/app/services/clusters/applications/upgrade_service.rb b/app/services/clusters/applications/upgrade_service.rb new file mode 100644 index 00000000000..a0ece1d2635 --- /dev/null +++ b/app/services/clusters/applications/upgrade_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class UpgradeService < BaseHelmService + def execute + return unless app.scheduled? + + begin + app.make_updating! + + # install_command works with upgrades too + # as it basically does `helm upgrade --install` + helm_api.update(install_command) + + ClusterWaitForAppInstallationWorker.perform_in( + ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) + rescue Kubeclient::HttpError => e + log_error(e) + app.make_update_errored!("Kubernetes error: #{e.error_code}") + rescue StandardError => e + log_error(e) + app.make_update_errored!("Can't start upgrade process.") + end + end + end + end +end diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb index 34593e12bd5..bb34a3d3352 100644 --- a/app/services/commits/create_service.rb +++ b/app/services/commits/create_service.rb @@ -11,6 +11,7 @@ module Commits @start_project = params[:start_project] || @project @start_branch = params[:start_branch] @branch_name = params[:branch_name] + @force = params[:force] || false end def execute @@ -42,10 +43,14 @@ module Commits @start_branch != @branch_name || @start_project != @project end + def force? + !!@force + end + def validate! validate_permissions! validate_on_branch! - validate_branch_existance! + validate_branch_existence! validate_new_branch_name! if different_branch? end @@ -64,14 +69,14 @@ module Commits end end - def validate_branch_existance! - if !project.empty_repo? && different_branch? && repository.branch_exists?(@branch_name) + def validate_branch_existence! + if !project.empty_repo? && different_branch? && repository.branch_exists?(@branch_name) && !force? raise_error("A branch called '#{@branch_name}' already exists. Switch to that branch in order to make changes") end end def validate_new_branch_name! - result = ValidateNewBranchService.new(project, current_user).execute(@branch_name) + result = ValidateNewBranchService.new(project, current_user).execute(@branch_name, force: force?) if result[:status] == :error raise_error("Something went wrong when we tried to create '#{@branch_name}' for you: #{result[:message]}") diff --git a/app/services/commits/tag_service.rb b/app/services/commits/tag_service.rb index 7961ba4d3c4..bb8cfb63f98 100644 --- a/app/services/commits/tag_service.rb +++ b/app/services/commits/tag_service.rb @@ -9,11 +9,10 @@ module Commits tag_name = params[:tag_name] message = params[:tag_message] - release_description = nil result = Tags::CreateService .new(commit.project, current_user) - .execute(tag_name, commit.sha, message, release_description) + .execute(tag_name, commit.sha, message) if result[:status] == :success tag = result[:tag] diff --git a/app/services/concerns/exclusive_lease_guard.rb b/app/services/concerns/exclusive_lease_guard.rb index f102e00d150..2cb73555d85 100644 --- a/app/services/concerns/exclusive_lease_guard.rb +++ b/app/services/concerns/exclusive_lease_guard.rb @@ -6,9 +6,14 @@ # # `#try_obtain_lease` takes a block which will be run if it was able to # obtain the lease. Implement `#lease_timeout` to configure the timeout -# for the exclusive lease. Optionally override `#lease_key` to set the +# for the exclusive lease. +# +# Optionally override `#lease_key` to set the # lease key, it defaults to the class name with underscores. # +# Optionally override `#lease_release?` to prevent the job to +# be re-executed more often than LEASE_TIMEOUT. +# module ExclusiveLeaseGuard extend ActiveSupport::Concern @@ -23,7 +28,7 @@ module ExclusiveLeaseGuard begin yield lease ensure - release_lease(lease) + release_lease(lease) if lease_release? end end @@ -37,7 +42,11 @@ module ExclusiveLeaseGuard def lease_timeout raise NotImplementedError, - "#{self.class.name} does not implement #{__method__}" + "#{self.class.name} does not implement #{__method__}" + end + + def lease_release? + true end def release_lease(uuid) diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb index 5b408bd96c7..6713b6617ae 100644 --- a/app/services/concerns/users/participable_service.rb +++ b/app/services/concerns/users/participable_service.rb @@ -11,7 +11,7 @@ module Users def noteable_owner return [] unless noteable && noteable.author.present? - [as_hash(noteable.author)] + [user_as_hash(noteable.author)] end def participants_in_noteable @@ -23,21 +23,24 @@ module Users def sorted(users) users.uniq.to_a.compact.sort_by(&:username).map do |user| - as_hash(user) + user_as_hash(user) end end def groups current_user.authorized_groups.sort_by(&:path).map do |group| - count = group.users.count - { username: group.full_path, name: group.full_name, count: count, avatar_url: group.avatar_url } + group_as_hash(group) end end private - def as_hash(user) - { username: user.username, name: user.name, avatar_url: user.avatar_url } + def user_as_hash(user) + { type: user.class.name, username: user.username, name: user.name, avatar_url: user.avatar_url } + end + + def group_as_hash(group) + { type: group.class.name, username: group.full_path, name: group.full_name, avatar_url: group.avatar_url, count: group.users.count } end end end diff --git a/app/services/create_branch_service.rb b/app/services/create_branch_service.rb index 65208b07e27..110e589e30d 100644 --- a/app/services/create_branch_service.rb +++ b/app/services/create_branch_service.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class CreateBranchService < BaseService - def execute(branch_name, ref) - create_master_branch if project.empty_repo? + def execute(branch_name, ref, create_master_if_empty: true) + create_master_branch if create_master_if_empty && project.empty_repo? result = ValidateNewBranchService.new(project, current_user) .execute(branch_name) diff --git a/app/services/create_release_service.rb b/app/services/create_release_service.rb deleted file mode 100644 index ab2dc5337aa..00000000000 --- a/app/services/create_release_service.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -class CreateReleaseService < BaseService - # rubocop: disable CodeReuse/ActiveRecord - def execute(tag_name, release_description) - repository = project.repository - existing_tag = repository.find_tag(tag_name) - - # Only create a release if the tag exists - if existing_tag - release = project.releases.find_by(tag: tag_name) - - if release - error('Release already exists', 409) - else - release = project.releases.create!( - tag: tag_name, - name: tag_name, - sha: existing_tag.dereferenced_target.sha, - author: current_user, - description: release_description - ) - - success(release) - end - else - error('Tag does not exist', 404) - end - end - # rubocop: enable CodeReuse/ActiveRecord - - def success(release) - super().merge(release: release) - end -end diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb index 44252f7b0a6..8322a3d74f4 100644 --- a/app/services/delete_branch_service.rb +++ b/app/services/delete_branch_service.rb @@ -14,7 +14,7 @@ class DeleteBranchService < BaseService end if repository.rm_branch(current_user, branch_name) - success('Branch was removed') + success('Branch was deleted') else error('Failed to remove branch') end diff --git a/app/services/emails/base_service.rb b/app/services/emails/base_service.rb index 988215ffc78..99324638300 100644 --- a/app/services/emails/base_service.rb +++ b/app/services/emails/base_service.rb @@ -2,10 +2,11 @@ module Emails class BaseService - attr_reader :current_user + attr_reader :current_user, :params, :user def initialize(current_user, params = {}) - @current_user, @params = current_user, params.dup + @current_user = current_user + @params = params.dup @user = params.delete(:user) end end diff --git a/app/services/emails/create_service.rb b/app/services/emails/create_service.rb index 56925a724fe..dc06a5caa40 100644 --- a/app/services/emails/create_service.rb +++ b/app/services/emails/create_service.rb @@ -3,12 +3,11 @@ module Emails class CreateService < ::Emails::BaseService def execute(extra_params = {}) - skip_confirmation = @params.delete(:skip_confirmation) + skip_confirmation = params.delete(:skip_confirmation) - email = @user.emails.create(@params.merge(extra_params)) - - email&.confirm if skip_confirmation && current_user.admin? - email + user.emails.create(params.merge(extra_params)).tap do |email| + email&.confirm if skip_confirmation && current_user.admin? + end end end end diff --git a/app/services/error_tracking/list_issues_service.rb b/app/services/error_tracking/list_issues_service.rb new file mode 100644 index 00000000000..a6c6bec9598 --- /dev/null +++ b/app/services/error_tracking/list_issues_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module ErrorTracking + class ListIssuesService < ::BaseService + DEFAULT_ISSUE_STATUS = 'unresolved' + DEFAULT_LIMIT = 20 + + def execute + return error('Error Tracking is not enabled') unless enabled? + return error('Access denied', :unauthorized) unless can_read? + + result = project_error_tracking_setting + .list_sentry_issues(issue_status: issue_status, limit: limit) + + # our results are not yet ready + unless result + return error('Not ready. Try again later', :no_content) + end + + if result[:error].present? + return error(result[:error], :bad_request) + end + + success(issues: result[:issues]) + end + + def external_url + project_error_tracking_setting&.sentry_external_url + end + + private + + def project_error_tracking_setting + project.error_tracking_setting + end + + def issue_status + params[:issue_status] || DEFAULT_ISSUE_STATUS + end + + def limit + params[:limit] || DEFAULT_LIMIT + end + + def enabled? + project_error_tracking_setting&.enabled? + end + + def can_read? + can?(current_user, :read_sentry_issue, project) + end + end +end diff --git a/app/services/error_tracking/list_projects_service.rb b/app/services/error_tracking/list_projects_service.rb new file mode 100644 index 00000000000..4e92353a13c --- /dev/null +++ b/app/services/error_tracking/list_projects_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module ErrorTracking + class ListProjectsService < ::BaseService + def execute + return error('access denied') unless can_read? + + setting = project_error_tracking_setting + + unless setting.valid? + return error(setting.errors.full_messages.join(', '), :bad_request) + end + + begin + result = setting.list_sentry_projects + rescue Sentry::Client::Error => e + return error(e.message, :bad_request) + rescue Sentry::Client::SentryError => e + return error(e.message, :unprocessable_entity) + end + + success(projects: result[:projects]) + end + + private + + def project_error_tracking_setting + (project.error_tracking_setting || project.build_error_tracking_setting).tap do |setting| + setting.api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( + api_host: params[:api_host], + organization_slug: 'org', + project_slug: 'proj' + ) + + setting.token = params[:token] + setting.enabled = true + end + end + + def can_read? + can?(current_user, :read_sentry_issue, project) + end + end +end diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb index 927634c2159..c1bc26c330a 100644 --- a/app/services/files/multi_service.rb +++ b/app/services/files/multi_service.rb @@ -46,7 +46,8 @@ module Files author_email: @author_email, author_name: @author_name, start_project: @start_project, - start_branch_name: @start_branch + start_branch_name: @start_branch, + force: force? ) rescue ArgumentError => e raise_error(e) diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index f1883877d56..f387c749a21 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -140,7 +140,7 @@ class GitPushService < BaseService .perform_async(project.id, current_user.id, params[:oldrev], params[:newrev], params[:ref]) EventCreateService.new.push(project, current_user, build_push_data) - Ci::CreatePipelineService.new(project, current_user, build_push_data).execute(:push) + Ci::CreatePipelineService.new(project, current_user, build_push_data).execute(:push, pipeline_options) project.execute_hooks(build_push_data.dup, :push_hooks) project.execute_services(build_push_data.dup, :push_hooks) @@ -174,7 +174,8 @@ class GitPushService < BaseService params[:newrev], params[:ref], @push_commits, - commits_count: commits_count) + commits_count: commits_count, + push_options: params[:push_options] || []) end def push_to_existing_branch? @@ -230,4 +231,10 @@ class GitPushService < BaseService def last_pushed_commits @last_pushed_commits ||= @push_commits.last(PROCESS_COMMIT_LIMIT) end + + private + + def pipeline_options + {} # to be overridden in EE + end end diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb index dbadafc0f52..e39b3603c6c 100644 --- a/app/services/git_tag_push_service.rb +++ b/app/services/git_tag_push_service.rb @@ -10,7 +10,7 @@ class GitTagPushService < BaseService @push_data = build_push_data EventCreateService.new.push(project, current_user, push_data) - Ci::CreatePipelineService.new(project, current_user, push_data).execute(:push) + Ci::CreatePipelineService.new(project, current_user, push_data).execute(:push, pipeline_options) SystemHooksService.new.execute_hooks(build_system_push_data, :tag_push_hooks) project.execute_hooks(push_data.dup, :tag_push_hooks) @@ -45,7 +45,8 @@ class GitTagPushService < BaseService params[:newrev], params[:ref], commits, - message) + message, + push_options: params[:push_options] || []) end def build_system_push_data @@ -58,4 +59,8 @@ class GitTagPushService < BaseService [], '') end + + def pipeline_options + {} # to be overridden in EE + end end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index 24d8400c625..99ead467f74 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -10,6 +10,8 @@ module Groups def execute @group = Group.new(params) + after_build_hook(@group, params) + unless can_use_visibility_level? && can_create_group? return @group end @@ -30,6 +32,10 @@ module Groups private + def after_build_hook(group, params) + # overridden in EE + end + def create_chat_team? Gitlab.config.mattermost.enabled && @chat_team && group.chat_team.nil? end diff --git a/app/services/groups/nested_create_service.rb b/app/services/groups/nested_create_service.rb index f01f5656296..01bd685712b 100644 --- a/app/services/groups/nested_create_service.rb +++ b/app/services/groups/nested_create_service.rb @@ -12,7 +12,7 @@ module Groups end def execute - return nil unless group_path + return unless group_path if namespace = namespace_or_group(group_path) return namespace diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index f64e327416a..94185605ab9 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -35,7 +35,10 @@ module Groups def proceed_to_transfer Group.transaction do update_group_attributes + ensure_ownership end + + true end def ensure_allowed_transfer @@ -95,6 +98,13 @@ module Groups end # rubocop: enable CodeReuse/ActiveRecord + def ensure_ownership + return if @new_parent_group + return unless @group.owners.empty? + + @group.add_owner(current_user) + end + def raise_transfer_error(message) raise TransferError, ERROR_MESSAGES[message] end diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index 31d3c844ad5..787445180f0 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -11,6 +11,8 @@ module Groups return false unless valid_share_with_group_lock_change? + before_assignment_hook(group, params) + group.assign_attributes(params) begin @@ -28,10 +30,14 @@ module Groups private + def before_assignment_hook(group, params) + # overridden in EE + end + def after_update if group.previous_changes.include?(:visibility_level) && group.private? # don't enqueue immediately to prevent todos removal in case of a mistake - TodosDestroyer::GroupPrivateWorker.perform_in(1.hour, group.id) + TodosDestroyer::GroupPrivateWorker.perform_in(Todo::WAIT_FOR_DELETE, group.id) end end diff --git a/app/services/import/base_service.rb b/app/services/import/base_service.rb new file mode 100644 index 00000000000..2683c75e41f --- /dev/null +++ b/app/services/import/base_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Import + class BaseService < ::BaseService + def initialize(client, user, params) + @client = client + @current_user = user + @params = params + end + + private + + def find_or_create_namespace(namespace, owner) + namespace = params[:target_namespace].presence || namespace + + return current_user.namespace if namespace == owner + + group = Groups::NestedCreateService.new(current_user, group_path: namespace).execute + + group.errors.any? ? current_user.namespace : group + rescue => e + Gitlab::AppLogger.error(e) + + current_user.namespace + end + + def project_save_error(project) + project.errors.full_messages.join(', ') + end + + def success(project) + super().merge(project: project, status: :success) + end + end +end diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb new file mode 100644 index 00000000000..a2533683da9 --- /dev/null +++ b/app/services/import/github_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Import + class GithubService < Import::BaseService + attr_accessor :client + attr_reader :params, :current_user + + def execute(access_params, provider) + unless authorized? + return error('This namespace has already been taken! Please choose another one.', :unprocessable_entity) + end + + project = Gitlab::LegacyGithubImport::ProjectCreator + .new(repo, project_name, target_namespace, current_user, access_params, type: provider) + .execute(extra_project_attrs) + + if project.persisted? + success(project) + else + error(project_save_error(project), :unprocessable_entity) + end + end + + def repo + @repo ||= client.repo(params[:repo_id].to_i) + end + + def project_name + @project_name ||= params[:new_name].presence || repo.name + end + + def namespace_path + @namespace_path ||= params[:target_namespace].presence || current_user.namespace_path + end + + def target_namespace + @target_namespace ||= find_or_create_namespace(namespace_path, current_user.namespace_path) + end + + def extra_project_attrs + {} + end + + def authorized? + can?(current_user, :create_projects, target_namespace) + end + end +end diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb index 885e14bba8f..77f38f8882e 100644 --- a/app/services/issuable/common_system_notes_service.rb +++ b/app/services/issuable/common_system_notes_service.rb @@ -20,7 +20,7 @@ module Issuable create_due_date_note if issuable.previous_changes.include?('due_date') create_milestone_note if issuable.previous_changes.include?('milestone_id') - create_labels_note(old_labels) if issuable.labels != old_labels + create_labels_note(old_labels) if old_labels && issuable.labels != old_labels end private diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index c7e7bb55e4b..f35ad2a9d8b 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -61,10 +61,10 @@ class IssuableBaseService < BaseService return unless milestone_id params[:milestone_id] = '' if milestone_id == IssuableFinder::NONE - group_ids = project.group&.self_and_ancestors&.pluck(:id) + groups = project.group&.self_and_ancestors&.select(:id) milestone = - Milestone.for_projects_and_groups([project.id], group_ids).find_by_id(milestone_id) + Milestone.for_projects_and_groups([project.id], groups).find_by_id(milestone_id) params[:milestone_id] = '' unless milestone end @@ -89,7 +89,7 @@ class IssuableBaseService < BaseService return unless labels - params[:label_ids] = labels.split(",").map do |label_name| + params[:label_ids] = labels.map do |label_name| label = Labels::FindOrCreateService.new( current_user, parent, @@ -235,6 +235,61 @@ class IssuableBaseService < BaseService issuable end + def update_task(issuable) + filter_params(issuable) + + if issuable.changed? || params.present? + issuable.assign_attributes(params.merge(updated_by: current_user, + last_edited_at: Time.now, + last_edited_by: current_user)) + + before_update(issuable) + + if issuable.with_transaction_returning_status { issuable.save } + # We do not touch as it will affect a update on updated_at field + ActiveRecord::Base.no_touching do + Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: nil) + end + + handle_task_changes(issuable) + invalidate_cache_counts(issuable, users: issuable.assignees.to_a) + after_update(issuable) + execute_hooks(issuable, 'update', old_associations: nil) + end + end + + issuable + end + + # Handle the `update_task` event sent from UI. Attempts to update a specific + # line in the markdown and cached html, bypassing any unnecessary updates or checks. + def update_task_event(issuable) + update_task_params = params.delete(:update_task) + return unless update_task_params + + tasklist_toggler = TaskListToggleService.new(issuable.description, issuable.description_html, + line_source: update_task_params[:line_source], + line_number: update_task_params[:line_number].to_i, + toggle_as_checked: update_task_params[:checked]) + + unless tasklist_toggler.execute + # if we make it here, the data is much newer than we thought it was - fail fast + raise ActiveRecord::StaleObjectError + end + + # by updating the description_html field at the same time, + # the markdown cache won't be considered invalid + params[:description] = tasklist_toggler.updated_markdown + params[:description_html] = tasklist_toggler.updated_markdown_html + + # since we're updating a very specific line, we don't care whether + # the `lock_version` sent from the FE is the same or not. Just + # make sure the data hasn't changed since we queried it + params[:lock_version] = issuable.lock_version + + update_task(issuable) + end + def labels_changing?(old_label_ids, new_label_ids) old_label_ids.sort != new_label_ids.sort end @@ -318,6 +373,10 @@ class IssuableBaseService < BaseService end # override if needed + def handle_task_changes(issuable) + end + + # override if needed def execute_hooks(issuable, action = 'open', params = {}) end @@ -328,4 +387,10 @@ class IssuableBaseService < BaseService def parent project end + + # we need to check this because milestone from milestone_id param is displayed on "new" page + # where private project milestone could leak without this check + def ensure_milestone_available(issuable) + issuable.milestone_id = nil unless issuable.milestone_available? + end end diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 52b45f1b2ce..61615ac2058 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -6,7 +6,9 @@ module Issues def execute filter_resolve_discussion_params - @issue = project.issues.new(issue_params) + @issue = project.issues.new(issue_params).tap do |issue| + ensure_milestone_available(issue) + end end def issue_params_with_info_from_discussions @@ -57,9 +59,11 @@ module Issues end def issue_params - @issue_params ||= issue_params_with_info_from_discussions.merge(whitelisted_issue_params) + @issue_params ||= build_issue_params end + private + def whitelisted_issue_params if can?(current_user, :admin_issue, project) params.slice(:title, :description, :milestone_id) @@ -67,5 +71,9 @@ module Issues params.slice(:title, :description) end end + + def build_issue_params + issue_params_with_info_from_discussions.merge(whitelisted_issue_params) + end end end diff --git a/app/services/issues/import_csv_service.rb b/app/services/issues/import_csv_service.rb new file mode 100644 index 00000000000..ef08fafa7cc --- /dev/null +++ b/app/services/issues/import_csv_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Issues + class ImportCsvService + def initialize(user, project, csv_io) + @user = user + @project = project + @csv_io = csv_io + @results = { success: 0, error_lines: [], parse_error: false } + end + + def execute + process_csv + email_results_to_user + + @results + end + + private + + def process_csv + csv_data = @csv_io.open(&:read).force_encoding(Encoding::UTF_8) + + CSV.new(csv_data, col_sep: detect_col_sep(csv_data.lines.first), headers: true).each.with_index(2) do |row, line_no| + issue = Issues::CreateService.new(@project, @user, title: row[0], description: row[1]).execute + + if issue.persisted? + @results[:success] += 1 + else + @results[:error_lines].push(line_no) + end + end + rescue ArgumentError, CSV::MalformedCSVError + @results[:parse_error] = true + end + + def email_results_to_user + Notify.import_issues_csv_email(@user.id, @project.id, @results).deliver_now + end + + def detect_col_sep(header) + if header.include?(",") + "," + elsif header.include?(";") + ";" + elsif header.include?("\t") + "\t" + else + raise CSV::MalformedCSVError + end + end + end +end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index a1d0cc0e568..cec5b5734c0 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -8,7 +8,7 @@ module Issues handle_move_between_ids(issue) filter_spam_check_params change_issue_duplicate(issue) - move_issue_to_new_project(issue) || update(issue) + move_issue_to_new_project(issue) || update_task_event(issue) || update(issue) end def update(issue) @@ -44,7 +44,7 @@ module Issues if issue.previous_changes.include?('confidential') # don't enqueue immediately to prevent todos removal in case of a mistake - TodosDestroyer::ConfidentialIssueWorker.perform_in(1.hour, issue.id) if issue.confidential? + TodosDestroyer::ConfidentialIssueWorker.perform_in(Todo::WAIT_FOR_DELETE, issue.id) if issue.confidential? create_confidentiality_note(issue) end @@ -63,6 +63,11 @@ module Issues end end + def handle_task_changes(issuable) + todo_service.mark_pending_todos_as_done(issuable, current_user) + todo_service.update_issue(issuable, current_user) + end + def handle_move_between_ids(issue) return unless params[:move_between_ids] @@ -78,6 +83,8 @@ module Issues # rubocop: disable CodeReuse/ActiveRecord def change_issue_duplicate(issue) canonical_issue_id = params.delete(:canonical_issue_id) + return unless canonical_issue_id + canonical_issue = IssuesFinder.new(current_user).find_by(id: canonical_issue_id) if canonical_issue diff --git a/app/services/labels/create_service.rb b/app/services/labels/create_service.rb index fe34be41ac1..db710bac900 100644 --- a/app/services/labels/create_service.rb +++ b/app/services/labels/create_service.rb @@ -3,7 +3,7 @@ module Labels class CreateService < Labels::BaseService def initialize(params = {}) - @params = params.dup.with_indifferent_access + @params = params.to_h.dup.with_indifferent_access end # returns the created label diff --git a/app/services/labels/promote_service.rb b/app/services/labels/promote_service.rb index 3c0e6196d4f..e73e6476c12 100644 --- a/app/services/labels/promote_service.rb +++ b/app/services/labels/promote_service.rb @@ -49,7 +49,7 @@ module Labels .new(current_user, title: new_label.title, group_id: project.group.id) .execute(skip_authorization: true) .where.not(id: new_label) - .select(:id) # Can't use pluck() to avoid object-creation because of the batching + .select(:id) # Can't use pluck() to avoid object-creation because of the batching end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/labels/update_service.rb b/app/services/labels/update_service.rb index c3a720a1c66..be33947d0eb 100644 --- a/app/services/labels/update_service.rb +++ b/app/services/labels/update_service.rb @@ -3,11 +3,12 @@ module Labels class UpdateService < Labels::BaseService def initialize(params = {}) - @params = params.dup.with_indifferent_access + @params = params.to_h.dup.with_indifferent_access end # returns the updated label def execute(label) + params[:name] = params.delete(:new_name) if params.key?(:new_name) params[:color] = convert_color_name_to_hex if params[:color].present? label.update(params) diff --git a/app/services/lfs/locks_finder_service.rb b/app/services/lfs/locks_finder_service.rb index 4a5b2a52921..192ce3d3c2a 100644 --- a/app/services/lfs/locks_finder_service.rb +++ b/app/services/lfs/locks_finder_service.rb @@ -12,7 +12,7 @@ module Lfs # rubocop: disable CodeReuse/ActiveRecord def find_locks - options = params.slice(:id, :path).compact.symbolize_keys + options = params.slice(:id, :path).to_h.compact.symbolize_keys project.lfs_file_locks.where(options) end diff --git a/app/services/members/base_service.rb b/app/services/members/base_service.rb index d734571f835..e78affff797 100644 --- a/app/services/members/base_service.rb +++ b/app/services/members/base_service.rb @@ -47,5 +47,11 @@ module Members raise "Unknown action '#{action}' on #{member}!" end end + + def enqueue_delete_todos(member) + type = member.is_a?(GroupMember) ? 'Group' : 'Project' + # don't enqueue immediately to prevent todos removal in case of a mistake + TodosDestroyer::EntityLeaveWorker.perform_in(Todo::WAIT_FOR_DELETE, member.user_id, member.source_id, type) + end end end diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 714b8586737..cf710fef52b 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -19,9 +19,19 @@ module Members current_user: current_user ) - members.each { |member| after_execute(member: member) } + errors = [] - success + members.each do |member| + if member.errors.any? + errors << "#{member.user.username}: #{member.errors.full_messages.to_sentence}" + else + after_execute(member: member) + end + end + + return success unless errors.any? + + error(errors.to_sentence) end private diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb index c186a5971dc..f9717a9426b 100644 --- a/app/services/members/destroy_service.rb +++ b/app/services/members/destroy_service.rb @@ -2,9 +2,11 @@ module Members class DestroyService < Members::BaseService - def execute(member, skip_authorization: false) + def execute(member, skip_authorization: false, skip_subresources: false) raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_destroy_member?(member) + @skip_auth = skip_authorization + return member if member.is_a?(GroupMember) && member.source.last_owner?(member.user) member.destroy @@ -15,7 +17,8 @@ module Members notification_service.decline_access_request(member) end - enqeue_delete_todos(member) + delete_subresources(member) unless skip_subresources + enqueue_delete_todos(member) after_execute(member: member) @@ -24,10 +27,27 @@ module Members private - def enqeue_delete_todos(member) - type = member.is_a?(GroupMember) ? 'Group' : 'Project' - # don't enqueue immediately to prevent todos removal in case of a mistake - TodosDestroyer::EntityLeaveWorker.perform_in(1.hour, member.user_id, member.source_id, type) + def delete_subresources(member) + return unless member.is_a?(GroupMember) && member.user && member.group + + delete_project_members(member) + delete_subgroup_members(member) if Group.supports_nested_objects? + end + + def delete_project_members(member) + groups = member.group.self_and_descendants + + ProjectMember.in_namespaces(groups).with_user(member.user).each do |project_member| + self.class.new(current_user).execute(project_member, skip_authorization: @skip_auth) + end + end + + def delete_subgroup_members(member) + groups = member.group.descendants + + GroupMember.in_groups(groups).with_user(member.user).each do |group_member| + self.class.new(current_user).execute(group_member, skip_authorization: @skip_auth, skip_subresources: true) + end end def can_destroy_member?(member) diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb index 1f5618dae53..ff8d5c1d8c9 100644 --- a/app/services/members/update_service.rb +++ b/app/services/members/update_service.rb @@ -10,9 +10,18 @@ module Members if member.update(params) after_execute(action: permission, old_access_level: old_access_level, member: member) + + # Deletes only confidential issues todos for guests + enqueue_delete_todos(member) if downgrading_to_guest? end member end + + private + + def downgrading_to_guest? + params[:access_level] == Gitlab::Access::GUEST + end end end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index fe19abf50f6..11ede5223e5 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -63,10 +63,11 @@ module MergeRequests # UpdateMergeRequestsWorker could be retried by an exception. # MR pipelines should not be recreated in such case. return if merge_request.merge_request_pipeline_exists? + return if merge_request.has_no_commits? Ci::CreatePipelineService .new(merge_request.source_project, user, ref: merge_request.source_branch) - .execute(:merge_request, + .execute(:merge_request_event, ignore_skip_ci: true, save_on_errors: false, merge_request: merge_request) diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index 36767621d74..109c964e577 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -18,7 +18,8 @@ module MergeRequests merge_request.source_project = find_source_project merge_request.target_project = find_target_project merge_request.target_branch = find_target_branch - merge_request.can_be_created = branches_valid? + merge_request.can_be_created = projects_and_branches_valid? + ensure_milestone_available(merge_request) # compare branches only if branches are valid, otherwise # compare_branches may raise an error @@ -49,15 +50,19 @@ module MergeRequests to: :merge_request def find_source_project - return source_project if source_project.present? && can?(current_user, :read_project, source_project) + return source_project if source_project.present? && can?(current_user, :create_merge_request_from, source_project) project end def find_target_project - return target_project if target_project.present? && can?(current_user, :read_project, target_project) + return target_project if target_project.present? && can?(current_user, :create_merge_request_in, target_project) - project.default_merge_request_target + target_project = project.default_merge_request_target + + return target_project if target_project.present? && can?(current_user, :create_merge_request_in, target_project) + + project end def find_target_branch @@ -72,10 +77,11 @@ module MergeRequests params[:target_branch].present? end - def branches_valid? + def projects_and_branches_valid? + return false if source_project.nil? || target_project.nil? return false unless source_branch_specified? || target_branch_specified? - validate_branches + validate_projects_and_branches errors.blank? end @@ -94,7 +100,12 @@ module MergeRequests end end - def validate_branches + def validate_projects_and_branches + merge_request.validate_target_project + merge_request.validate_fork + + return if errors.any? + add_error('You must select source and target branch') unless branches_present? add_error('You must select different branches') if same_source_and_target? add_error("Source branch \"#{source_branch}\" does not exist") unless source_branch_exists? diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 7bb9fa60515..02c2388c05c 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -26,7 +26,7 @@ module MergeRequests todo_service.new_merge_request(issuable, current_user) issuable.cache_merge_request_closes_issues!(current_user) create_merge_request_pipeline(issuable, current_user) - update_merge_requests_head_pipeline(issuable) + issuable.update_head_pipeline super end @@ -45,20 +45,6 @@ module MergeRequests private - def update_merge_requests_head_pipeline(merge_request) - pipeline = head_pipeline_for(merge_request) - merge_request.update(head_pipeline_id: pipeline.id) if pipeline - end - - def head_pipeline_for(merge_request) - return unless merge_request.source_project - - sha = merge_request.source_branch_sha - return unless sha - - merge_request.all_pipelines(shas: sha).first - end - def set_projects! # @project is used to determine whether the user can set the merge request's # assignee, milestone and labels. Whether they can depends on their diff --git a/app/services/merge_requests/merge_base_service.rb b/app/services/merge_requests/merge_base_service.rb new file mode 100644 index 00000000000..095bdca5472 --- /dev/null +++ b/app/services/merge_requests/merge_base_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module MergeRequests + class MergeBaseService < MergeRequests::BaseService + include Gitlab::Utils::StrongMemoize + + MergeError = Class.new(StandardError) + + attr_reader :merge_request + + # Overridden in EE. + def hooks_validation_pass?(_merge_request) + true + end + + # Overridden in EE. + def hooks_validation_error(_merge_request) + # No-op + end + + def source + if merge_request.squash + squash_sha! + else + merge_request.diff_head_sha + end + end + + private + + # Overridden in EE. + def error_check! + # No-op + end + + def raise_error(message) + raise MergeError, message + end + + def handle_merge_error(*args) + # No-op + end + + def commit_message + params[:commit_message] || + merge_request.default_merge_commit_message + end + + def squash_sha! + strong_memoize(:squash_sha) do + params[:merge_request] = merge_request + squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute + + case squash_result[:status] + when :success + squash_result[:squash_sha] + when :error + raise ::MergeRequests::MergeService::MergeError, squash_result[:message] + end + end + end + end +end diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 70a67baa01c..b29e0b1759e 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -7,11 +7,7 @@ module MergeRequests # mark merge request as merged and execute all hooks and notifications # Executed when you do merge via GitLab UI # - class MergeService < MergeRequests::BaseService - MergeError = Class.new(StandardError) - - attr_reader :merge_request, :source - + class MergeService < MergeRequests::MergeBaseService delegate :merge_jid, :state, to: :@merge_request def execute(merge_request) @@ -22,7 +18,7 @@ module MergeRequests @merge_request = merge_request - error_check! + validate! merge_request.in_locked_state do if commit @@ -36,27 +32,22 @@ module MergeRequests handle_merge_error(log_message: e.message, save_message_on_model: true) end - def source - return merge_request.diff_head_sha unless merge_request.squash - - squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute(merge_request) + private - case squash_result[:status] - when :success - squash_result[:squash_sha] - when :error - raise ::MergeRequests::MergeService::MergeError, squash_result[:message] - end + def validate! + authorization_check! + error_check! end - # Overridden in EE. - def hooks_validation_pass?(_merge_request) - true + def authorization_check! + unless @merge_request.can_be_merged_by?(current_user) + raise_error('You are not allowed to merge this merge request') + end end - private - def error_check! + super + error = if @merge_request.should_be_rebased? 'Only fast-forward merge is allowed for your project. Please update your source branch' @@ -66,7 +57,7 @@ module MergeRequests 'No source for merge' end - raise MergeError, error if error + raise_error(error) if error end def commit @@ -76,22 +67,19 @@ module MergeRequests if commit_id log_info("Git merge finished on JID #{merge_jid} commit #{commit_id}") else - raise MergeError, 'Conflicts detected during merge' + raise_error('Conflicts detected during merge') end merge_request.update!(merge_commit_sha: commit_id) end def try_merge - message = params[:commit_message] || merge_request.merge_commit_message - - repository.merge(current_user, source, merge_request, message) + repository.merge(current_user, source, merge_request, commit_message) rescue Gitlab::Git::PreReceiveError => e - handle_merge_error(log_message: e.message) - raise MergeError, 'Something went wrong during merge pre-receive hook' + raise_error("Something went wrong during merge pre-receive hook: #{e.message}") rescue => e handle_merge_error(log_message: e.message) - raise MergeError, 'Something went wrong during merge' + raise_error('Something went wrong during merge') ensure merge_request.update!(in_progress_merge_commit_sha: nil) end diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb new file mode 100644 index 00000000000..586652ae44e --- /dev/null +++ b/app/services/merge_requests/merge_to_ref_service.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module MergeRequests + # Performs the merge between source SHA and the target branch. Instead + # of writing the result to the MR target branch, it targets the `target_ref`. + # + # Ideally this should leave the `target_ref` state with the same state the + # target branch would have if we used the regular `MergeService`, but without + # every side-effect that comes with it (MR updates, mails, source branch + # deletion, etc). This service should be kept idempotent (i.e. can + # be executed regardless of the `target_ref` current state). + # + class MergeToRefService < MergeRequests::MergeBaseService + def execute(merge_request) + @merge_request = merge_request + + validate! + + commit_id = commit + + raise_error('Conflicts detected during merge') unless commit_id + + success(commit_id: commit_id) + rescue MergeError => error + error(error.message) + end + + private + + def validate! + authorization_check! + error_check! + end + + def error_check! + super + + error = + if Feature.disabled?(:merge_to_tmp_merge_ref_path, project) + 'Feature is not enabled' + elsif !merge_method_supported? + "#{project.human_merge_method} to #{target_ref} is currently not supported." + elsif !hooks_validation_pass?(merge_request) + hooks_validation_error(merge_request) + elsif @merge_request.should_be_rebased? + 'Fast-forward merge is not possible. Please update your source branch.' + elsif !@merge_request.mergeable_to_ref? + "Merge request is not mergeable to #{target_ref}" + elsif !source + 'No source for merge' + end + + raise_error(error) if error + end + + def authorization_check! + unless Ability.allowed?(current_user, :admin_merge_request, project) + raise_error("You are not allowed to merge to this ref") + end + end + + def target_ref + merge_request.merge_ref_path + end + + def commit + repository.merge_to_ref(current_user, source, merge_request, target_ref, commit_message) + rescue Gitlab::Git::PreReceiveError => error + raise MergeError, error.message + end + + def merge_method_supported? + [:merge, :rebase_merge].include?(project.merge_method) + end + end +end diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb index a439a380255..9d1a5d5e6d4 100644 --- a/app/services/merge_requests/squash_service.rb +++ b/app/services/merge_requests/squash_service.rb @@ -2,15 +2,10 @@ module MergeRequests class SquashService < MergeRequests::WorkingCopyBaseService - def execute(merge_request) - @merge_request = merge_request - @repository = target_project.repository - - squash || error('Failed to squash. Should be done manually.') - end - - def squash - if merge_request.commits_count < 2 + def execute + # If performing a squash would result in no change, then + # immediately return a success message without performing a squash + if merge_request.commits_count < 2 && message.nil? return success(squash_sha: merge_request.diff_head_sha) end @@ -18,7 +13,13 @@ module MergeRequests return error('Squash task canceled: another squash is already in progress.') end - squash_sha = repository.squash(current_user, merge_request) + squash! || error('Failed to squash. Should be done manually.') + end + + private + + def squash! + squash_sha = repository.squash(current_user, merge_request, message || merge_request.default_squash_commit_message) success(squash_sha: squash_sha) rescue => e @@ -26,5 +27,17 @@ module MergeRequests log_error(e.message) false end + + def repository + target_project.repository + end + + def merge_request + params[:merge_request] + end + + def message + params[:squash_commit_message].presence + end end end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 33d8299c8b6..8112c2a4299 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -21,7 +21,7 @@ module MergeRequests end handle_wip_event(merge_request) - update(merge_request) + update_task_event(merge_request) || update(merge_request) end # rubocop:disable Metrics/AbcSize @@ -46,11 +46,13 @@ module MergeRequests end if merge_request.previous_changes.include?('assignee_id') + reassigned_merge_request_args = [merge_request, current_user] + old_assignee_id = merge_request.previous_changes['assignee_id'].first - old_assignee = User.find(old_assignee_id) if old_assignee_id + reassigned_merge_request_args << User.find(old_assignee_id) if old_assignee_id create_assignee_note(merge_request) - notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignee) + notification_service.async.reassigned_merge_request(*reassigned_merge_request_args) todo_service.reassigned_merge_request(merge_request, current_user) end @@ -81,6 +83,11 @@ module MergeRequests end # rubocop:enable Metrics/AbcSize + def handle_task_changes(merge_request) + todo_service.mark_pending_todos_as_done(merge_request, current_user) + todo_service.update_merge_request(merge_request, current_user) + end + def merge_from_quick_action(merge_request) last_diff_sha = params.delete(:merge) return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha) diff --git a/app/services/milestones/promote_service.rb b/app/services/milestones/promote_service.rb index 39071b5dc14..cbe5996e8ca 100644 --- a/app/services/milestones/promote_service.rb +++ b/app/services/milestones/promote_service.rb @@ -82,11 +82,9 @@ module Milestones end # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord def group_project_ids - @group_project_ids ||= group.projects.pluck(:id) + group.projects.select(:id) end - # rubocop: enable CodeReuse/ActiveRecord def raise_error(message) raise PromoteMilestoneError, "Promotion failed - #{message}" diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb index 7b92fe6fe14..541f3e0d23c 100644 --- a/app/services/notes/build_service.rb +++ b/app/services/notes/build_service.rb @@ -9,12 +9,14 @@ module Notes if in_reply_to_discussion_id.present? discussion = find_discussion(in_reply_to_discussion_id) - unless discussion + unless discussion && can?(current_user, :create_note, discussion.noteable) note = Note.new note.errors.add(:base, 'Discussion to reply to cannot be found') return note end + discussion = discussion.convert_to_discussion! if discussion.can_convert_to_discussion? + params.merge!(discussion.reply_attributes) should_resolve = discussion.resolved? end @@ -34,19 +36,8 @@ module Notes if project project.notes.find_discussion(discussion_id) else - discussion = Note.find_discussion(discussion_id) - noteable = discussion.noteable - - return nil unless noteable_without_project?(noteable) - - discussion + Note.find_discussion(discussion_id) end end - - def noteable_without_project?(noteable) - return true if noteable.is_a?(PersonalSnippet) && can?(current_user, :comment_personal_snippet, noteable) - - false - end end end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index c4546f30235..1b46f6d8a72 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -21,7 +21,7 @@ module Notes if quick_actions_service.supported?(note) options = { merge_request_diff_head_sha: merge_request_diff_head_sha } - content, command_params = quick_actions_service.extract_commands(note, options) + content, update_params = quick_actions_service.execute(note, options) only_commands = content.empty? @@ -34,21 +34,26 @@ module Notes end if !only_commands && note.save + if note.part_of_discussion? && note.discussion.can_convert_to_discussion? + note.discussion.convert_to_discussion!(save: true) + end + todo_service.new_note(note, current_user) clear_noteable_diffs_cache(note) Suggestions::CreateService.new(note).execute end - if command_params.present? - quick_actions_service.execute(command_params, note) + if quick_actions_service.commands_executed_count.to_i > 0 + if update_params.present? + quick_actions_service.apply_updates(update_params, note) + note.commands_changes = update_params + end # We must add the error after we call #save because errors are reset # when #save is called if only_commands note.errors.add(:commands_only, 'Commands applied') end - - note.commands_changes = command_params end note diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb index 4c14d834949..0852a708240 100644 --- a/app/services/notes/quick_actions_service.rb +++ b/app/services/notes/quick_actions_service.rb @@ -1,15 +1,31 @@ # frozen_string_literal: true +# QuickActionsService class +# +# Executes quick actions commands extracted from note text +# +# Most commands returns parameters to be applied later +# using QuickActionService#apply_updates +# module Notes class QuickActionsService < BaseService + attr_reader :interpret_service + + delegate :commands_executed_count, to: :interpret_service, allow_nil: true + UPDATE_SERVICES = { 'Issue' => Issues::UpdateService, 'MergeRequest' => MergeRequests::UpdateService, 'Commit' => Commits::TagService }.freeze + private_constant :UPDATE_SERVICES + + def self.update_services + UPDATE_SERVICES + end def self.noteable_update_service(note) - UPDATE_SERVICES[note.noteable_type] + update_services[note.noteable_type] end def self.supported?(note) @@ -20,18 +36,21 @@ module Notes self.class.supported?(note) end - def extract_commands(note, options = {}) + def execute(note, options = {}) return [note.note, {}] unless supported?(note) - QuickActions::InterpretService.new(project, current_user, options) - .execute(note.note, note.noteable) + @interpret_service = QuickActions::InterpretService.new(project, current_user, options) + + @interpret_service.execute(note.note, note.noteable) end - def execute(command_params, note) - return if command_params.empty? + # Applies updates extracted to note#noteable + # The update parameters are extracted on self#execute + def apply_updates(update_params, note) + return if update_params.empty? return unless supported?(note) - self.class.noteable_update_service(note).new(project, current_user, command_params).execute(note.noteable) + self.class.noteable_update_service(note).new(note.parent, current_user, update_params).execute(note.noteable) end end end diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index 68cdc69023a..56f11b31110 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -249,6 +249,7 @@ module NotificationRecipientService attr_reader :action attr_reader :previous_assignee attr_reader :skip_current_user + def initialize(target, current_user, action:, custom_action: nil, previous_assignee: nil, skip_current_user: true) @target = target @current_user = current_user @@ -258,9 +259,13 @@ module NotificationRecipientService @skip_current_user = skip_current_user end + def add_watchers + add_project_watchers + end + def build! add_participants(current_user) - add_project_watchers + add_watchers add_custom_notifications # Re-assign is considered as a mention of the new assignee diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index ff035fea216..1a65561dd70 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -188,7 +188,7 @@ class NotificationService # * merge_request assignee if their notification level is not Disabled # * users with custom level checked with "reassign merge request" # - def reassigned_merge_request(merge_request, current_user, previous_assignee) + def reassigned_merge_request(merge_request, current_user, previous_assignee = nil) recipients = NotificationRecipientService.build_recipients( merge_request, current_user, @@ -373,7 +373,8 @@ class NotificationService end def project_was_moved(project, old_path_with_namespace) - recipients = notifiable_users(project.team.members, :mention, project: project) + recipients = project.private? ? project.team.members_in_project_and_ancestors : project.team.members + recipients = notifiable_users(recipients, :mention, project: project) recipients.each do |recipient| mailer.project_was_moved_email( diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index a449a5dc3e9..c1655c38095 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -10,8 +10,7 @@ class PreviewMarkdownService < BaseService text: text, users: users, suggestions: suggestions, - commands: commands.join(' '), - markdown_engine: markdown_engine + commands: commands.join(' ') ) end @@ -49,12 +48,4 @@ class PreviewMarkdownService < BaseService def commands_target_id params[:quick_actions_target_id] end - - def markdown_engine - if params[:legacy_render] - :redcarpet - else - CacheMarkdownField::MarkdownEngine.from_version(params[:markdown_version].to_i) - end - end end diff --git a/app/services/projects/after_rename_service.rb b/app/services/projects/after_rename_service.rb index aa9b253eb20..fafdecb3222 100644 --- a/app/services/projects/after_rename_service.rb +++ b/app/services/projects/after_rename_service.rb @@ -12,22 +12,27 @@ module Projects # # Projects::AfterRenameService.new(project).execute class AfterRenameService - attr_reader :project, :full_path_before, :full_path_after, :path_before + # @return [String] The Project being renamed. + attr_reader :project - RenameFailedError = Class.new(StandardError) + # @return [String] The path slug the project was using, before the rename took place. + attr_reader :path_before - # @param [Project] project The Project of the repository to rename. - def initialize(project) - @project = project + # @return [String] The full path of the namespace + project, before the rename took place. + attr_reader :full_path_before - # The full path of the namespace + project, before the rename took place. - @full_path_before = project.full_path_was + # @return [String] The full path of the namespace + project, after the rename took place. + attr_reader :full_path_after - # The full path of the namespace + project, after the rename took place. - @full_path_after = project.build_full_path + RenameFailedError = Class.new(StandardError) - # The path of just the project, before the rename took place. - @path_before = project.path_was + # @param [Project] project The Project being renamed. + # @param [String] path_before The path slug the project was using, before the rename took place. + def initialize(project, path_before:, full_path_before:) + @project = project + @path_before = path_before + @full_path_before = full_path_before + @full_path_after = project.full_path end def execute @@ -57,11 +62,11 @@ module Projects def rename_or_migrate_repository! success = if migrate_to_hashed_storage? - ::Projects::HashedStorageMigrationService + ::Projects::HashedStorage::MigrationService .new(project, full_path_before) .execute else - project.storage.rename_repo + project.storage.rename_repo(old_full_path: full_path_before, new_full_path: full_path_after) end rename_failed! unless success diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index 61f6402a810..3dad90188cf 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -14,7 +14,7 @@ module Projects order: { due_date: :asc, title: :asc } } - finder_params[:group_ids] = @project.group.self_and_ancestors_ids if @project.group + finder_params[:group_ids] = @project.group.self_and_ancestors.select(:id) if @project.group MilestonesFinder.new(finder_params).execute.select([:iid, :title]) end diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb new file mode 100644 index 00000000000..488290db824 --- /dev/null +++ b/app/services/projects/container_repository/cleanup_tags_service.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Projects + module ContainerRepository + class CleanupTagsService < BaseService + def execute(container_repository) + return error('feature disabled') unless can_use? + return error('access denied') unless can_admin? + + tags = container_repository.tags + tags_by_digest = group_by_digest(tags) + + tags = without_latest(tags) + tags = filter_by_name(tags) + tags = with_manifest(tags) + tags = order_by_date(tags) + tags = filter_keep_n(tags) + tags = filter_by_older_than(tags) + + deleted_tags = delete_tags(tags, tags_by_digest) + + success(deleted: deleted_tags.map(&:name)) + end + + private + + def delete_tags(tags_to_delete, tags_by_digest) + deleted_digests = group_by_digest(tags_to_delete).select do |digest, tags| + delete_tag_digest(digest, tags, tags_by_digest[digest]) + end + + deleted_digests.values.flatten + end + + def delete_tag_digest(digest, tags, other_tags) + # Issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/21405 + # we have to remove all tags due + # to Docker Distribution bug unable + # to delete single tag + return unless tags.count == other_tags.count + + # delete all tags + tags.map(&:delete) + end + + def group_by_digest(tags) + tags.group_by(&:digest) + end + + def without_latest(tags) + tags.reject(&:latest?) + end + + def with_manifest(tags) + tags.select(&:valid?) + end + + def order_by_date(tags) + now = DateTime.now + tags.sort_by { |tag| tag.created_at || now }.reverse + end + + def filter_by_name(tags) + regex = Gitlab::UntrustedRegexp.new("\\A#{params['name_regex']}\\z") + + tags.select do |tag| + regex.scan(tag.name).any? + end + end + + def filter_keep_n(tags) + tags.drop(params['keep_n'].to_i) + end + + def filter_by_older_than(tags) + return tags unless params['older_than'] + + older_than = ChronicDuration.parse(params['older_than']).seconds.ago + + tags.select do |tag| + tag.created_at && tag.created_at < older_than + end + end + + def can_admin? + can?(current_user, :admin_container_image, project) + end + + def can_use? + Feature.enabled?(:container_registry_cleanup, project, default_enabled: true) + end + end + end +end diff --git a/app/services/projects/create_from_template_service.rb b/app/services/projects/create_from_template_service.rb index 8306d43ca7c..678bc0d24c3 100644 --- a/app/services/projects/create_from_template_service.rb +++ b/app/services/projects/create_from_template_service.rb @@ -5,7 +5,7 @@ module Projects include Gitlab::Utils::StrongMemoize def initialize(user, params) - @current_user, @params = user, params.dup + @current_user, @params = user, params.to_h.dup end def execute diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 336d029d330..b14b31302f5 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -7,9 +7,16 @@ module Projects DestroyError = Class.new(StandardError) DELETED_FLAG = '+deleted'.freeze + REPO_REMOVAL_DELAY = 5.minutes.to_i def async_execute project.update_attribute(:pending_delete, true) + + # Ensure no repository +deleted paths are kept, + # regardless of any issue with the ProjectDestroyWorker + # job process. + schedule_stale_repos_removal + job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params) Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.full_path} with job ID #{job_id}") end @@ -92,14 +99,23 @@ module Projects log_info(%Q{Repository "#{path}" moved to "#{new_path}" for project "#{project.full_path}"}) project.run_after_commit do - # self is now project - GitlabShellWorker.perform_in(5.minutes, :remove_repository, self.repository_storage, new_path) + GitlabShellWorker.perform_in(REPO_REMOVAL_DELAY, :remove_repository, self.repository_storage, new_path) end else false end end + def schedule_stale_repos_removal + repo_paths = [removal_path(repo_path), removal_path(wiki_path)] + + # Ideally it should wait until the regular removal phase finishes, + # so let's delay it a bit further. + repo_paths.each do |path| + GitlabShellWorker.perform_in(REPO_REMOVAL_DELAY * 2, :remove_repository, project.repository_storage, path) + end + end + def rollback_repository(old_path, new_path) # There is a possibility project does not have repository or wiki return true unless repo_exists?(old_path) @@ -113,13 +129,11 @@ module Projects end # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord def mv_repository(from_path, to_path) - return true unless gitlab_shell.exists?(project.repository_storage, from_path + '.git') + return true unless repo_exists?(from_path) gitlab_shell.mv_repository(project.repository_storage, from_path, to_path) end - # rubocop: enable CodeReuse/ActiveRecord def attempt_rollback(project, message) return unless project diff --git a/app/services/projects/download_service.rb b/app/services/projects/download_service.rb index dd297c9ba43..aba175eb79b 100644 --- a/app/services/projects/download_service.rb +++ b/app/services/projects/download_service.rb @@ -11,7 +11,7 @@ module Projects end def execute - return nil unless valid_url?(@url) + return unless valid_url?(@url) uploader = FileUploader.new(@project) uploader.download!(@url) diff --git a/app/services/projects/fetch_statistics_increment_service.rb b/app/services/projects/fetch_statistics_increment_service.rb new file mode 100644 index 00000000000..8644e6bf313 --- /dev/null +++ b/app/services/projects/fetch_statistics_increment_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Projects + class FetchStatisticsIncrementService + attr_reader :project + + def initialize(project) + @project = project + end + + def execute + increment_fetch_count_sql = <<~SQL + INSERT INTO #{table_name} (project_id, date, fetch_count) + VALUES (#{project.id}, '#{Date.today}', 1) + SQL + + increment_fetch_count_sql += if Gitlab::Database.postgresql? + "ON CONFLICT (project_id, date) DO UPDATE SET fetch_count = #{table_name}.fetch_count + 1" + else + "ON DUPLICATE KEY UPDATE fetch_count = #{table_name}.fetch_count + 1" + end + + ActiveRecord::Base.connection.execute(increment_fetch_count_sql) + end + + private + + def table_name + ProjectDailyStatistic.table_name + end + end +end diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index 91091c4393d..fc234bafc57 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -38,8 +38,8 @@ module Projects new_params = { visibility_level: allowed_visibility_level, description: @project.description, - name: @project.name, - path: @project.path, + name: target_name, + path: target_path, shared_runners_enabled: @project.shared_runners_enabled, namespace_id: target_namespace.id, fork_network: fork_network, @@ -94,6 +94,14 @@ module Projects Projects::ForksCountService.new(@project).refresh_cache end + def target_path + @target_path ||= @params[:path] || @project.path + end + + def target_name + @target_name ||= @params[:name] || @project.name + end + def target_namespace @target_namespace ||= @params[:namespace] || current_user.namespace end diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb index 1392775f805..e3d5bea0852 100644 --- a/app/services/projects/group_links/create_service.rb +++ b/app/services/projects/group_links/create_service.rb @@ -4,13 +4,19 @@ module Projects module GroupLinks class CreateService < BaseService def execute(group) - return false unless group + return error('Not Found', 404) unless group && can?(current_user, :read_namespace, group) - project.project_group_links.create( + link = project.project_group_links.new( group: group, group_access: params[:link_group_access], expires_at: params[:expires_at] ) + + if link.save + success(link: link) + else + error(link.errors.full_messages.to_sentence, 409) + end end end end diff --git a/app/services/projects/hashed_storage/base_attachment_service.rb b/app/services/projects/hashed_storage/base_attachment_service.rb new file mode 100644 index 00000000000..828ab616bab --- /dev/null +++ b/app/services/projects/hashed_storage/base_attachment_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Projects + module HashedStorage + AttachmentMigrationError = Class.new(StandardError) + + AttachmentCannotMoveError = Class.new(StandardError) + + class BaseAttachmentService < BaseService + # Returns the disk_path value before the execution + attr_reader :old_disk_path + + # Returns the disk_path value after the execution + attr_reader :new_disk_path + + # Returns the logger currently in use + attr_reader :logger + + # Return whether this operation was skipped or not + # + # @return [Boolean] true if skipped of false otherwise + def skipped? + @skipped + end + + protected + + def move_folder!(old_path, new_path) + unless File.directory?(old_path) + logger.info("Skipped attachments move from '#{old_path}' to '#{new_path}', source path doesn't exist or is not a directory (PROJECT_ID=#{project.id})") + @skipped = true + + return true + end + + if File.exist?(new_path) + logger.error("Cannot move attachments from '#{old_path}' to '#{new_path}', target path already exist (PROJECT_ID=#{project.id})") + raise AttachmentCannotMoveError, "Target path '#{new_path}' already exists" + end + + # Create base path folder on the new storage layout + FileUtils.mkdir_p(File.dirname(new_path)) + + FileUtils.mv(old_path, new_path) + logger.info("Project attachments moved from '#{old_path}' to '#{new_path}' (PROJECT_ID=#{project.id})") + + true + end + end + end +end diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb new file mode 100644 index 00000000000..f97a28b8c3b --- /dev/null +++ b/app/services/projects/hashed_storage/base_repository_service.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Projects + module HashedStorage + # Returned when repository can't be made read-only because there is already a git transfer in progress + RepositoryInUseError = Class.new(StandardError) + + class BaseRepositoryService < BaseService + include Gitlab::ShellAdapter + + attr_reader :old_disk_path, :new_disk_path, :old_wiki_disk_path, :old_storage_version, :logger, :move_wiki + + def initialize(project, old_disk_path, logger: nil) + @project = project + @logger = logger || Gitlab::AppLogger + @old_disk_path = old_disk_path + @old_wiki_disk_path = "#{old_disk_path}.wiki" + @move_wiki = has_wiki? + end + + protected + + # rubocop: disable CodeReuse/ActiveRecord + def has_wiki? + gitlab_shell.exists?(project.repository_storage, "#{old_wiki_disk_path}.git") + end + # rubocop: enable CodeReuse/ActiveRecord + + # rubocop: disable CodeReuse/ActiveRecord + def move_repository(from_name, to_name) + from_exists = gitlab_shell.exists?(project.repository_storage, "#{from_name}.git") + to_exists = gitlab_shell.exists?(project.repository_storage, "#{to_name}.git") + + # If we don't find the repository on either original or target we should log that as it could be an issue if the + # project was not originally empty. + if !from_exists && !to_exists + logger.warn "Can't find a repository on either source or target paths for #{project.full_path} (ID=#{project.id}) ..." + + # We return true so we still reflect the change in the database. + # Next time the repository is (re)created it will be under the new storage layout + return true + elsif !from_exists + # Repository have been moved already. + return true + end + + gitlab_shell.mv_repository(project.repository_storage, from_name, to_name) + end + # rubocop: enable CodeReuse/ActiveRecord + + def rollback_folder_move + move_repository(new_disk_path, old_disk_path) + move_repository("#{new_disk_path}.wiki", old_wiki_disk_path) + end + + def try_to_set_repository_read_only! + # Mitigate any push operation to start during migration + unless project.set_repository_read_only! + migration_error = "Target repository '#{old_disk_path}' cannot be made read-only as there is a git transfer in progress" + logger.error migration_error + + raise RepositoryInUseError, migration_error + end + end + end + end +end diff --git a/app/services/projects/hashed_storage/migrate_attachments_service.rb b/app/services/projects/hashed_storage/migrate_attachments_service.rb index a1f0302aeb7..9eaeb6eb4e7 100644 --- a/app/services/projects/hashed_storage/migrate_attachments_service.rb +++ b/app/services/projects/hashed_storage/migrate_attachments_service.rb @@ -2,56 +2,37 @@ module Projects module HashedStorage - AttachmentMigrationError = Class.new(StandardError) - - class MigrateAttachmentsService < BaseService - attr_reader :logger, :old_disk_path, :new_disk_path - + class MigrateAttachmentsService < BaseAttachmentService def initialize(project, old_disk_path, logger: nil) @project = project @logger = logger || Rails.logger @old_disk_path = old_disk_path - @new_disk_path = project.disk_path + @skipped = false end def execute origin = FileUploader.absolute_base_dir(project) - # It's possible that old_disk_path does not match project.disk_path. For example, that happens when we rename a project + # It's possible that old_disk_path does not match project.disk_path. + # For example, that happens when we rename a project origin.sub!(/#{Regexp.escape(project.full_path)}\z/, old_disk_path) project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:attachments] target = FileUploader.absolute_base_dir(project) - result = move_folder!(origin, target) - project.save! - - if result && block_given? - yield - end - - result - end + @new_disk_path = project.disk_path - private + result = move_folder!(origin, target) - def move_folder!(old_disk_path, new_disk_path) - unless File.directory?(old_disk_path) - logger.info("Skipped attachments migration from '#{old_disk_path}' to '#{new_disk_path}', source path doesn't exist or is not a directory (PROJECT_ID=#{project.id})") - return - end + if result + project.save! - if File.exist?(new_disk_path) - logger.error("Cannot migrate attachments from '#{old_disk_path}' to '#{new_disk_path}', target path already exist (PROJECT_ID=#{project.id})") - raise AttachmentMigrationError, "Target path '#{new_disk_path}' already exist" + yield if block_given? + else + # Rollback changes + project.rollback! end - # Create hashed storage base path folder - FileUtils.mkdir_p(File.dirname(new_disk_path)) - - FileUtils.mv(old_disk_path, new_disk_path) - logger.info("Migrated project attachments from '#{old_disk_path}' to '#{new_disk_path}' (PROJECT_ID=#{project.id})") - - true + result end end end diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb index f3e026ba38c..5afa8732c0a 100644 --- a/app/services/projects/hashed_storage/migrate_repository_service.rb +++ b/app/services/projects/hashed_storage/migrate_repository_service.rb @@ -2,20 +2,10 @@ module Projects module HashedStorage - class MigrateRepositoryService < BaseService - include Gitlab::ShellAdapter - - attr_reader :old_disk_path, :new_disk_path, :old_wiki_disk_path, :old_storage_version, :logger, :move_wiki - - def initialize(project, old_disk_path, logger: nil) - @project = project - @logger = logger || Rails.logger - @old_disk_path = old_disk_path - @old_wiki_disk_path = "#{old_disk_path}.wiki" - @move_wiki = has_wiki? - end - + class MigrateRepositoryService < BaseRepositoryService def execute + try_to_set_repository_read_only! + @old_storage_version = project.storage_version project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository] project.ensure_storage_path_exists @@ -25,7 +15,7 @@ module Projects result = move_repository(old_disk_path, new_disk_path) if move_wiki - result &&= move_repository("#{old_wiki_disk_path}", "#{new_disk_path}.wiki") + result &&= move_repository(old_wiki_disk_path, "#{new_disk_path}.wiki") end if result @@ -45,38 +35,6 @@ module Projects result end - - private - - # rubocop: disable CodeReuse/ActiveRecord - def has_wiki? - gitlab_shell.exists?(project.repository_storage, "#{old_wiki_disk_path}.git") - end - # rubocop: enable CodeReuse/ActiveRecord - - # rubocop: disable CodeReuse/ActiveRecord - def move_repository(from_name, to_name) - from_exists = gitlab_shell.exists?(project.repository_storage, "#{from_name}.git") - to_exists = gitlab_shell.exists?(project.repository_storage, "#{to_name}.git") - - # If we don't find the repository on either original or target we should log that as it could be an issue if the - # project was not originally empty. - if !from_exists && !to_exists - logger.warn "Can't find a repository on either source or target paths for #{project.full_path} (ID=#{project.id}) ..." - return false - elsif !from_exists - # Repository have been moved already. - return true - end - - gitlab_shell.mv_repository(project.repository_storage, from_name, to_name) - end - # rubocop: enable CodeReuse/ActiveRecord - - def rollback_folder_move - move_repository(new_disk_path, old_disk_path) - move_repository("#{new_disk_path}.wiki", old_wiki_disk_path) - end end end end diff --git a/app/services/projects/hashed_storage/migration_service.rb b/app/services/projects/hashed_storage/migration_service.rb new file mode 100644 index 00000000000..f132dca61c9 --- /dev/null +++ b/app/services/projects/hashed_storage/migration_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Projects + module HashedStorage + class MigrationService < BaseService + attr_reader :logger, :old_disk_path + + def initialize(project, old_disk_path, logger: nil) + @project = project + @old_disk_path = old_disk_path + @logger = logger || Gitlab::AppLogger + end + + def execute + # Migrate repository from Legacy to Hashed Storage + unless project.hashed_storage?(:repository) + return false unless migrate_repository + end + + # Migrate attachments from Legacy to Hashed Storage + unless project.hashed_storage?(:attachments) + return false unless migrate_attachments + end + + true + end + + private + + def migrate_repository + HashedStorage::MigrateRepositoryService.new(project, old_disk_path, logger: logger).execute + end + + def migrate_attachments + HashedStorage::MigrateAttachmentsService.new(project, old_disk_path, logger: logger).execute + end + end + end +end diff --git a/app/services/projects/hashed_storage/rollback_attachments_service.rb b/app/services/projects/hashed_storage/rollback_attachments_service.rb new file mode 100644 index 00000000000..6c370ac47e9 --- /dev/null +++ b/app/services/projects/hashed_storage/rollback_attachments_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Projects + module HashedStorage + class RollbackAttachmentsService < BaseAttachmentService + def initialize(project, logger: nil) + @project = project + @logger = logger || Rails.logger + @old_disk_path = project.disk_path + end + + def execute + origin = FileUploader.absolute_base_dir(project) + project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository] + target = FileUploader.absolute_base_dir(project) + + @new_disk_path = FileUploader.base_dir(project) + + result = move_folder!(origin, target) + + if result + project.save! + + yield if block_given? + else + # Rollback changes + project.rollback! + end + + result + end + end + end +end diff --git a/app/services/projects/hashed_storage/rollback_repository_service.rb b/app/services/projects/hashed_storage/rollback_repository_service.rb new file mode 100644 index 00000000000..b5c971c70a5 --- /dev/null +++ b/app/services/projects/hashed_storage/rollback_repository_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Projects + module HashedStorage + class RollbackRepositoryService < BaseRepositoryService + def execute + try_to_set_repository_read_only! + + @old_storage_version = project.storage_version + project.storage_version = nil + project.ensure_storage_path_exists + + @new_disk_path = project.disk_path + + result = move_repository(old_disk_path, new_disk_path) + + if move_wiki + result &&= move_repository(old_wiki_disk_path, "#{new_disk_path}.wiki") + end + + if result + project.write_repository_config + project.track_project_repository + else + rollback_folder_move + project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository] + end + + project.repository_read_only = false + project.save! + + if result && block_given? + yield + end + + result + end + end + end +end diff --git a/app/services/projects/hashed_storage/rollback_service.rb b/app/services/projects/hashed_storage/rollback_service.rb new file mode 100644 index 00000000000..25767f5de5e --- /dev/null +++ b/app/services/projects/hashed_storage/rollback_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Projects + module HashedStorage + class RollbackService < BaseService + attr_reader :logger, :old_disk_path + + def initialize(project, old_disk_path, logger: nil) + @project = project + @old_disk_path = old_disk_path + @logger = logger || Rails.logger + end + + def execute + # Rollback attachments from Hashed Storage to Legacy + if project.hashed_storage?(:attachments) + return false unless rollback_attachments + end + + # Rollback repository from Hashed Storage to Legacy + if project.hashed_storage?(:repository) + rollback_repository + end + end + + private + + def rollback_attachments + HashedStorage::RollbackAttachmentsService.new(project, logger: logger).execute + end + + def rollback_repository + HashedStorage::RollbackRepositoryService.new(project, old_disk_path, logger: logger).execute + end + end + end +end diff --git a/app/services/projects/hashed_storage_migration_service.rb b/app/services/projects/hashed_storage_migration_service.rb deleted file mode 100644 index a0e734005f8..00000000000 --- a/app/services/projects/hashed_storage_migration_service.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module Projects - class HashedStorageMigrationService < BaseService - attr_reader :logger, :old_disk_path - - def initialize(project, old_disk_path, logger: nil) - @project = project - @old_disk_path = old_disk_path - @logger = logger || Rails.logger - end - - def execute - # Migrate repository from Legacy to Hashed Storage - unless project.hashed_storage?(:repository) - return unless HashedStorage::MigrateRepositoryService.new(project, old_disk_path, logger: logger).execute - end - - # Migrate attachments from Legacy to Hashed Storage - unless project.hashed_storage?(:attachments) - HashedStorage::MigrateAttachmentsService.new(project, old_disk_path, logger: logger).execute - end - - true - end - end -end diff --git a/app/services/projects/import_error_filter.rb b/app/services/projects/import_error_filter.rb new file mode 100644 index 00000000000..a0fc5149bb4 --- /dev/null +++ b/app/services/projects/import_error_filter.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Projects + # Used by project imports, it removes any potential paths + # included in an error message that could be stored in the DB + class ImportErrorFilter + ERROR_MESSAGE_FILTER = /[^\s]*#{File::SEPARATOR}[^\s]*(?=(\s|\z))/ + FILTER_MESSAGE = '[FILTERED]' + + def self.filter_message(message) + message.gsub(ERROR_MESSAGE_FILTER, FILTER_MESSAGE) + end + end +end diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index 0c426faa22d..7214e9efaf6 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -24,8 +24,16 @@ module Projects import_data success - rescue => e + rescue Gitlab::UrlBlocker::BlockedUrlError => e + Gitlab::Sentry.track_acceptable_exception(e, extra: { project_path: project.full_path, importer: project.import_type }) + error("Error importing repository #{project.safe_import_url} into #{project.full_path} - #{e.message}") + rescue => e + message = Projects::ImportErrorFilter.filter_message(e.message) + + Gitlab::Sentry.track_acceptable_exception(e, extra: { project_path: project.full_path, importer: project.import_type }) + + error("Error importing repository #{project.safe_import_url} into #{project.full_path} - #{message}") end private @@ -35,7 +43,7 @@ module Projects begin Gitlab::UrlBlocker.validate!(project.import_url, ports: Project::VALID_IMPORT_PORTS) rescue Gitlab::UrlBlocker::BlockedUrlError => e - raise Error, "Blocked import URL: #{e.message}" + raise e, "Blocked import URL: #{e.message}" end end @@ -65,7 +73,7 @@ module Projects project.ensure_repository project.repository.fetch_as_mirror(project.import_url, refmap: refmap) else - gitlab_shell.import_repository(project.repository_storage, project.disk_path, project.import_url) + gitlab_shell.import_project_repository(project) end rescue Gitlab::Shell::Error => e # Expire cache to prevent scenarios such as: @@ -86,11 +94,11 @@ module Projects return unless project.lfs_enabled? - oids_to_download = Projects::LfsPointers::LfsImportService.new(project).execute - download_service = Projects::LfsPointers::LfsDownloadService.new(project) + lfs_objects_to_download = Projects::LfsPointers::LfsImportService.new(project).execute - oids_to_download.each do |oid, link| - download_service.execute(oid, link) + lfs_objects_to_download.each do |lfs_download_object| + Projects::LfsPointers::LfsDownloadService.new(project, lfs_download_object) + .execute end rescue => e # Right now, to avoid aborting the importing process, we silently fail diff --git a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb index a837ea82e38..7998976b00a 100644 --- a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb +++ b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb @@ -41,16 +41,17 @@ module Projects end def parse_response_links(objects_response) - objects_response.each_with_object({}) do |entry, link_list| + objects_response.each_with_object([]) do |entry, link_list| begin - oid = entry['oid'] link = entry.dig('actions', DOWNLOAD_ACTION, 'href') raise DownloadLinkNotFound unless link - link_list[oid] = add_credentials(link) - rescue DownloadLinkNotFound, URI::InvalidURIError - Rails.logger.error("Link for Lfs Object with oid #{oid} not found or invalid.") + link_list << LfsDownloadObject.new(oid: entry['oid'], + size: entry['size'], + link: add_credentials(link)) + rescue DownloadLinkNotFound, Addressable::URI::InvalidURIError + log_error("Link for Lfs Object with oid #{entry['oid']} not found or invalid.") end end end @@ -70,7 +71,7 @@ module Projects end def add_credentials(link) - uri = URI.parse(link) + uri = Addressable::URI.parse(link) if should_add_credentials?(uri) uri.user = remote_uri.user diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb index f9b9781ad5f..398f00a598d 100644 --- a/app/services/projects/lfs_pointers/lfs_download_service.rb +++ b/app/services/projects/lfs_pointers/lfs_download_service.rb @@ -4,49 +4,93 @@ module Projects module LfsPointers class LfsDownloadService < BaseService - VALID_PROTOCOLS = %w[http https].freeze + SizeError = Class.new(StandardError) + OidError = Class.new(StandardError) - # rubocop: disable CodeReuse/ActiveRecord - def execute(oid, url) - return unless project&.lfs_enabled? && oid.present? && url.present? + attr_reader :lfs_download_object + delegate :oid, :size, :credentials, :sanitized_url, to: :lfs_download_object, prefix: :lfs - return if LfsObject.exists?(oid: oid) + def initialize(project, lfs_download_object) + super(project) - sanitized_uri = Gitlab::UrlSanitizer.new(url) - Gitlab::UrlBlocker.validate!(sanitized_uri.sanitized_url, protocols: VALID_PROTOCOLS) + @lfs_download_object = lfs_download_object + end - with_tmp_file(oid) do |file| - size = download_and_save_file(file, sanitized_uri) - lfs_object = LfsObject.new(oid: oid, size: size, file: file) + # rubocop: disable CodeReuse/ActiveRecord + def execute + return unless project&.lfs_enabled? && lfs_download_object + return error("LFS file with oid #{lfs_oid} has invalid attributes") unless lfs_download_object.valid? + return if LfsObject.exists?(oid: lfs_oid) - project.all_lfs_objects << lfs_object + wrap_download_errors do + download_lfs_file! end - rescue StandardError => e - Rails.logger.error("LFS file with oid #{oid} could't be downloaded from #{sanitized_uri.sanitized_url}: #{e.message}") end # rubocop: enable CodeReuse/ActiveRecord private - def download_and_save_file(file, sanitized_uri) - IO.copy_stream(open(sanitized_uri.sanitized_url, headers(sanitized_uri)), file) # rubocop:disable Security/Open + def wrap_download_errors(&block) + yield + rescue SizeError, OidError, StandardError => e + error("LFS file with oid #{lfs_oid} could't be downloaded from #{lfs_sanitized_url}: #{e.message}") + end + + def download_lfs_file! + with_tmp_file do |tmp_file| + download_and_save_file!(tmp_file) + project.all_lfs_objects << LfsObject.new(oid: lfs_oid, + size: lfs_size, + file: tmp_file) + + success + end end - def headers(sanitized_uri) - {}.tap do |headers| - credentials = sanitized_uri.credentials + def download_and_save_file!(file) + digester = Digest::SHA256.new + response = Gitlab::HTTP.get(lfs_sanitized_url, download_headers) do |fragment| + digester << fragment + file.write(fragment) + + raise_size_error! if file.size > lfs_size + end + + raise StandardError, "Received error code #{response.code}" unless response.success? + + raise_size_error! if file.size != lfs_size + raise_oid_error! if digester.hexdigest != lfs_oid + end - if credentials[:user].present? || credentials[:password].present? + def download_headers + { stream_body: true }.tap do |headers| + if lfs_credentials[:user].present? || lfs_credentials[:password].present? # Using authentication headers in the request - headers[:http_basic_authentication] = [credentials[:user], credentials[:password]] + headers[:basic_auth] = { username: lfs_credentials[:user], password: lfs_credentials[:password] } end end end - def with_tmp_file(oid) + def with_tmp_file create_tmp_storage_dir - File.open(File.join(tmp_storage_dir, oid), 'w') { |file| yield file } + File.open(tmp_filename, 'wb') do |file| + begin + yield file + rescue StandardError => e + # If the lfs file is successfully downloaded it will be removed + # when it is added to the project's lfs files. + # Nevertheless if any excetion raises the file would remain + # in the file system. Here we ensure to remove it + File.unlink(file) if File.exist?(file) + + raise e + end + end + end + + def tmp_filename + File.join(tmp_storage_dir, lfs_oid) end def create_tmp_storage_dir @@ -60,6 +104,20 @@ module Projects def storage_dir @storage_dir ||= Gitlab.config.lfs.storage_path end + + def raise_size_error! + raise SizeError, 'Size mistmatch' + end + + def raise_oid_error! + raise OidError, 'Oid mismatch' + end + + def error(message, http_status = nil) + log_error(message) + + super + end end end end diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb new file mode 100644 index 00000000000..aedf79c86d7 --- /dev/null +++ b/app/services/projects/operations/update_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Projects + module Operations + class UpdateService < BaseService + def execute + Projects::UpdateService + .new(project, current_user, project_update_params) + .execute + end + + private + + def project_update_params + error_tracking_params + end + + def error_tracking_params + settings = params[:error_tracking_setting_attributes] + return {} if settings.blank? + + api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( + api_host: settings[:api_host], + project_slug: settings.dig(:project, :slug), + organization_slug: settings.dig(:project, :organization_slug) + ) + + { + error_tracking_setting_attributes: { + api_url: api_url, + token: settings[:token], + enabled: settings[:enabled], + project_name: settings.dig(:project, :name), + organization_name: settings.dig(:project, :organization_name) + } + } + end + end + end +end diff --git a/app/services/projects/protect_default_branch_service.rb b/app/services/projects/protect_default_branch_service.rb new file mode 100644 index 00000000000..245490791bf --- /dev/null +++ b/app/services/projects/protect_default_branch_service.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Projects + # Service class that can be used to execute actions necessary after creating a + # default branch. + class ProtectDefaultBranchService + attr_reader :project, :default_branch_protection + + # @param [Project] project + def initialize(project) + @project = project + + @default_branch_protection = Gitlab::Access::BranchProtection + .new(Gitlab::CurrentSettings.default_branch_protection) + end + + def execute + protect_default_branch if default_branch + end + + def protect_default_branch + # Ensure HEAD points to the default branch in case it is not master + project.change_head(default_branch) + + create_protected_branch if protect_branch? + end + + def create_protected_branch + params = { + name: default_branch, + push_access_levels_attributes: [{ access_level: push_access_level }], + merge_access_levels_attributes: [{ access_level: merge_access_level }] + } + + # The creator of the project is always allowed to create protected + # branches, so we skip the authorization check in this service class. + ProtectedBranches::CreateService + .new(project, project.creator, params) + .execute(skip_authorization: true) + end + + def protect_branch? + default_branch_protection.any? && + !ProtectedBranch.protected?(project, default_branch) + end + + def default_branch + project.default_branch + end + + def push_access_level + if default_branch_protection.developer_can_push? + Gitlab::Access::DEVELOPER + else + Gitlab::Access::MAINTAINER + end + end + + def merge_access_level + if default_branch_protection.developer_can_merge? + Gitlab::Access::DEVELOPER + else + Gitlab::Access::MAINTAINER + end + end + end +end diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb index abf40b3ad7a..674071ad92a 100644 --- a/app/services/projects/update_pages_configuration_service.rb +++ b/app/services/projects/update_pages_configuration_service.rb @@ -2,6 +2,8 @@ module Projects class UpdatePagesConfigurationService < BaseService + include Gitlab::Utils::StrongMemoize + attr_reader :project def initialize(project) @@ -9,15 +11,25 @@ module Projects end def execute - update_file(pages_config_file, pages_config.to_json) + if file_equals?(pages_config_file, pages_config_json) + return success(reload: false) + end + + update_file(pages_config_file, pages_config_json) reload_daemon - success + success(reload: true) rescue => e error(e.message) end private + def pages_config_json + strong_memoize(:pages_config_json) do + pages_config.to_json + end + end + def pages_config { domains: pages_domains_config, @@ -67,11 +79,6 @@ module Projects end def update_file(file, data) - unless data - FileUtils.remove(file, force: true) - return - end - temp_file = "#{file}.#{SecureRandom.hex(16)}" File.open(temp_file, 'w') do |f| f.write(data) @@ -81,5 +88,18 @@ module Projects # In case if the updating fails FileUtils.remove(temp_file, force: true) end + + def file_equals?(file, data) + existing_data = read_file(file) + data == existing_data.to_s + end + + def read_file(file) + File.open(file, 'r') do |f| + f.read + end + rescue + nil + end end end diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index eb2478be3cf..5caeb4cfa5f 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -7,7 +7,11 @@ module Projects BLOCK_SIZE = 32.kilobytes MAX_SIZE = 1.terabyte - SITE_PATH = 'public/'.freeze + PUBLIC_DIR = 'public'.freeze + + # this has to be invalid group name, + # as it shares the namespace with groups + TMP_EXTRACT_PATH = '@pages.tmp'.freeze attr_reader :build @@ -27,12 +31,11 @@ module Projects raise InvalidStateError, 'pages are outdated' unless latest? # Create temporary directory in which we will extract the artifacts - FileUtils.mkdir_p(tmp_path) - Dir.mktmpdir(nil, tmp_path) do |archive_path| + make_secure_tmp_dir(tmp_path) do |archive_path| extract_archive!(archive_path) # Check if we did extract public directory - archive_public_path = File.join(archive_path, 'public') + archive_public_path = File.join(archive_path, PUBLIC_DIR) raise InvalidStateError, 'pages miss the public folder' unless Dir.exist?(archive_public_path) raise InvalidStateError, 'pages are outdated' unless latest? @@ -85,22 +88,18 @@ module Projects raise InvalidStateError, 'missing artifacts metadata' unless build.artifacts_metadata? # Calculate page size after extract - public_entry = build.artifacts_metadata_entry(SITE_PATH, recursive: true) + public_entry = build.artifacts_metadata_entry(PUBLIC_DIR + '/', recursive: true) if public_entry.total_size > max_size raise InvalidStateError, "artifacts for pages are too large: #{public_entry.total_size}" end - # Requires UnZip at least 6.00 Info-ZIP. - # -qq be (very) quiet - # -n never overwrite existing files - # We add * to end of SITE_PATH, because we want to extract SITE_PATH and all subdirectories - site_path = File.join(SITE_PATH, '*') build.artifacts_file.use_file do |artifacts_path| - unless system(*%W(unzip -n #{artifacts_path} #{site_path} -d #{temp_path})) - raise FailedToExtractError, 'pages failed to extract' - end + SafeZip::Extract.new(artifacts_path) + .extract(directories: [PUBLIC_DIR], to: temp_path) end + rescue SafeZip::Extract::Error => e + raise FailedToExtractError, e.message end def deploy_page!(archive_public_path) @@ -139,7 +138,7 @@ module Projects end def tmp_path - @tmp_path ||= File.join(::Settings.pages.path, 'tmp') + @tmp_path ||= File.join(::Settings.pages.path, TMP_EXTRACT_PATH) end def pages_path @@ -147,11 +146,11 @@ module Projects end def public_path - @public_path ||= File.join(pages_path, 'public') + @public_path ||= File.join(pages_path, PUBLIC_DIR) end def previous_public_path - @previous_public_path ||= File.join(pages_path, "public.#{SecureRandom.hex}") + @previous_public_path ||= File.join(pages_path, "#{PUBLIC_DIR}.#{SecureRandom.hex}") end def ref @@ -188,5 +187,15 @@ module Projects def pages_deployments_failed_total_counter @pages_deployments_failed_total_counter ||= Gitlab::Metrics.counter(:pages_deployments_failed_total, "Counter of GitLab Pages deployments which failed") end + + def make_secure_tmp_dir(tmp_path) + FileUtils.mkdir_p(tmp_path) + path = Dir.mktmpdir(nil, tmp_path) + begin + yield(path) + ensure + FileUtils.remove_entry_secure(path) + end + end end end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 93e48fc0199..6856009b395 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -61,13 +61,13 @@ module Projects if project.previous_changes.include?(:visibility_level) && project.private? # don't enqueue immediately to prevent todos removal in case of a mistake - TodosDestroyer::ProjectPrivateWorker.perform_in(1.hour, project.id) + TodosDestroyer::ProjectPrivateWorker.perform_in(Todo::WAIT_FOR_DELETE, project.id) elsif (project_changed_feature_keys & todos_features_changes).present? - TodosDestroyer::PrivateFeaturesWorker.perform_in(1.hour, project.id) + TodosDestroyer::PrivateFeaturesWorker.perform_in(Todo::WAIT_FOR_DELETE, project.id) end if project.previous_changes.include?('path') - AfterRenameService.new(project).execute + after_rename_service(project).execute else system_hook_service.execute_hooks_for(project, :update) end @@ -75,6 +75,13 @@ module Projects update_pages_config if changing_pages_related_config? end + def after_rename_service(project) + # The path slug the project was using, before the rename took place. + path_before = project.previous_changes['path'].first + + AfterRenameService.new(project, path_before: path_before, full_path_before: project.full_path_was) + end + def changing_pages_related_config? changing_pages_https_only? || changing_pages_access_level? end diff --git a/app/services/prometheus/adapter_service.rb b/app/services/prometheus/adapter_service.rb index a791845ba20..3be958e1613 100644 --- a/app/services/prometheus/adapter_service.rb +++ b/app/services/prometheus/adapter_service.rb @@ -30,7 +30,7 @@ module Prometheus return unless deployment_platform.respond_to?(:cluster) cluster = deployment_platform.cluster - return unless cluster.application_prometheus&.ready? + return unless cluster.application_prometheus&.available? cluster.application_prometheus end diff --git a/app/services/protected_branches/api_service.rb b/app/services/protected_branches/api_service.rb index 4340d3e8260..1b13dace5f2 100644 --- a/app/services/protected_branches/api_service.rb +++ b/app/services/protected_branches/api_service.rb @@ -3,24 +3,15 @@ module ProtectedBranches class ApiService < BaseService def create - @push_params = AccessLevelParams.new(:push, params) - @merge_params = AccessLevelParams.new(:merge, params) - - verify_params! - - protected_branch_params = { - name: params[:name], - push_access_levels_attributes: @push_params.access_levels, - merge_access_levels_attributes: @merge_params.access_levels - } - ::ProtectedBranches::CreateService.new(@project, @current_user, protected_branch_params).execute end - private - - def verify_params! - # EE-only + def protected_branch_params + { + name: params[:name], + push_access_levels_attributes: AccessLevelParams.new(:push, params).access_levels, + merge_access_levels_attributes: AccessLevelParams.new(:merge, params).access_levels + } end end end diff --git a/app/services/protected_branches/legacy_api_update_service.rb b/app/services/protected_branches/legacy_api_update_service.rb index da8bf2ce02a..7cb8d41818f 100644 --- a/app/services/protected_branches/legacy_api_update_service.rb +++ b/app/services/protected_branches/legacy_api_update_service.rb @@ -6,30 +6,31 @@ # lives in this service. module ProtectedBranches class LegacyApiUpdateService < BaseService + attr_reader :protected_branch, :developers_can_push, :developers_can_merge + def execute(protected_branch) + @protected_branch = protected_branch @developers_can_push = params.delete(:developers_can_push) @developers_can_merge = params.delete(:developers_can_merge) - @protected_branch = protected_branch - protected_branch.transaction do delete_redundant_access_levels - case @developers_can_push + case developers_can_push when true params[:push_access_levels_attributes] = [{ access_level: Gitlab::Access::DEVELOPER }] when false params[:push_access_levels_attributes] = [{ access_level: Gitlab::Access::MAINTAINER }] end - case @developers_can_merge + case developers_can_merge when true params[:merge_access_levels_attributes] = [{ access_level: Gitlab::Access::DEVELOPER }] when false params[:merge_access_levels_attributes] = [{ access_level: Gitlab::Access::MAINTAINER }] end - service = ProtectedBranches::UpdateService.new(@project, @current_user, @params) + service = ProtectedBranches::UpdateService.new(project, current_user, params) service.execute(protected_branch) end end @@ -37,12 +38,12 @@ module ProtectedBranches private def delete_redundant_access_levels - unless @developers_can_merge.nil? - @protected_branch.merge_access_levels.destroy_all # rubocop: disable DestroyAll + unless developers_can_merge.nil? + protected_branch.merge_access_levels.destroy_all # rubocop: disable DestroyAll end - unless @developers_can_push.nil? - @protected_branch.push_access_levels.destroy_all # rubocop: disable DestroyAll + unless developers_can_push.nil? + protected_branch.push_access_levels.destroy_all # rubocop: disable DestroyAll end end end diff --git a/app/services/push_event_payload_service.rb b/app/services/push_event_payload_service.rb index bb1259787af..fe366ac225b 100644 --- a/app/services/push_event_payload_service.rb +++ b/app/services/push_event_payload_service.rb @@ -46,7 +46,7 @@ class PushEventPayloadService def commit_title commit = @push_data.fetch(:commits).last - return nil unless commit && commit[:message] + return unless commit && commit[:message] raw_msg = commit[:message] diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index d248b10f41e..131efb8925e 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -2,10 +2,16 @@ module QuickActions class InterpretService < BaseService + include Gitlab::Utils::StrongMemoize include Gitlab::QuickActions::Dsl attr_reader :issuable + # Counts how many commands have been executed. + # Used to display relevant feedback on UI when a note + # with only commands has been processed. + attr_accessor :commands_executed_count + SHRUG = '¯\\_(ツ)_/¯'.freeze TABLEFLIP = '(╯°□°)╯︵ ┻━┻'.freeze @@ -210,15 +216,9 @@ module QuickActions end params '~label1 ~"label 2"' condition do - if project - available_labels = LabelsFinder - .new(current_user, project_id: project.id, include_ancestor_groups: true) - .execute - end - - project && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) && - available_labels.any? + parent && + current_user.can?(:"admin_#{issuable.to_ability_name}", parent) && + find_labels.any? end command :label do |labels_param| label_ids = find_label_ids(labels_param) @@ -245,7 +245,7 @@ module QuickActions issuable.is_a?(Issuable) && issuable.persisted? && issuable.labels.any? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) + current_user.can?(:"admin_#{issuable.to_ability_name}", parent) end command :unlabel do |labels_param = nil| if labels_param.present? @@ -674,9 +674,25 @@ module QuickActions MilestonesFinder.new(params.merge(project_ids: [project.id], group_ids: [project.group&.id])).execute end - def find_labels(labels_param) - extract_references(labels_param, :label) | - LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split, include_ancestor_groups: true).execute + def parent + project || group + end + + def group + strong_memoize(:group) do + issuable.group if issuable.respond_to?(:group) + end + end + + def find_labels(labels_params = nil) + finder_params = { include_ancestor_groups: true } + finder_params[:project_id] = project.id if project + finder_params[:group_id] = group.id if group + finder_params[:name] = labels_params.split if labels_params + + result = LabelsFinder.new(current_user, finder_params).execute + + extract_references(labels_params, :label) | result end def find_label_references(labels_param) @@ -707,9 +723,11 @@ module QuickActions # rubocop: disable CodeReuse/ActiveRecord def extract_references(arg, type) + return [] unless arg + ext = Gitlab::ReferenceExtractor.new(project, current_user) - ext.analyze(arg, author: current_user) + ext.analyze(arg, author: current_user, group: group) ext.references(type) end diff --git a/app/services/releases/concerns.rb b/app/services/releases/concerns.rb new file mode 100644 index 00000000000..a04bb8f9e14 --- /dev/null +++ b/app/services/releases/concerns.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Releases + module Concerns + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + included do + def tag_name + params[:tag] + end + + def ref + params[:ref] + end + + def name + params[:name] + end + + def description + params[:description] + end + + def release + strong_memoize(:release) do + project.releases.find_by_tag(tag_name) + end + end + + def existing_tag + strong_memoize(:existing_tag) do + repository.find_tag(tag_name) + end + end + + def tag_exist? + existing_tag.present? + end + + def repository + strong_memoize(:repository) do + project.repository + end + end + end + end +end diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb new file mode 100644 index 00000000000..c6e143d440d --- /dev/null +++ b/app/services/releases/create_service.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Releases + class CreateService < BaseService + include Releases::Concerns + + def execute + return error('Access Denied', 403) unless allowed? + return error('Release already exists', 409) if release + + tag = ensure_tag + + return tag unless tag.is_a?(Gitlab::Git::Tag) + + create_release(tag) + end + + private + + def ensure_tag + existing_tag || create_tag + end + + def create_tag + return error('Ref is not specified', 422) unless ref + + result = Tags::CreateService + .new(project, current_user) + .execute(tag_name, ref, nil) + + return result unless result[:status] == :success + + result[:tag] + end + + def allowed? + Ability.allowed?(current_user, :create_release, project) + end + + def create_release(tag) + release = project.releases.create!( + name: name, + description: description, + author: current_user, + tag: tag.name, + sha: tag.dereferenced_target.sha, + links_attributes: params.dig(:assets, 'links') || [] + ) + + success(tag: tag, release: release) + rescue => e + error(e.message, 400) + end + end +end diff --git a/app/services/releases/destroy_service.rb b/app/services/releases/destroy_service.rb new file mode 100644 index 00000000000..8c2bc3b4e6e --- /dev/null +++ b/app/services/releases/destroy_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Releases + class DestroyService < BaseService + include Releases::Concerns + + def execute + return error('Tag does not exist', 404) unless existing_tag + return error('Release does not exist', 404) unless release + return error('Access Denied', 403) unless allowed? + + if release.destroy + success(tag: existing_tag, release: release) + else + error(release.errors.messages || '400 Bad request', 400) + end + end + + private + + def allowed? + Ability.allowed?(current_user, :destroy_release, release) + end + end +end diff --git a/app/services/releases/update_service.rb b/app/services/releases/update_service.rb new file mode 100644 index 00000000000..fabfa398c59 --- /dev/null +++ b/app/services/releases/update_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Releases + class UpdateService < BaseService + include Releases::Concerns + + def execute + return error('Tag does not exist', 404) unless existing_tag + return error('Release does not exist', 404) unless release + return error('Access Denied', 403) unless allowed? + return error('params is empty', 400) if empty_params? + + if release.update(params) + success(tag: existing_tag, release: release) + else + error(release.errors.messages || '400 Bad request', 400) + end + end + + private + + def allowed? + Ability.allowed?(current_user, :update_release, release) + end + + # rubocop: disable CodeReuse/ActiveRecord + def empty_params? + params.except(:tag).empty? + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index cb1bf0a03a5..d6af26d949d 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -2,6 +2,8 @@ module Search class GlobalService + include Gitlab::Utils::StrongMemoize + attr_accessor :current_user, :params attr_reader :default_project_filter @@ -19,11 +21,15 @@ module Search @projects ||= ProjectsFinder.new(current_user: current_user).execute end - def scope - @scope ||= begin - allowed_scopes = %w[issues merge_requests milestones] + def allowed_scopes + strong_memoize(:allowed_scopes) do + %w[issues merge_requests milestones] + end + end - allowed_scopes.delete(params[:scope]) { 'projects' } + def scope + strong_memoize(:scope) do + allowed_scopes.include?(params[:scope]) ? params[:scope] : 'projects' end end end diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb index d931d528c86..f778c5aa5f5 100644 --- a/app/services/suggestions/apply_service.rb +++ b/app/services/suggestions/apply_service.rb @@ -11,7 +11,17 @@ module Suggestions return error('Suggestion is not appliable') end - params = file_update_params(suggestion) + unless latest_source_head?(suggestion) + return error('The file has been changed') + end + + diff_file = suggestion.note.latest_diff_file + + unless diff_file + return error('The file was not found') + end + + params = file_update_params(suggestion, diff_file) result = ::Files::UpdateService.new(suggestion.project, @current_user, params).execute if result[:status] == :success @@ -19,30 +29,45 @@ module Suggestions end result + rescue Files::UpdateService::FileChangedError + error('The file has been changed') end private - def file_update_params(suggestion) - diff_file = suggestion.diff_file + # Checks whether the latest source branch HEAD matches with + # the position HEAD we're using to update the file content. Since + # the persisted HEAD is updated async (for MergeRequest), + # it's more consistent to fetch this data directly from the + # repository. + def latest_source_head?(suggestion) + suggestion.position.head_sha == suggestion.noteable.source_branch_sha + end - file_path = diff_file.file_path - branch_name = suggestion.noteable.source_branch - file_content = new_file_content(suggestion) + def file_update_params(suggestion, diff_file) + blob = diff_file.new_blob + file_path = suggestion.file_path + branch_name = suggestion.branch + file_content = new_file_content(suggestion, blob) commit_message = "Apply suggestion to #{file_path}" + file_last_commit = + Gitlab::Git::Commit.last_for_path(suggestion.project.repository, + blob.commit_id, + blob.path) + { file_path: file_path, branch_name: branch_name, start_branch: branch_name, commit_message: commit_message, - file_content: file_content + file_content: file_content, + last_commit_sha: file_last_commit&.id } end - def new_file_content(suggestion) + def new_file_content(suggestion, blob) range = suggestion.from_line_index..suggestion.to_line_index - blob = suggestion.diff_file.new_blob blob.load_all_data! content = blob.data.lines diff --git a/app/services/suggestions/create_service.rb b/app/services/suggestions/create_service.rb index 77e958cbe0c..c7ac2452c53 100644 --- a/app/services/suggestions/create_service.rb +++ b/app/services/suggestions/create_service.rb @@ -9,6 +9,10 @@ module Suggestions def execute return unless @note.supports_suggestion? + diff_file = @note.latest_diff_file + + return unless diff_file + suggestions = Banzai::SuggestionsParser.parse(@note.note) # For single line suggestion we're only looking forward to @@ -20,7 +24,7 @@ module Suggestions rows = suggestions.map.with_index do |suggestion, index| - from_content = changing_lines(comment_line, comment_line) + from_content = changing_lines(diff_file, comment_line, comment_line) # The parsed suggestion doesn't have information about the correct # ending characters (we may have a line break, or not), so we take @@ -44,8 +48,8 @@ module Suggestions private - def changing_lines(from_line, to_line) - @note.diff_file.new_blob_lines_between(from_line, to_line).join + def changing_lines(diff_file, from_line, to_line) + diff_file.new_blob_lines_between(from_line, to_line).join end def line_break_chars(line) diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index ec6c306227b..ea8ac7e4656 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -360,7 +360,7 @@ module SystemNoteService # author - User performing the change # branch_type - 'source' or 'target' # old_branch - old branch name - # new_branch - new branch nmae + # new_branch - new branch name # # Example Note text: # diff --git a/app/services/tags/create_service.rb b/app/services/tags/create_service.rb index 6bb9bb3988e..4de6b2d2774 100644 --- a/app/services/tags/create_service.rb +++ b/app/services/tags/create_service.rb @@ -2,7 +2,7 @@ module Tags class CreateService < BaseService - def execute(tag_name, target, message, release_description = nil) + def execute(tag_name, target, message) valid_tag = Gitlab::GitRefValidator.validate(tag_name) return error('Tag name invalid') unless valid_tag @@ -20,10 +20,7 @@ module Tags end if new_tag - if release_description.present? - CreateReleaseService.new(@project, @current_user) - .execute(tag_name, release_description) - end + repository.expire_tags_cache success.merge(tag: new_tag) else diff --git a/app/services/tags/destroy_service.rb b/app/services/tags/destroy_service.rb index 6bfef09ac54..cab507946b4 100644 --- a/app/services/tags/destroy_service.rb +++ b/app/services/tags/destroy_service.rb @@ -2,7 +2,6 @@ module Tags class DestroyService < BaseService - # rubocop: disable CodeReuse/ActiveRecord def execute(tag_name) repository = project.repository tag = repository.find_tag(tag_name) @@ -12,8 +11,12 @@ module Tags end if repository.rm_tag(current_user, tag_name) - release = project.releases.find_by(tag: tag_name) - release&.destroy + ## + # When a tag in a repository is destroyed, + # release assets will be destroyed too. + Releases::DestroyService + .new(project, current_user, tag: tag_name) + .execute push_data = build_push_data(tag) EventCreateService.new.push(project, current_user, push_data) @@ -27,7 +30,6 @@ module Tags rescue Gitlab::Git::PreReceiveError => ex error(ex.message) end - # rubocop: enable CodeReuse/ActiveRecord def error(message, return_code = 400) super(message).merge(return_code: return_code) diff --git a/app/services/task_list_toggle_service.rb b/app/services/task_list_toggle_service.rb new file mode 100644 index 00000000000..f6602a35033 --- /dev/null +++ b/app/services/task_list_toggle_service.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +# Finds the correct checkbox in the passed in markdown/html and toggles it's state, +# returning the updated markdown/html. +# We don't care if the text has changed above or below the specific checkbox, as long +# the checkbox still exists at exactly the same line number and the text is equal. +# If successful, new values are available in `updated_markdown` and `updated_markdown_html` +class TaskListToggleService + attr_reader :updated_markdown, :updated_markdown_html + + def initialize(markdown, markdown_html, line_source:, line_number:, toggle_as_checked:) + @markdown, @markdown_html = markdown, markdown_html + @line_source, @line_number = line_source, line_number + @toggle_as_checked = toggle_as_checked + + @updated_markdown, @updated_markdown_html = nil + end + + def execute + return false unless markdown && markdown_html + + toggle_markdown && toggle_markdown_html + end + + private + + attr_reader :markdown, :markdown_html, :toggle_as_checked + attr_reader :line_source, :line_number + + def toggle_markdown + source_lines = markdown.split("\n") + source_line_index = line_number - 1 + markdown_task = source_lines[source_line_index] + + # The source in the DB could be using either \n or \r\n line endings + return unless markdown_task.chomp == line_source + return unless source_checkbox = Taskable::ITEM_PATTERN.match(markdown_task) + + currently_checked = TaskList::Item.new(source_checkbox[1]).complete? + + # Check `toggle_as_checked` to make sure we don't accidentally replace + # any `[ ]` or `[x]` in the middle of the text + if currently_checked + markdown_task.sub!(Taskable::COMPLETE_PATTERN, '[ ]') unless toggle_as_checked + else + markdown_task.sub!(Taskable::INCOMPLETE_PATTERN, '[x]') if toggle_as_checked + end + + source_lines[source_line_index] = markdown_task + @updated_markdown = source_lines.join("\n") + end + + def toggle_markdown_html + html = Nokogiri::HTML.fragment(markdown_html) + html_checkbox = get_html_checkbox(html) + return unless html_checkbox + + if toggle_as_checked + html_checkbox[:checked] = 'checked' + else + html_checkbox.remove_attribute('checked') + end + + @updated_markdown_html = html.to_html + end + + # When using CommonMark, we should be able to use the embedded `sourcepos` attribute to + # target the exact line in the DOM. + def get_html_checkbox(html) + html.css(".task-list-item[data-sourcepos^='#{line_number}:'] input.task-list-item-checkbox").first + end +end diff --git a/app/services/update_release_service.rb b/app/services/update_release_service.rb deleted file mode 100644 index e2228ca026c..00000000000 --- a/app/services/update_release_service.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -class UpdateReleaseService < BaseService - # rubocop: disable CodeReuse/ActiveRecord - def execute(tag_name, release_description) - repository = project.repository - existing_tag = repository.find_tag(tag_name) - - if existing_tag - release = project.releases.find_by(tag: tag_name) - - if release - release.update(description: release_description) - - success(release) - else - error('Release does not exist', 404) - end - else - error('Tag does not exist', 404) - end - end - # rubocop: enable CodeReuse/ActiveRecord - - def success(release) - super().merge(release: release) - end -end diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb index 39909ee4f82..403944557a2 100644 --- a/app/services/upload_service.rb +++ b/app/services/upload_service.rb @@ -6,12 +6,12 @@ class UploadService end def execute - return nil unless @file && @file.size <= max_attachment_size + return unless @file && @file.size <= max_attachment_size uploader = @uploader_class.new(@model, nil, @uploader_context) uploader.store!(@file) - uploader.to_h + uploader end private diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb index db03ba8756f..e50840a9158 100644 --- a/app/services/users/activity_service.rb +++ b/app/services/users/activity_service.rb @@ -26,12 +26,15 @@ module Users def record_activity return if Gitlab::Database.read_only? + today = Date.today + + return if @user.last_activity_on == today + lease = Gitlab::ExclusiveLease.new("acitvity_service:#{@user.id}", timeout: LEASE_TIMEOUT) return unless lease.try_obtain - @user.update_attribute(:last_activity_on, Date.today) - Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@user.id} (username: #{@user.username})") + @user.update_attribute(:last_activity_on, today) end end end diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb index af4fe1aebb9..0b00bd135eb 100644 --- a/app/services/users/update_service.rb +++ b/app/services/users/update_service.rb @@ -55,7 +55,7 @@ module Users params.reject! { |key, _| read_only.include?(key.to_sym) } end - @user.assign_attributes(params) if params.any? + @user.assign_attributes(params) unless params.empty? end end end diff --git a/app/services/validate_new_branch_service.rb b/app/services/validate_new_branch_service.rb index c19e2ec2043..3f4a59e5cee 100644 --- a/app/services/validate_new_branch_service.rb +++ b/app/services/validate_new_branch_service.rb @@ -3,14 +3,14 @@ require_relative 'base_service' class ValidateNewBranchService < BaseService - def execute(branch_name) + def execute(branch_name, force: false) valid_branch = Gitlab::GitRefValidator.validate(branch_name) unless valid_branch return error('Branch name is invalid') end - if project.repository.branch_exists?(branch_name) + if project.repository.branch_exists?(branch_name) && !force return error('Branch already exists') end diff --git a/app/uploaders/external_diff_uploader.rb b/app/uploaders/external_diff_uploader.rb new file mode 100644 index 00000000000..d2707cd0777 --- /dev/null +++ b/app/uploaders/external_diff_uploader.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class ExternalDiffUploader < GitlabUploader + include ObjectStorage::Concern + + storage_options Gitlab.config.external_diffs + + alias_method :upload, :model + + def filename + "diff-#{model.id}" + end + + def store_dir + dynamic_segment + end + + private + + def dynamic_segment + File.join(model.model_name.plural, "mr-#{model.merge_request_id}") + end +end diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb index a7f8615e9ba..236b7ed2b3d 100644 --- a/app/uploaders/file_mover.rb +++ b/app/uploaders/file_mover.rb @@ -11,6 +11,8 @@ class FileMover end def execute + return unless valid? + move if update_markdown @@ -21,6 +23,12 @@ class FileMover private + def valid? + Pathname.new(temp_file_path).realpath.to_path.start_with?( + (Pathname(temp_file_uploader.root) + temp_file_uploader.base_dir).to_path + ) + end + def move FileUtils.mkdir_p(File.dirname(file_path)) FileUtils.move(temp_file_path, file_path) diff --git a/app/uploaders/personal_file_uploader.rb b/app/uploaders/personal_file_uploader.rb index 25474b494ff..272837aa6ce 100644 --- a/app/uploaders/personal_file_uploader.rb +++ b/app/uploaders/personal_file_uploader.rb @@ -6,8 +6,15 @@ class PersonalFileUploader < FileUploader options.storage_path end - def self.base_dir(model, _store = nil) - File.join(options.base_dir, model_path_segment(model)) + def self.base_dir(model, store = nil) + base_dirs(model)[store || Store::LOCAL] + end + + def self.base_dirs(model) + { + Store::LOCAL => File.join(options.base_dir, model_path_segment(model)), + Store::REMOTE => model_path_segment(model) + } end def self.model_path_segment(model) @@ -33,13 +40,6 @@ class PersonalFileUploader < FileUploader store_dirs[object_store] end - def store_dirs - { - Store::LOCAL => File.join(base_dir, dynamic_segment), - Store::REMOTE => File.join(self.class.model_path_segment(model), dynamic_segment) - } - end - private def secure_url diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb index 0efca895a50..9a243e07936 100644 --- a/app/uploaders/records_uploads.rb +++ b/app/uploaders/records_uploads.rb @@ -23,13 +23,23 @@ module RecordsUploads return unless model return unless file && file.exists? - Upload.transaction do - uploads.where(path: upload_path).delete_all - upload.delete if upload - - self.upload = build_upload.tap(&:save!) + # MySQL InnoDB may encounter a deadlock if a deletion and an + # insert is in the same transaction due to its next-key locking + # algorithm, so we need to skip the transaction. + # https://gitlab.com/gitlab-org/gitlab-ce/issues/55161#note_131556351 + if Gitlab::Database.mysql? + readd_upload + else + Upload.transaction { readd_upload } end end + + def readd_upload + uploads.where(path: upload_path).delete_all + upload.delete if upload + + self.upload = build_upload.tap(&:save!) + end # rubocop: enable CodeReuse/ActiveRecord def upload_path diff --git a/app/validators/sha_validator.rb b/app/validators/sha_validator.rb new file mode 100644 index 00000000000..085fca4d65d --- /dev/null +++ b/app/validators/sha_validator.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ShaValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return if value.blank? || value.match(/\A\h{40}\z/) + + record.errors.add(attribute, 'is not a valid SHA') + end +end diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb index 5feb0b0f05b..3fd015c3cf5 100644 --- a/app/validators/url_validator.rb +++ b/app/validators/url_validator.rb @@ -26,6 +26,7 @@ # - allow_local_network: Allow urls pointing to private network addresses. Default: true # - ports: Allowed ports. Default: all. # - enforce_user: Validate user format. Default: false +# - enforce_sanitization: Validate that there are no html/css/js tags. Default: false # # Example: # class User < ActiveRecord::Base @@ -70,7 +71,8 @@ class UrlValidator < ActiveModel::EachValidator allow_localhost: true, allow_local_network: true, ascii_only: false, - enforce_user: false + enforce_user: false, + enforce_sanitization: false } end @@ -91,6 +93,12 @@ class UrlValidator < ActiveModel::EachValidator end def allow_setting_local_requests? + # We cannot use Gitlab::CurrentSettings as ApplicationSetting itself + # uses UrlValidator to validate urls. This ends up in a cycle + # when Gitlab::CurrentSettings creates an ApplicationSetting which then + # calls this validator. + # + # See https://gitlab.com/gitlab-org/gitlab-ee/issues/9833 ApplicationSetting.current&.allow_local_requests_from_hooks_and_services? end end diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml index cb67079853e..a5f34d0dab2 100644 --- a/app/views/admin/appearances/_form.html.haml +++ b/app/views/admin/appearances/_form.html.haml @@ -1,86 +1,97 @@ -= form_for @appearance, url: admin_appearances_path do |f| += form_for @appearance, url: admin_appearances_path, html: { class: 'prepend-top-default' } do |f| = form_errors(@appearance) - %fieldset.app_logo - %legend - Navigation bar: - .form-group.row - = f.label :header_logo, 'Header logo', class: 'col-sm-2 col-form-label pt-0' - .col-sm-10 - - if @appearance.header_logo? - = image_tag @appearance.header_logo_url, class: 'appearance-light-logo-preview' - - if @appearance.persisted? - %br - = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" - %hr - = f.hidden_field :header_logo_cache - = f.file_field :header_logo, class: "" - .hint - Maximum file size is 1MB. Pages are optimized for a 28px tall header logo - %fieldset.app_logo - %legend - Favicon: - .form-group.row - = f.label :favicon, 'Favicon', class: 'col-sm-2 col-form-label pt-0' - .col-sm-10 - - if @appearance.favicon? - = image_tag @appearance.favicon_url, class: 'appearance-light-logo-preview' - - if @appearance.persisted? - %br - = link_to 'Remove favicon', favicon_admin_appearances_path, data: { confirm: "Favicon will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" - %hr - = f.hidden_field :favicon_cache - = f.file_field :favicon, class: '' - .hint - Maximum file size is 1MB. Image size must be 32x32px. Allowed image formats are #{favicon_extension_whitelist}. - %br - Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior. + .row + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 Navigation bar - %fieldset.sign-in - %legend - Sign in/Sign up pages: - .form-group.row - = f.label :title, class: 'col-sm-2 col-form-label' - .col-sm-10 - = f.text_field :title, class: "form-control" - .form-group.row - = f.label :description, class: 'col-sm-2 col-form-label' - .col-sm-10 - = f.text_area :description, class: "form-control", rows: 10 - .hint - Description parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}. - .form-group.row - = f.label :logo, class: 'col-sm-2 col-form-label pt-0' - .col-sm-10 - - if @appearance.logo? - = image_tag @appearance.logo_url, class: 'appearance-logo-preview' - - if @appearance.persisted? - %br - = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" - %hr - = f.hidden_field :logo_cache - = f.file_field :logo, class: "" - .hint - Maximum file size is 1MB. Pages are optimized for a 640x360 px logo. + .col-lg-8 + .form-group + = f.label :header_logo, 'Header logo', class: 'col-form-label label-bold pt-0' + %p + - if @appearance.header_logo? + = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview' + - if @appearance.persisted? + %br + = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" + %hr + = f.hidden_field :header_logo_cache + = f.file_field :header_logo, class: "" + .hint + Maximum file size is 1MB. Pages are optimized for a 28px tall header logo + %hr + .row + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 Favicon + + .col-lg-8 + .form-group + = f.label :favicon, 'Favicon', class: 'col-form-label label-bold pt-0' + %p + - if @appearance.favicon? + = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview' + - if @appearance.persisted? + %br + = link_to 'Remove favicon', favicon_admin_appearances_path, data: { confirm: "Favicon will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" + %hr + = f.hidden_field :favicon_cache + = f.file_field :favicon, class: '' + .hint + Maximum file size is 1MB. Image size must be 32x32px. Allowed image formats are #{favicon_extension_whitelist}. + %br + Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior. + + = render partial: 'admin/appearances/system_header_footer_form', locals: { form: f } - %fieldset - %legend - New project pages: - .form-group.row - = f.label :new_project_guidelines, class: 'col-sm-2 col-form-label' - .col-sm-10 - = f.text_area :new_project_guidelines, class: "form-control", rows: 10 - .hint - Guidelines parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}. + %hr + .row + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 Sign in/Sign up pages - .form-actions - = f.submit 'Save', class: 'btn btn-success append-right-10' - - if @appearance.persisted? - Preview last save: - = link_to 'Sign-in page', preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' - = link_to 'New project page', new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' + .col-lg-8 + .form-group + = f.label :title, class: 'col-form-label label-bold' + = f.text_field :title, class: "form-control" + .form-group + = f.label :description, class: 'col-form-label label-bold' + = f.text_area :description, class: "form-control", rows: 10 + .hint + Description parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}. + .form-group + = f.label :logo, class: 'col-form-label label-bold pt-0' + %p + - if @appearance.logo? + = image_tag @appearance.logo_path, class: 'appearance-logo-preview' + - if @appearance.persisted? + %br + = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" + %hr + = f.hidden_field :logo_cache + = f.file_field :logo, class: "" + .hint + Maximum file size is 1MB. Pages are optimized for a 640x360 px logo. + + %hr + .row + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 New project pages + + .col-lg-8 + .form-group + = f.label :new_project_guidelines, class: 'col-form-label label-bold' + %p + = f.text_area :new_project_guidelines, class: "form-control", rows: 10 + .hint + Guidelines parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}. + + .prepend-top-default.append-bottom-default + = f.submit 'Update appearance settings', class: 'btn btn-success' + - if @appearance.persisted? + Preview last save: + = link_to 'Sign-in page', preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' + = link_to 'New project page', new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' - - if @appearance.updated_at - %span.float-right - Last edit #{time_ago_with_tooltip(@appearance.updated_at)} + - if @appearance.updated_at + %span.float-right + Last edit #{time_ago_with_tooltip(@appearance.updated_at)} diff --git a/app/views/admin/appearances/_system_header_footer_form.html.haml b/app/views/admin/appearances/_system_header_footer_form.html.haml new file mode 100644 index 00000000000..4301ebd05af --- /dev/null +++ b/app/views/admin/appearances/_system_header_footer_form.html.haml @@ -0,0 +1,33 @@ +- form = local_assigns.fetch(:form) + +%hr +.row + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 + = _('System header and footer') + + .col-lg-8 + .form-group + = form.label :header_message, _('Header message'), class: 'col-form-label label-bold' + = form.text_area :header_message, placeholder: _('State your message to activate'), class: "form-control js-autosize" + .form-group + = form.label :footer_message, _('Footer message'), class: 'col-form-label label-bold' + = form.text_area :footer_message, placeholder: _('State your message to activate'), class: "form-control js-autosize" + .form-group + .form-check + = form.check_box :email_header_and_footer_enabled, class: 'form-check-input' + = form.label :email_header_and_footer_enabled, class: 'label-bold' do + = _('Enable header and footer in emails') + + .hint + = _('Add header and footer to emails. Please note that color settings will only be applied within the application interface') + + .form-group.js-toggle-colors-container + %button.btn.btn-link.js-toggle-colors-link{ type: 'button' } + = _('Customize colors') + .form-group.js-toggle-colors-container.hide + = form.label :message_background_color, _('Background Color'), class: 'col-form-label label-bold' + = form.color_field :message_background_color, class: "form-control" + .form-group.js-toggle-colors-container.hide + = form.label :message_font_color, _('Font Color'), class: 'col-form-label label-bold' + = form.color_field :message_font_color, class: "form-control" diff --git a/app/views/admin/appearances/show.html.haml b/app/views/admin/appearances/show.html.haml index 454b779842c..ccf6f960cf2 100644 --- a/app/views/admin/appearances/show.html.haml +++ b/app/views/admin/appearances/show.html.haml @@ -1,9 +1,4 @@ - page_title "Appearance" - -%h3.page-title - Appearance settings -%p.light - You can modify the look and feel of GitLab here -%hr +- @content_class = "limit-container-width" unless fluid_layout = render 'form' diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index 10bc3452d8b..65a24854583 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -15,7 +15,7 @@ = f.number_field :max_attachment_size, class: 'form-control' .form-group = f.label :receive_max_input_size, 'Maximum push size (MB)', class: 'label-light' - = f.number_field :receive_max_input_size, class: 'form-control' + = f.number_field :receive_max_input_size, class: 'form-control qa-receive-max-input-size-field' .form-group = f.label :session_expire_delay, 'Session duration (minutes)', class: 'label-light' = f.number_field :session_expire_delay, class: 'form-control' @@ -46,4 +46,4 @@ = f.label :user_show_add_ssh_key_message, class: 'form-check-label' do Inform users without uploaded SSH keys that they can't push over SSH until one is added - = f.submit 'Save changes', class: 'btn btn-success' + = f.submit 'Save changes', class: 'btn btn-success qa-save-changes-button' diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index fdaad1cf181..c99d7e9b8e9 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -13,7 +13,7 @@ = s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.') = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank' .form-group - = f.label :auto_devops_domain, class: 'label-bold' + = f.label :auto_devops_domain, s_('AdminSettings|Auto DevOps domain'), class: 'label-bold' = f.text_field :auto_devops_domain, class: 'form-control', placeholder: 'domain.com' .form-text.text-muted = s_("AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages.") diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml new file mode 100644 index 00000000000..95d016a94a5 --- /dev/null +++ b/app/views/admin/application_settings/_localization.html.haml @@ -0,0 +1,11 @@ += form_for @application_setting, url: admin_application_settings_path(anchor: 'js-localization-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :first_day_of_week, _('Default first day of the week'), class: 'label-bold' + = f.select :first_day_of_week, first_day_of_week_choices, {}, class: 'form-control' + .form-text.text-muted + = _('Default first day of the week in calendars and date pickers.') + + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index c6c29ed1f21..7a2bbfcdc4d 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -11,7 +11,6 @@ .form-text.text-muted Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents repositories from having to be moved or renamed when the Project URL changes and may improve disk I/O performance. - %em (EXPERIMENTAL) .form-group = f.label :repository_storages, 'Storage paths for new projects', class: 'label-bold' = f.select :repository_storages, repository_storages_options_for_select(@application_setting.repository_storages), diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml index 00000b86ab7..c468d69d7b8 100644 --- a/app/views/admin/application_settings/preferences.html.haml +++ b/app/views/admin/application_settings/preferences.html.haml @@ -56,3 +56,14 @@ = _('Configure Gitaly timeouts.') .settings-content = render 'gitaly' + +%section.settings.as-localization.no-animate#js-localization-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4 + = _('Localization') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = _('Various localization settings.') + .settings-content + = render 'localization' diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml index 65e4723afe6..fc9dd29b8ca 100644 --- a/app/views/admin/application_settings/show.html.haml +++ b/app/views/admin/application_settings/show.html.haml @@ -13,7 +13,7 @@ .settings-content = render 'visibility_and_access' -%section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?) } +%section.settings.qa-account-and-limit-settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 = _('Account and limit') diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml index 0a688b90f3a..395c469255e 100644 --- a/app/views/admin/groups/_group.html.haml +++ b/app/views/admin/groups/_group.html.haml @@ -22,7 +22,7 @@ %span.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(group) } = visibility_level_icon(group.visibility_level, fw: false) - .avatar-container.s40 + .avatar-container.rect-avatar.s40 = group_icon(group, class: "avatar s40 d-none d-sm-block") .title = link_to [:admin, group], class: 'group-name' do diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index da2ebb08405..00d255846f9 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -15,7 +15,7 @@ = _('Group info:') %ul.content-list %li - .avatar-container.s60 + .avatar-container.rect-avatar.s60 = group_icon(@group, class: "avatar s60") %li %span.light= _('Name:') @@ -104,7 +104,7 @@ - link_to_help = link_to(_("here"), help_page_path("user/permissions")) = _('Read more about project permissions <strong>%{link_to_help}</strong>').html_safe % { link_to_help: link_to_help } - = form_tag admin_group_members_update_path(@group), id: "new_project_member", class: "bulk_import", method: :put do + = form_tag admin_group_members_update_path(@group), id: "new_project_member", class: "bulk_import", method: :put do %div = users_select_tag(:user_ids, multiple: true, email_user: true, skip_ldap: @group.ldap_synced?, scope: :all) .prepend-top-10 diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml index 50296a2afe7..5bc695aa7b5 100644 --- a/app/views/admin/projects/_projects.html.haml +++ b/app/views/admin/projects/_projects.html.haml @@ -19,7 +19,7 @@ .title = link_to(admin_namespace_project_path(project.namespace, project)) do .dash-project-avatar - .avatar-container.s40 + .avatar-container.rect-avatar.s40 = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40) %span.project-full-name %span.namespace-name diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml index e4fc2985087..ecf2b1d60ba 100644 --- a/app/views/admin/runners/_runner.html.haml +++ b/app/views/admin/runners/_runner.html.haml @@ -18,19 +18,19 @@ .table-mobile-content = link_to runner.short_sha, admin_runner_path(runner) - .table-section.section-15 + .table-section.section-20 .table-mobile-header{ role: 'rowheader' }= _('Description') .table-mobile-content.str-truncated.has-tooltip{ title: runner.description } = runner.description - .table-section.section-15 + .table-section.section-10 .table-mobile-header{ role: 'rowheader' }= _('Version') .table-mobile-content.str-truncated.has-tooltip{ title: runner.version } = runner.version .table-section.section-10 .table-mobile-header{ role: 'rowheader' }= _('IP Address') - .table-mobile-content + .table-mobile-content.str-truncated.has-tooltip{ title: runner.ip_address } = runner.ip_address .table-section.section-5 @@ -44,20 +44,21 @@ .table-section.section-5 .table-mobile-header{ role: 'rowheader' }= _('Jobs') .table-mobile-content - = runner.builds.count(:all) + = limited_counter_with_delimiter(runner.builds) .table-section.section-10.section-wrap .table-mobile-header{ role: 'rowheader' }= _('Tags') .table-mobile-content - - runner.tag_list.sort.each do |tag| + - runner.tags.map(&:name).sort.each do |tag| %span.badge.badge-primary = tag .table-section.section-10 .table-mobile-header{ role: 'rowheader' }= _('Last contact') .table-mobile-content - - if runner.contacted_at - = time_ago_with_tooltip runner.contacted_at + - contacted_at = runner_contacted_at(runner) + - if contacted_at + = time_ago_with_tooltip contacted_at - else = _('Never') diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index e9e4e0847d3..2e23b748edb 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -92,6 +92,25 @@ = button_tag class: %w[btn btn-link] do = runner_type.titleize + #js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + - Ci::Runner::AVAILABLE_TYPES.each do |runner_type| + %li.filter-dropdown-item{ data: { value: runner_type } } + = button_tag class: %w[btn btn-link] do + = runner_type.titleize + + #js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'none' } } + %button.btn.btn-link + = _('No Tag') + %li.divider.droplab-item-ignore + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value + %span.dropdown-light-content + {{name}} + = button_tag class: %w[clear-search hidden] do = icon('times') .filter-dropdown-container @@ -106,8 +125,8 @@ .gl-responsive-table-row.table-row-header{ role: 'row' } .table-section.section-10{ role: 'rowheader' }= _('Type') .table-section.section-10{ role: 'rowheader' }= _('Runner token') - .table-section.section-15{ role: 'rowheader' }= _('Description') - .table-section.section-15{ role: 'rowheader' }= _('Version') + .table-section.section-20{ role: 'rowheader' }= _('Description') + .table-section.section-10{ role: 'rowheader' }= _('Version') .table-section.section-10{ role: 'rowheader' }= _('IP Address') .table-section.section-5{ role: 'rowheader' }= _('Projects') .table-section.section-5{ role: 'rowheader' }= _('Jobs') diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index b2163ee85fa..be7bfa958b2 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -1,36 +1,37 @@ -%li.flex-row - .user-avatar - = image_tag avatar_icon_for_user(user), class: "avatar", alt: '' - .row-main-content - .user-name.row-title.str-truncated-100 - = link_to user.name, [:admin, user] - - if user.blocked? - %span.badge.badge-danger blocked - - if user.admin? - %span.badge.badge-success Admin - - if user.external? - %span.badge.badge-secondary External - - if user == current_user - %span It's you! - .row-second-line.str-truncated-100 - = mail_to user.email, user.email - .controls - = link_to 'Edit', edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn' - - unless user == current_user - .dropdown.inline - %a.dropdown-new.btn.btn-default#project-settings-button{ href: '#', data: { toggle: 'dropdown' } } +.gl-responsive-table-row{ role: 'row' } + .table-section.section-40 + .table-mobile-header{ role: 'rowheader' } + = _('Name') + .table-mobile-content + = render 'user_detail', user: user + .table-section.section-25 + .table-mobile-header{ role: 'rowheader' } + = _('Created on') + .table-mobile-content + = l(user.created_at.to_date, format: :admin) + .table-section.section-15 + .table-mobile-header{ role: 'rowheader' } + = _('Last activity') + .table-mobile-content + = user.last_activity_on.nil? ? _('Never') : l(user.last_activity_on, format: :admin) + .table-section.section-20.table-button-footer + .table-action-buttons + = link_to _('Edit'), edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn btn-default' + - unless user == current_user + %button.dropdown-new.btn.btn-default{ type: 'button', data: { toggle: 'dropdown' } } = icon('cog') = icon('caret-down') %ul.dropdown-menu.dropdown-menu-right %li.dropdown-header - Settings + = _('Settings') %li - if user.ldap_blocked? - %span.small Cannot unblock LDAP blocked users + %span.small + = s_('AdminUsers|Cannot unblock LDAP blocked users') - elsif user.blocked? - = link_to 'Unblock', unblock_admin_user_path(user), method: :put + = link_to _('Unblock'), unblock_admin_user_path(user), method: :put - else - = link_to 'Block', block_admin_user_path(user), data: { confirm: 'USER WILL BE BLOCKED! Are you sure?' }, method: :put + = link_to _('Block'), block_admin_user_path(user), data: { confirm: "#{s_('AdminUsers|User will be blocked').upcase}! #{_('Are you sure')}?" }, method: :put - if user.access_locked? %li = link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') } @@ -42,7 +43,7 @@ target: '#delete-user-modal', delete_user_url: admin_user_path(user), block_user_url: block_admin_user_path(user), - username: user.name, + username: sanitize_name(user.name), delete_contributions: false }, type: 'button' } = s_('AdminUsers|Delete user') @@ -51,6 +52,6 @@ target: '#delete-user-modal', delete_user_url: admin_user_path(user, hard_delete: true), block_user_url: block_admin_user_path(user), - username: user.name, + username: sanitize_name(user.name), delete_contributions: true }, type: 'button' } = s_('AdminUsers|Delete user and contributions') diff --git a/app/views/admin/users/_user_detail.html.haml b/app/views/admin/users/_user_detail.html.haml new file mode 100644 index 00000000000..3319b4bad3a --- /dev/null +++ b/app/views/admin/users/_user_detail.html.haml @@ -0,0 +1,17 @@ +.flex-list + .flex-row + = image_tag avatar_icon_for_user(user), class: 'avatar s32 d-none d-md-flex', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) } + .row-main-content + .row-title.str-truncated-100 + = image_tag avatar_icon_for_user(user), class: 'avatar s16 d-xs-flex d-md-none mr-1 prepend-top-2', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) } + = link_to user.name, admin_user_path(user), class: 'text-plain js-user-link', data: { user_id: user.id } + + = render_if_exists 'admin/users/user_detail_note', user: user + + - user_badges_in_admin_section(user).each do |badge| + - css_badge = "badge badge-#{badge[:variant]}" if badge[:variant].present? + %span{ class: css_badge } + = badge[:text] + + .row-second-line.str-truncated-100 + = mail_to user.email, user.email, class: 'text-secondary' diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 600120c4f05..c3d5ce0fe70 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -2,72 +2,78 @@ - page_title "Users" %div{ class: container_class } - .prepend-top-default + .top-area.scrolling-tabs-container.inner-page-scroll-tabs + .fade-left + = icon('angle-left') + .fade-right + = icon('angle-right') + %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.badge-pill= limited_counter_with_delimiter(User.active) + = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do + = link_to admin_users_path(filter: "admins") do + = s_('AdminUsers|Admins') + %small.badge.badge-pill= 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.badge-pill= 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.badge-pill= 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.badge-pill= 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.badge-pill= limited_counter_with_delimiter(User.blocked) + = 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.badge-pill= limited_counter_with_delimiter(User.without_projects) + .nav-controls + = render_if_exists 'admin/users/admin_email_users' + = link_to s_('AdminUsers|New user'), new_admin_user_path, class: 'btn btn-success btn-search float-right' + + .filtered-search-block.row-content-block.border-top-0 = form_tag admin_users_path, method: :get do - if params[:filter].present? = hidden_field_tag "filter", h(params[:filter]) .search-holder .search-field-holder - = search_field_tag :search_query, params[:search_query], placeholder: 'Search by name, email or username', class: 'form-control search-text-input js-search-input', spellcheck: false + = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false - if @sort.present? = hidden_field_tag :sort, @sort = icon("search", class: "search-icon") - = button_tag 'Search users' if Rails.env.test? + = button_tag s_('AdminUsers|Search users') if Rails.env.test? .dropdown.user-sort-dropdown - toggle_text = if @sort.present? then users_sort_options_hash[@sort] else sort_title_name end = dropdown_toggle(toggle_text, { toggle: 'dropdown' }) %ul.dropdown-menu.dropdown-menu-right %li.dropdown-header - Sort by + = s_('AdminUsers|Sort by') %li - users_sort_options_hash.each do |value, title| = link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do = title - = link_to 'New user', new_admin_user_path, class: 'btn btn-success btn-search' - .top-area.scrolling-tabs-container.inner-page-scroll-tabs - .fade-left - = icon('angle-left') - .fade-right - = icon('angle-right') - %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 - Active - %small.badge.badge-pill= limited_counter_with_delimiter(User.active) - = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do - = link_to admin_users_path(filter: "admins") do - Admins - %small.badge.badge-pill= 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 - 2FA Enabled - %small.badge.badge-pill= 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 - 2FA Disabled - %small.badge.badge-pill= 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 - External - %small.badge.badge-pill= 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 - Blocked - %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked) - = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do - = link_to admin_users_path(filter: "wop") do - Without projects - %small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects) + - if @users.empty? + .nothing-here-block.border-top-0 + = s_('AdminUsers|No users found') + - else + .table-holder + .thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' } + .table-section.section-40{ role: 'rowheader' }= _('Name') + .table-section.section-25{ role: 'rowheader' }= _('Created on') + .table-section.section-15{ role: 'rowheader' }= _('Last activity') - %ul.flex-list.content-list - - if @users.empty? - %li - .nothing-here-block No users found. - - else = render partial: 'admin/users/user', collection: @users = paginate @users, theme: "gitlab" #delete-user-modal - diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml index a758a63dfb3..8d9c083d223 100644 --- a/app/views/award_emoji/_awards_block.html.haml +++ b/app/views/award_emoji/_awards_block.html.haml @@ -3,7 +3,7 @@ - awards_sort(grouped_emojis).each do |emoji, awards| %button.btn.award-control.js-emoji-btn.has-tooltip{ type: "button", class: [(award_state_class(awardable, awards, current_user))], - data: { placement: "bottom", title: award_user_list(awards, current_user) } } + data: { title: award_user_list(awards, current_user) } } = emoji_icon(emoji) %span.award-control-text.js-counter = awards.count @@ -12,7 +12,7 @@ .award-menu-holder.js-award-holder %button.btn.award-control.has-tooltip.js-add-award{ type: 'button', 'aria-label': _('Add reaction'), - data: { title: _('Add reaction'), placement: "bottom" } } + data: { title: _('Add reaction') } } %span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face') %span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley') %span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile') diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml index 9de9143e8b1..c787d7420b7 100644 --- a/app/views/ci/status/_dropdown_graph_badge.html.haml +++ b/app/views/ci/status/_dropdown_graph_badge.html.haml @@ -6,7 +6,7 @@ - tooltip = "#{subject.name} - #{status.status_tooltip}" - if status.has_details? - = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do + = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do %span{ class: klass }= sprite_icon(status.icon) %span.ci-build-text= subject.name @@ -16,5 +16,5 @@ %span.ci-build-text= subject.name - if status.has_action? - = link_to status.action_path, class: "ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do + = link_to status.action_path, class: "ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do = sprite_icon(status.action_icon, css_class: "icon-action-#{status.action_icon}") diff --git a/app/views/ci/status/_icon.html.haml b/app/views/ci/status/_icon.html.haml new file mode 100644 index 00000000000..f38bdb2e5ed --- /dev/null +++ b/app/views/ci/status/_icon.html.haml @@ -0,0 +1,16 @@ +- status = local_assigns.fetch(:status) +- size = local_assigns.fetch(:size, 16) +- type = local_assigns.fetch(:type, 'pipeline') +- tooltip_placement = local_assigns.fetch(:tooltip_placement, "left") +- path = local_assigns.fetch(:path, status.has_details? ? status.details_path : nil) +- css_classes = "ci-status-link ci-status-icon ci-status-icon-#{status.group} has-tooltip" +- title = s_("PipelineStatusTooltip|Pipeline: %{ci_status}") % {ci_status: status.label} +- if type == 'commit' + - title = s_("PipelineStatusTooltip|Commit: %{ci_status}") % {ci_status: status.label} + +- if path + = link_to path, class: css_classes, title: title, data: { placement: tooltip_placement } do + = sprite_icon(status.icon, size: size) +- else + %span{ class: css_classes, title: title, data: { placement: tooltip_placement } } + = sprite_icon(status.icon, size: size) diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml index fa82611d9c1..90c59bec975 100644 --- a/app/views/ci/variables/_content.html.haml +++ b/app/views/ci/variables/_content.html.haml @@ -1 +1,3 @@ = _('Environment variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use environment variables for passwords, secret keys, or whatever you want.') += _('You may also add variables that are made available to the running application by prepending the variable key with <code>K8S_SECRET_</code>.').html_safe += link_to _('More information'), help_page_path('ci/variables/README', anchor: 'variables') diff --git a/app/views/clusters/clusters/_buttons.html.haml b/app/views/clusters/clusters/_buttons.html.haml index 9238903aa10..c81d1d5b05a 100644 --- a/app/views/clusters/clusters/_buttons.html.haml +++ b/app/views/clusters/clusters/_buttons.html.haml @@ -1,6 +1,5 @@ --# This partial is overridden in EE .nav-controls - - if clusterable.can_create_cluster? && clusterable.clusters.empty? + - if clusterable.can_add_cluster? = link_to s_('ClusterIntegration|Add Kubernetes cluster'), clusterable.new_path, class: 'btn btn-success js-add-cluster' - else %span.btn.btn-add-cluster.disabled.js-add-cluster diff --git a/app/views/clusters/clusters/_empty_state.html.haml b/app/views/clusters/clusters/_empty_state.html.haml index c926ec258f0..cfdbfe2dea1 100644 --- a/app/views/clusters/clusters/_empty_state.html.haml +++ b/app/views/clusters/clusters/_empty_state.html.haml @@ -9,6 +9,6 @@ = clusterable.empty_state_help_text = clusterable.learn_more_link - - if clusterable.can_create_cluster? + - if clusterable.can_add_cluster? .text-center = link_to s_('ClusterIntegration|Add Kubernetes cluster'), clusterable.new_path, class: 'btn btn-success' diff --git a/app/views/clusters/clusters/_form.html.haml b/app/views/clusters/clusters/_form.html.haml new file mode 100644 index 00000000000..9fb91a39387 --- /dev/null +++ b/app/views/clusters/clusters/_form.html.haml @@ -0,0 +1,46 @@ += form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster do |field| + = form_errors(@cluster) + .form-group + %h5= s_('ClusterIntegration|Integration status') + %label.append-bottom-0.js-cluster-enable-toggle-area + %button{ type: 'button', + class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if @cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}", + "aria-label": s_("ClusterIntegration|Toggle Kubernetes cluster"), + disabled: !can?(current_user, :update_cluster, @cluster) } + = field.hidden_field :enabled, { class: 'js-project-feature-toggle-input'} + %span.toggle-icon + = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') + = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') + .form-text.text-muted= s_('ClusterIntegration|Enable or disable GitLab\'s connection to your Kubernetes cluster.') + + .form-group + %h5= s_('ClusterIntegration|Environment scope') + - if has_multiple_clusters? + = field.text_field :environment_scope, class: 'col-md-6 form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Environment scope') + .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.") + - else + = text_field_tag :environment_scope, '*', class: 'col-md-6 form-control disabled', placeholder: s_('ClusterIntegration|Environment scope'), disabled: true + - environment_scope_url = help_page_path('user/project/clusters/index', anchor: 'base-domain') + - environment_scope_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: environment_scope_url } + .form-text.text-muted + %code * + = s_("ClusterIntegration| is the default environment scope for this cluster. This means that all jobs, regardless of their environment, will use this cluster. %{environment_scope_start}More information%{environment_scope_end}").html_safe % { environment_scope_start: environment_scope_start, environment_scope_end: '</a>'.html_safe } + + .form-group + %h5= s_('ClusterIntegration|Base domain') + = field.text_field :base_domain, class: 'col-md-6 form-control js-select-on-focus qa-base-domain' + .form-text.text-muted + - auto_devops_url = help_page_path('topics/autodevops/index') + - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url } + = s_('ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe } + - if @cluster.application_ingress_external_ip.present? + = s_('ClusterIntegration|Alternatively') + %code #{@cluster.application_ingress_external_ip}.nip.io + = s_('ClusterIntegration| can be used instead of a custom domain.') + - custom_domain_url = help_page_path('user/project/clusters/index', anchor: 'pointing-your-dns-at-the-cluster-ip') + - custom_domain_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: custom_domain_url } + = s_('ClusterIntegration| %{custom_domain_start}More information%{custom_domain_end}.').html_safe % { custom_domain_start: custom_domain_start, custom_domain_end: '</a>'.html_safe } + + - if can?(current_user, :update_cluster, @cluster) + .form-group + = field.submit _('Save changes'), class: 'btn btn-success qa-save-domain' diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml index 73b11d509d3..a9299af8d78 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -1,6 +1,6 @@ - link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer') -.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert' } - %button.close{ type: "button", data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } × +.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } + %button.close.js-close{ type: "button" } × .gcp-signup-offer--content .gcp-signup-offer--icon.append-right-8 = sprite_icon("information", size: 16) @@ -8,5 +8,5 @@ %h4= s_('ClusterIntegration|Did you know?') %p= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link } %a.btn.btn-default{ href: 'https://goo.gl/AaJzRW', target: '_blank', rel: 'noopener noreferrer' } - Apply for credit + = s_("ClusterIntegration|Apply for credit") diff --git a/app/views/clusters/clusters/_integration_form.html.haml b/app/views/clusters/clusters/_integration_form.html.haml deleted file mode 100644 index 5e451f60c9d..00000000000 --- a/app/views/clusters/clusters/_integration_form.html.haml +++ /dev/null @@ -1,31 +0,0 @@ -= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster do |field| - = form_errors(@cluster) - .form-group - %h5= s_('ClusterIntegration|Integration status') - %label.append-bottom-0.js-cluster-enable-toggle-area - %button{ type: 'button', - class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if @cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}", - "aria-label": s_("ClusterIntegration|Toggle Kubernetes cluster"), - disabled: !can?(current_user, :update_cluster, @cluster) } - = field.hidden_field :enabled, { class: 'js-project-feature-toggle-input'} - %span.toggle-icon - = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') - = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') - .form-text.text-muted= s_('ClusterIntegration|Enable or disable GitLab\'s connection to your Kubernetes cluster.') - - - if has_multiple_clusters? - .form-group - %h5= s_('ClusterIntegration|Environment scope') - = field.text_field :environment_scope, class: 'col-md-6 form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Environment scope') - .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.") - - - if can?(current_user, :update_cluster, @cluster) - .form-group - = field.submit _('Save changes'), class: 'btn btn-success' - - - unless has_multiple_clusters? - %h5= s_('ClusterIntegration|Environment scope') - %p - %code * - is the default environment scope for this cluster. This means that all jobs, regardless of their environment, will use this cluster. - = link_to 'More information', ('https://docs.gitlab.com/ee/user/project/clusters/#setting-the-environment-scope') diff --git a/app/views/clusters/clusters/gcp/_show.html.haml b/app/views/clusters/clusters/gcp/_show.html.haml deleted file mode 100644 index e9f05eaf453..00000000000 --- a/app/views/clusters/clusters/gcp/_show.html.haml +++ /dev/null @@ -1,50 +0,0 @@ -.form-group - %label.append-bottom-10{ for: 'cluster-name' } - = s_('ClusterIntegration|Kubernetes cluster name') - .input-group - %input.form-control.cluster-name.js-select-on-focus{ value: @cluster.name, readonly: true } - %span.input-group-append - = clipboard_button(text: @cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), class: 'input-group-text btn-default') - -= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster do |field| - = form_errors(@cluster) - - = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field| - .form-group - = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL') - .input-group - = platform_kubernetes_field.text_field :api_url, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|API URL'), readonly: true - %span.input-group-append - = clipboard_button(text: @cluster.platform_kubernetes.api_url, title: s_('ClusterIntegration|Copy API URL'), class: 'input-group-text btn-default') - - .form-group - = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate') - .input-group - = platform_kubernetes_field.text_area :ca_cert, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), readonly: true - %span.input-group-append.clipboard-addon - = clipboard_button(text: @cluster.platform_kubernetes.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), class: 'input-group-text btn-blank') - - .form-group - = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token') - .input-group - = platform_kubernetes_field.text_field :token, class: 'form-control js-cluster-token js-select-on-focus', type: 'password', placeholder: s_('ClusterIntegration|Token'), readonly: true - %span.input-group-append - %button.btn.btn-default.input-group-text.js-show-cluster-token{ type: 'button' } - = s_('ClusterIntegration|Show') - = clipboard_button(text: @cluster.platform_kubernetes.token, title: s_('ClusterIntegration|Copy Token'), class: 'btn-default') - - - if @cluster.allow_user_defined_namespace? - .form-group - = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)') - = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace') - - .form-group - .form-check - = platform_kubernetes_field.check_box :authorization_type, { class: 'form-check-input', disabled: true }, 'rbac', 'abac' - = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' - .form-text.text-muted - = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') - = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') - - .form-group - = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success' diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml index 58d0a304363..9bab3bf56aa 100644 --- a/app/views/clusters/clusters/index.html.haml +++ b/app/views/clusters/clusters/index.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title 'Kubernetes' -- page_title "Kubernetes Clusters" +- breadcrumb_title _('Kubernetes') +- page_title _('Kubernetes Clusters') = render_gcp_signup_offer @@ -9,12 +9,12 @@ - else .top-area.adjust .nav-text - = s_("ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project") + = s_('ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project') = render 'clusters/clusters/buttons' - if @has_ancestor_clusters .bs-callout.bs-callout-info - = s_("ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.") + = s_('ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.') %strong = link_to _('More information'), help_page_path('user/group/clusters/', anchor: 'cluster-precedence') diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml index eeeef6bd824..6a8af23e5e8 100644 --- a/app/views/clusters/clusters/new.html.haml +++ b/app/views/clusters/clusters/new.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title 'Kubernetes' -- page_title _("Kubernetes Cluster") +- breadcrumb_title _('Kubernetes') +- page_title _('Kubernetes Cluster') - active_tab = local_assigns.fetch(:active_tab, 'gcp') = javascript_include_tag 'https://apis.google.com/js/api.js' diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index b1aa8e5d477..1ef76ef801e 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -1,7 +1,7 @@ - @content_class = "limit-container-width" unless fluid_layout -- add_to_breadcrumbs "Kubernetes Clusters", clusterable.index_path +- add_to_breadcrumbs _('Kubernetes Clusters'), clusterable.index_path - breadcrumb_title @cluster.name -- page_title _("Kubernetes Cluster") +- page_title _('Kubernetes Cluster') - manage_prometheus_path = edit_project_service_path(@cluster.project, 'prometheus') if @project - expanded = Rails.env.test? @@ -16,6 +16,7 @@ install_jupyter_path: clusterable.install_applications_cluster_path(@cluster, :jupyter), install_knative_path: clusterable.install_applications_cluster_path(@cluster, :knative), toggle_status: @cluster.enabled? ? 'true': 'false', + has_rbac: @cluster.platform_kubernetes_rbac? ? 'true': 'false', cluster_type: @cluster.cluster_type, cluster_status: @cluster.status_name, cluster_status_reason: @cluster.status_reason, @@ -30,7 +31,7 @@ %section#cluster-integration %h4= @cluster.name = render 'banner' - = render 'integration_form' + = render 'form' .cluster-applications-table#js-cluster-applications @@ -38,19 +39,16 @@ .settings-header %h4= s_('ClusterIntegration|Kubernetes cluster details') %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p= s_('ClusterIntegration|See and edit the details for your Kubernetes cluster') .settings-content - - if @cluster.managed? - = render 'clusters/clusters/gcp/show' - - else - = render 'clusters/clusters/user/show' + = render 'clusters/platforms/kubernetes/form', cluster: @cluster, platform: @cluster.platform_kubernetes, update_cluster_url_path: clusterable.cluster_path(@cluster) %section.settings.no-animate#js-cluster-advanced-settings{ class: ('expanded' if expanded) } .settings-header %h4= _('Advanced settings') %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p= s_("ClusterIntegration|Advanced options on this Kubernetes cluster's integration") .settings-content = render 'advanced_settings' diff --git a/app/views/clusters/clusters/user/_show.html.haml b/app/views/clusters/clusters/user/_show.html.haml deleted file mode 100644 index cac8e72edd3..00000000000 --- a/app/views/clusters/clusters/user/_show.html.haml +++ /dev/null @@ -1,39 +0,0 @@ -= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster do |field| - = form_errors(@cluster) - .form-group - = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold' - = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') - - = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field| - .form-group - = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL'), class: 'label-bold' - = platform_kubernetes_field.text_field :api_url, class: 'form-control', placeholder: s_('ClusterIntegration|API URL') - - .form-group - = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate'), class: 'label-bold' - = platform_kubernetes_field.text_area :ca_cert, class: 'form-control', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)') - - .form-group - = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token'), class: 'label-bold' - .input-group - = platform_kubernetes_field.text_field :token, class: 'form-control js-cluster-token', type: 'password', placeholder: s_('ClusterIntegration|Token'), autocomplete: 'off' - %span.input-group-append.clipboard-addon - .input-group-text - %button.js-show-cluster-token.btn-blank{ type: 'button' } - = s_('ClusterIntegration|Show') - - - if @cluster.allow_user_defined_namespace? - .form-group - = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)'), class: 'label-bold' - = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace') - - .form-group - .form-check - = platform_kubernetes_field.check_box :authorization_type, { class: 'form-check-input', disabled: true }, 'rbac', 'abac' - = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' - .form-text.text-muted - = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') - = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') - - .form-group - = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success' diff --git a/app/views/clusters/platforms/kubernetes/_form.html.haml b/app/views/clusters/platforms/kubernetes/_form.html.haml new file mode 100644 index 00000000000..4a452b83112 --- /dev/null +++ b/app/views/clusters/platforms/kubernetes/_form.html.haml @@ -0,0 +1,58 @@ += form_for cluster, url: update_cluster_url_path, as: :cluster do |field| + = form_errors(cluster) + + .form-group + - if cluster.managed? + %label.append-bottom-10{ for: 'cluster-name' } + = s_('ClusterIntegration|Kubernetes cluster name') + .input-group + %input.form-control.cluster-name.js-select-on-focus{ value: cluster.name, readonly: true } + %span.input-group-append + = clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), class: 'input-group-text btn-default') + - else + = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold' + .input-group + = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') + + = field.fields_for :platform_kubernetes, platform do |platform_field| + .form-group + = platform_field.label :api_url, s_('ClusterIntegration|API URL') + .input-group + = platform_field.text_field :api_url, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|API URL'), readonly: cluster.managed? + - if cluster.managed? + %span.input-group-append + = clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'), class: 'input-group-text btn-default') + + .form-group + = platform_field.label :ca_cert, s_('ClusterIntegration|CA Certificate') + .input-group + = platform_field.text_area :ca_cert, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), readonly: cluster.managed? + - if cluster.managed? + %span.input-group-append.clipboard-addon + = clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), class: 'input-group-text btn-blank') + + .form-group + = platform_field.label :token, s_('ClusterIntegration|Token') + .input-group + = platform_field.text_field :token, class: 'form-control js-cluster-token js-select-on-focus', type: 'password', placeholder: s_('ClusterIntegration|Token'), readonly: cluster.managed? + %span.input-group-append + %button.btn.btn-default.input-group-text.js-show-cluster-token{ type: 'button' } + = s_('ClusterIntegration|Show') + - if cluster.managed? + = clipboard_button(text: platform.token, title: s_('ClusterIntegration|Copy Token'), class: 'btn-default') + + - if cluster.allow_user_defined_namespace? + .form-group + = platform_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)') + = platform_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace') + + .form-group + .form-check + = platform_field.check_box :authorization_type, { class: 'form-check-input', disabled: true }, 'rbac', 'abac' + = platform_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' + .form-text.text-muted + = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') + = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') + + .form-group + = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success' diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml index 9c246e19faa..4359a2c3c2b 100644 --- a/app/views/dashboard/_activities.html.haml +++ b/app/views/dashboard/_activities.html.haml @@ -1,7 +1,7 @@ .nav-block.activities = render 'shared/event_filter' .controls - = link_to dashboard_projects_path(rss_url_options), class: 'btn rss-btn has-tooltip', title: 'Subscribe' do + = link_to dashboard_projects_path(rss_url_options), class: 'btn d-none d-sm-inline-block has-tooltip', title: 'Subscribe' do %i.fa.fa-rss .content_list diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml index 1050945b15a..ae67192cbcd 100644 --- a/app/views/dashboard/_projects_head.html.haml +++ b/app/views/dashboard/_projects_head.html.haml @@ -15,9 +15,11 @@ = 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.badge-pill= 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.badge-pill= 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 diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml index 8d99f84755a..a05d0190efb 100644 --- a/app/views/dashboard/_snippets_head.html.haml +++ b/app/views/dashboard/_snippets_head.html.haml @@ -1,9 +1,9 @@ .page-title-holder %h1.page-title= _('Snippets') - - if current_user + - if current_user && current_user.snippets.any? || @snippets.any? .page-title-controls - = link_to "New snippet", new_snippet_path, class: "btn btn-success", title: "New snippet" + = link_to _("New snippet"), new_snippet_path, class: "btn btn-success", title: _("New snippet") .top-area %ul.nav-links.nav.nav-tabs diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml index 31d4b3da4f1..4dbda5c754b 100644 --- a/app/views/dashboard/activity.html.haml +++ b/app/views/dashboard/activity.html.haml @@ -4,6 +4,9 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") + += render_if_exists "shared/gold_trial_callout" + - page_title "Activity" - header_title "Activity", activity_dashboard_path diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml index 50f39f93283..19b06ba5cdd 100644 --- a/app/views/dashboard/groups/index.html.haml +++ b/app/views/dashboard/groups/index.html.haml @@ -1,6 +1,8 @@ - @hide_top_links = true - page_title "Groups" -- header_title "Groups", dashboard_groups_path +- header_title "Groups", dashboard_groups_path + += render_if_exists "shared/gold_trial_callout" = render 'dashboard/groups_head' - if params[:filter].blank? && @groups.empty? diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml index fdd5c19d562..afd46412fab 100644 --- a/app/views/dashboard/issues.html.haml +++ b/app/views/dashboard/issues.html.haml @@ -4,6 +4,8 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{current_user.name} issues") += render_if_exists "shared/gold_trial_callout" + .page-title-holder %h1.page-title= _('Issues') diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml index 77cfa1271df..3e5f13b92e3 100644 --- a/app/views/dashboard/merge_requests.html.haml +++ b/app/views/dashboard/merge_requests.html.haml @@ -2,6 +2,8 @@ - page_title _("Merge Requests") - @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username) += render_if_exists "shared/gold_trial_callout" + .page-title-holder %h1.page-title= _('Merge Requests') diff --git a/app/views/dashboard/milestones/_milestone.html.haml b/app/views/dashboard/milestones/_milestone.html.haml index b876d6fd1f3..89212eb6bf9 100644 --- a/app/views/dashboard/milestones/_milestone.html.haml +++ b/app/views/dashboard/milestones/_milestone.html.haml @@ -1,5 +1,5 @@ = render 'shared/milestones/milestone', - milestone_path: group_or_dashboard_milestone_path(milestone), + milestone_path: group_or_project_milestone_path(milestone), issues_path: issues_dashboard_path(milestone_title: milestone.title), merge_requests_path: merge_requests_dashboard_path(milestone_title: milestone.title), milestone: milestone, diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml index ae0e38bf0ee..13822d36f15 100644 --- a/app/views/dashboard/milestones/index.html.haml +++ b/app/views/dashboard/milestones/index.html.haml @@ -13,6 +13,8 @@ .top-area = render 'shared/milestones_filter', counts: @milestone_states + .nav-controls + = render 'shared/milestones/search_form' .milestones %ul.content-list diff --git a/app/views/dashboard/projects/_starred_empty_state.html.haml b/app/views/dashboard/projects/_starred_empty_state.html.haml new file mode 100644 index 00000000000..bea27f1a456 --- /dev/null +++ b/app/views/dashboard/projects/_starred_empty_state.html.haml @@ -0,0 +1,9 @@ +.row.empty-state + .col-12 + .svg-content.svg-250 + = image_tag 'illustrations/starred_empty.svg' + .text-content + %h4.text-center + = s_("StarredProjectsEmptyState|You don't have starred projects yet.") + %p.text-secondary + = s_("StarredProjectsEmptyState|Visit a project page and press on a star icon. Then, you can find the project on this page.") diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index deed774a4a5..446b4715b2d 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -4,6 +4,8 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") += render_if_exists "shared/gold_trial_callout" + - page_title "Projects" - header_title "Projects", dashboard_projects_path diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml index 8933d9e31ff..3a45f6df017 100644 --- a/app/views/dashboard/projects/starred.html.haml +++ b/app/views/dashboard/projects/starred.html.haml @@ -1,8 +1,10 @@ - @hide_top_links = true - @no_container = true -- breadcrumb_title "Projects" -- page_title "Starred Projects" -- header_title "Projects", dashboard_projects_path +- breadcrumb_title _("Projects") +- page_title _("Starred Projects") +- header_title _("Projects"), dashboard_projects_path + += render_if_exists "shared/gold_trial_callout" %div{ class: container_class } = render "projects/last_push" @@ -11,5 +13,4 @@ - if params[:filter_projects] || any_projects?(@projects) = render 'projects' - else - %h3.page-title You don't have starred projects yet - %p.slead Visit project page and press on star icon and it will appear on this page. + = render 'starred_empty_state' diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml index 6eb067da95c..b649fe91c24 100644 --- a/app/views/dashboard/snippets/index.html.haml +++ b/app/views/dashboard/snippets/index.html.haml @@ -3,6 +3,14 @@ - header_title "Snippets", dashboard_snippets_path = render 'dashboard/snippets_head' -= render partial: 'snippets/snippets_scope_menu', locals: { include_private: true } +- if current_user.snippets.exists? + = render partial: 'snippets/snippets_scope_menu', locals: { include_private: true } -= render partial: 'snippets/snippets', locals: { link_project: true } +.d-block.d-sm-none + + = link_to _("New snippet"), new_snippet_path, class: "btn btn-success btn-block", title: _("New snippet") + +- if current_user.snippets.exists? + = render partial: 'shared/snippets/list', locals: { link_project: true } +- else + = render 'shared/empty_states/snippets', button_path: new_snippet_path diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index d2593179f17..47729321961 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -2,6 +2,8 @@ - page_title "Todos" - header_title "Todos", dashboard_todos_path += render_if_exists "shared/gold_trial_callout" + .page-title-holder %h1.page-title= _('Todos') diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml index 3d0a1f622a5..ccc3e734276 100644 --- a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml +++ b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml @@ -1,5 +1,5 @@ #content - = email_default_heading("#{@resource.user.name}, you've added an additional email!") + = email_default_heading("#{sanitize_name(@resource.user.name)}, you've added an additional email!") %p Click the link below to confirm your email address (#{@resource.email}) #cta = link_to 'Confirm your email address', confirmation_url(@resource, confirmation_token: @token) diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml index 4b8ad5acd5b..dd1edb5fdc9 100644 --- a/app/views/devise/passwords/edit.html.haml +++ b/app/views/devise/passwords/edit.html.haml @@ -7,7 +7,7 @@ = f.hidden_field :reset_password_token .form-group = f.label 'New password', for: "user_password" - = f.password_field :password, class: "form-control top qa-password-field", required: true, title: 'This field is required' + = f.password_field :password, class: "form-control top qa-password-field", required: true, title: 'This field is required' .form-group = f.label 'Confirm new password', for: "user_password_confirmation" = f.password_field :password_confirmation, class: "form-control bottom qa-password-confirmation", title: 'This field is required', required: true diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml index 796c0cadda8..f856773526d 100644 --- a/app/views/devise/sessions/_new_ldap.html.haml +++ b/app/views/devise/sessions/_new_ldap.html.haml @@ -1,3 +1,5 @@ +- server = local_assigns.fetch(:server) + = form_tag(omniauth_callback_path(:user, server['provider_name']), id: 'new_ldap_user', class: "gl-show-field-errors") do .form-group = label_tag :username, "#{server['label']} Username" diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index 34d4293bd45..30ed7ed6b29 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -1,6 +1,6 @@ - page_title "Sign in" -%div +#signin-container - if form_based_providers.any? = render 'devise/shared/tabs_ldap' - else diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml index fefdf5f9531..f49cdfbf8da 100644 --- a/app/views/devise/sessions/two_factor.html.haml +++ b/app/views/devise/sessions/two_factor.html.haml @@ -7,7 +7,7 @@ - resource_params = params[resource_name].presence || params = f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0) %div - = f.label 'Two-Factor Authentication code', name: :otp_attempt + = f.label 'Two-Factor Authentication code', name: :otp_attempt = f.text_field :otp_attempt, class: 'form-control', required: true, autofocus: true, autocomplete: 'off', title: 'This field is required.' %p.form-text.text-muted.hint Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes. .prepend-top-20 diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index 12271ee5adb..1b583ea85d6 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -5,7 +5,7 @@ .d-flex.justify-content-between.flex-wrap - providers.each do |provider| - has_icon = provider_has_icon?(provider) - = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'btn d-flex align-items-center omniauth-btn text-left oauth-login qa-saml-login-button', id: "oauth-login-#{provider}" do + = link_to omniauth_authorize_path(:user, provider), method: :post, class: "btn d-flex align-items-center omniauth-btn text-left oauth-login #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do - if has_icon = provider_image_tag(provider) %span diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 004a3528d4b..9c7ca6ebbd4 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -3,12 +3,12 @@ = form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f| .devise-errors = devise_error_messages! - .form-group + .name.form-group = f.label :name, 'Full name', class: 'label-bold' - = f.text_field :name, class: "form-control top qa-new-user-name", required: true, title: "This field is required." + = f.text_field :name, class: "form-control top qa-new-user-name js-block-emoji", required: true, title: _("This field is required.") .username.form-group = f.label :username, class: 'label-bold' - = f.text_field :username, class: "form-control middle qa-new-user-username", pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: 'Please create a username with only alphanumeric characters.' + = f.text_field :username, class: "form-control middle qa-new-user-username js-block-emoji", pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.") %p.validation-error.hide Username is already taken. %p.validation-success.hide Username is available. %p.validation-pending.hide Checking username availability... diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml index 74791b81ccd..dae9a7acf6b 100644 --- a/app/views/doorkeeper/authorizations/new.html.haml +++ b/app/views/doorkeeper/authorizations/new.html.haml @@ -31,7 +31,7 @@ %strong= t scope, scope: [:doorkeeper, :scopes] .text-secondary= t scope, scope: [:doorkeeper, :scope_desc] .form-actions.text-right - = form_tag oauth_authorization_path, method: :delete, class: 'inline' do + = form_tag oauth_authorization_path, method: :delete, class: 'inline' do = hidden_field_tag :client_id, @pre_auth.client.uid = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri = hidden_field_tag :state, @pre_auth.state diff --git a/app/views/email_rejection_mailer/rejection.html.haml b/app/views/email_rejection_mailer/rejection.html.haml index c4ae7befe4e..666dab61f40 100644 --- a/app/views/email_rejection_mailer/rejection.html.haml +++ b/app/views/email_rejection_mailer/rejection.html.haml @@ -1,5 +1,5 @@ %p - Unfortunately, your email message to GitLab could not be processed. + = _("Unfortunately, your email message to GitLab could not be processed.") = markdown @reason = render_if_exists 'shared/additional_email_text' diff --git a/app/views/email_rejection_mailer/rejection.text.haml b/app/views/email_rejection_mailer/rejection.text.haml index 0e13b2a6473..8d940ef1293 100644 --- a/app/views/email_rejection_mailer/rejection.text.haml +++ b/app/views/email_rejection_mailer/rejection.text.haml @@ -1,4 +1,4 @@ -Unfortunately, your email message to GitLab could not be processed. += _("Unfortunately, your email message to GitLab could not be processed.") \ = @reason = render_if_exists 'shared/additional_email_text' diff --git a/app/views/events/_events.html.haml b/app/views/events/_events.html.haml index 68c19df092d..e1b7804c5a7 100644 --- a/app/views/events/_events.html.haml +++ b/app/views/events/_events.html.haml @@ -1 +1,18 @@ -= render partial: 'events/event', collection: @events +- illustration_path = 'illustrations/profile-page/activity.svg' +- current_user_empty_message_header = s_('UserProfile|Join or create a group to start contributing by commenting on issues or submitting merge requests!') +- primary_button_label = _('New group') +- primary_button_link = new_group_path +- secondary_button_label = _('Explore groups') +- secondary_button_link = explore_groups_path +- visitor_empty_message = _('No activities found') + +- if @events.present? + = render partial: 'events/event', collection: @events +- else + = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path, + current_user_empty_message_header: current_user_empty_message_header, + primary_button_label: primary_button_label, + primary_button_link: primary_button_link, + secondary_button_label: secondary_button_label, + secondary_button_link: secondary_button_link, + visitor_empty_message: visitor_empty_message } diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml index fb0d2c3b8b0..90ed8e41d32 100644 --- a/app/views/events/event/_note.html.haml +++ b/app/views/events/event/_note.html.haml @@ -21,6 +21,6 @@ = link_to note.attachment.url, target: '_blank' do = image_tag note.attachment.url, class: 'note-image-attach' - else - = link_to note.attachment.url, target: '_blank', class: 'note-file-attach' do + = link_to note.attachment.url, target: '_blank', class: 'note-file-attach' do %i.fa.fa-paperclip = note.attachment_identifier diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml index a3eafc61d0a..869be4e8581 100644 --- a/app/views/explore/groups/index.html.haml +++ b/app/views/explore/groups/index.html.haml @@ -2,6 +2,8 @@ - page_title _("Groups") - header_title _("Groups"), dashboard_groups_path += render_if_exists "shared/gold_trial_callout" + - if current_user = render 'dashboard/groups_head' - else diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml index 452f390695c..d18dec7bd8e 100644 --- a/app/views/explore/projects/index.html.haml +++ b/app/views/explore/projects/index.html.haml @@ -2,6 +2,8 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path += render_if_exists "shared/gold_trial_callout" + - if current_user = render 'dashboard/projects_head' - else diff --git a/app/views/explore/projects/starred.html.haml b/app/views/explore/projects/starred.html.haml index 452f390695c..d18dec7bd8e 100644 --- a/app/views/explore/projects/starred.html.haml +++ b/app/views/explore/projects/starred.html.haml @@ -2,6 +2,8 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path += render_if_exists "shared/gold_trial_callout" + - if current_user = render 'dashboard/projects_head' - else diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml index 452f390695c..d18dec7bd8e 100644 --- a/app/views/explore/projects/trending.html.haml +++ b/app/views/explore/projects/trending.html.haml @@ -2,6 +2,8 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path += render_if_exists "shared/gold_trial_callout" + - if current_user = render 'dashboard/projects_head' - else diff --git a/app/views/explore/snippets/index.html.haml b/app/views/explore/snippets/index.html.haml index 94fc4ac21d2..d23c8301b10 100644 --- a/app/views/explore/snippets/index.html.haml +++ b/app/views/explore/snippets/index.html.haml @@ -7,4 +7,4 @@ - else = render 'explore/head' -= render partial: 'snippets/snippets', locals: { link_project: true } += render partial: 'shared/snippets/list', locals: { link_project: true } diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml index 82a497289f3..13df1e57125 100644 --- a/app/views/groups/_activities.html.haml +++ b/app/views/groups/_activities.html.haml @@ -1,7 +1,7 @@ .nav-block.activities = render 'shared/event_filter' .controls - = link_to group_path(@group, rss_url_options), class: 'btn rss-btn has-tooltip' , title: 'Subscribe' do + = link_to group_path(@group, rss_url_options), class: 'btn d-none d-sm-inline-block has-tooltip' , title: 'Subscribe' do %i.fa.fa-rss .content_list diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index 6219da2c715..39c0c113793 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -1,17 +1,58 @@ -.group-home-panel.text-center.border-bottom - %div{ class: container_class } - .avatar-container.s70.group-avatar - = group_icon(@group, class: "avatar s70 avatar-tile") - %h1.group-title - = @group.name - %span.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } - = visibility_level_icon(@group.visibility_level, fw: false) +- can_create_subgroups = can?(current_user, :create_subgroup, @group) - - if @group.description.present? - .group-home-desc - = markdown_field(@group, :description) +.group-home-panel + .row.mb-3 + .home-panel-title-row.col-md-12.col-lg-6.d-flex + .avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none + = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64) + .d-flex.flex-column.flex-wrap.align-items-baseline + .d-inline-flex.align-items-baseline + %h1.home-panel-title.prepend-top-8.append-bottom-5 + = @group.name + %span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } + = visibility_level_icon(@group.visibility_level, fw: false, options: {class: 'icon'}) + .home-panel-metadata.d-flex.align-items-center.text-secondary + %span + = _("Group") + - if current_user + %span.access-request-links.prepend-left-8 + = render 'shared/members/access_request_links', source: @group - - if current_user - .group-buttons - = render 'shared/members/access_request_buttons', source: @group - = render 'shared/notifications/button', notification_setting: @notification_setting + .home-panel-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end + - if current_user + .group-buttons + = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn' + - if can? current_user, :create_projects, @group + - new_project_label = _("New project") + - new_subgroup_label = _("New subgroup") + - if can_create_subgroups + .btn-group.new-project-subgroup.droplab-dropdown.home-panel-action-button.prepend-top-default.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } } + %input.btn.btn-success.dropdown-primary.js-new-group-child.qa-new-in-group-button{ type: "button", value: new_project_label, data: { action: "new-project" } } + %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle.qa-new-project-or-subgroup-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown", 'display' => 'static' } } + = sprite_icon("arrow-down", css_class: "icon dropdown-btn-icon") + %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-right{ data: { dropdown: true } } + %li.droplab-item-selected.qa-new-project-option{ role: "button", data: { value: "new-project", text: new_project_label } } + .menu-item + .icon-container + = icon("check", class: "list-item-checkmark") + .description + %strong= new_project_label + %span= s_("GroupsTree|Create a project in this group.") + %li.divider.droplap-item-ignore + %li.qa-new-subgroup-option{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } } + .menu-item + .icon-container + = icon("check", class: "list-item-checkmark") + .description + %strong= new_subgroup_label + %span= s_("GroupsTree|Create a subgroup in this group.") + - else + = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success" + + - if @group.description.present? + .group-home-desc.mt-1 + .home-panel-description + .home-panel-description-markdown.read-more-container + = markdown_field(@group, :description) + %button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" } + = _("Read more") diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 13d584f5f1d..2af3e861587 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -23,7 +23,7 @@ Members with access to %strong= @group.name %span.badge= @members.total_count - = form_tag group_group_members_path(@group), method: :get, class: 'form-inline member-search-form flex-project-members-form' do + = form_tag group_group_members_path(@group), method: :get, class: 'form-inline member-search-form flex-project-members-form' do .form-group .position-relative.append-right-8 = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml index 4df3d831942..5cf3193bc62 100644 --- a/app/views/groups/labels/index.html.haml +++ b/app/views/groups/labels/index.html.haml @@ -6,15 +6,10 @@ - subscribed = params[:subscribed] - labels_or_filters = @labels.exists? || search.present? || subscribed.present? -- if @labels.present? && can_admin_label - - content_for(:header_content) do - .nav-controls - = link_to _('New label'), new_group_label_path(@group), class: "btn btn-success" - - if labels_or_filters #promote-label-modal %div{ class: container_class } - = render 'shared/labels/nav' + = render 'shared/labels/nav', labels_or_filters: labels_or_filters, can_admin_label: can_admin_label .labels-container.prepend-top-5 - if @labels.any? diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml index a9ce2fe5ab0..808bb1309b1 100644 --- a/app/views/groups/merge_requests.html.haml +++ b/app/views/groups/merge_requests.html.haml @@ -7,7 +7,7 @@ = render 'shared/issuable/nav', type: :merge_requests - if current_user .nav-controls - = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", type: :merge_requests, with_feature_enabled: 'merge_requests', with_shared: false, include_projects_in_subgroups: true + = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", type: :merge_requests, with_feature_enabled: 'merge_requests', with_shared: false, include_projects_in_subgroups: true = render 'shared/issuable/search_bar', type: :merge_requests diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml index 39e3af5f6d2..b0ba846f204 100644 --- a/app/views/groups/milestones/_form.html.haml +++ b/app/views/groups/milestones/_form.html.haml @@ -1,20 +1,20 @@ -= form_for [@group, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f| += form_for [@group, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f| + = form_errors(@milestone) .row - = form_errors(@milestone) - .col-md-6 .form-group.row - = f.label :title, "Title", class: "col-form-label col-sm-2" + .col-form-label.col-sm-2 + = f.label :title, "Title" .col-sm-10 = f.text_field :title, maxlength: 255, class: "form-control", required: true, autofocus: true .form-group.row.milestone-description - = f.label :description, "Description", class: "col-form-label col-sm-2" + .col-form-label.col-sm-2 + = f.label :description, "Description" .col-sm-10 = render layout: 'projects/md_preview', locals: { url: group_preview_markdown_path } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...', supports_autocomplete: false - .clearfix - .error-alert - + .clearfix + .error-alert = render "shared/milestones/form_dates", f: f .form-actions @@ -24,4 +24,3 @@ - else = f.submit 'Update milestone', class: "btn-success btn" = link_to "Cancel", group_milestone_path(@group, @milestone), class: "btn btn-cancel" - diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml index af4fe8f2ef8..b6fb908c8f6 100644 --- a/app/views/groups/milestones/index.html.haml +++ b/app/views/groups/milestones/index.html.haml @@ -4,6 +4,7 @@ = render 'shared/milestones_filter', counts: @milestone_states .nav-controls + = render 'shared/milestones/search_form' = render 'shared/milestones_sort_dropdown' - if can?(current_user, :admin_milestone, @group) = link_to "New milestone", new_group_milestone_path(@group), class: "btn btn-success" diff --git a/app/views/groups/runners/_group_runners.html.haml b/app/views/groups/runners/_group_runners.html.haml index bcfb6d99716..806a24e2bbc 100644 --- a/app/views/groups/runners/_group_runners.html.haml +++ b/app/views/groups/runners/_group_runners.html.haml @@ -21,6 +21,6 @@ - else %h4.underlined-title - = _('Available group Runners : %{runners}.').html_safe % { runners: @group.runners.count } + = _('Available group Runners: %{runners}').html_safe % { runners: @group.runners.count } %ul.bordered-list = render partial: 'groups/runners/runner', collection: @group.runners, as: :runner diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml index 0424ece037d..9ed71d19d32 100644 --- a/app/views/groups/settings/_general.html.haml +++ b/app/views/groups/settings/_general.html.haml @@ -20,7 +20,7 @@ = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group .form-group.prepend-top-default.append-bottom-20 - .avatar-container.s90 + .avatar-container.rect-avatar.s90 = group_icon(@group, alt: '', class: 'avatar group-avatar s90') = f.label :avatar, _('Group avatar'), class: 'label-bold d-block' = render 'shared/choose_group_avatar_button', f: f diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index cc294f6a931..77fe88dacb7 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -1,66 +1,41 @@ - @no_container = true - breadcrumb_title _("Details") -- can_create_subgroups = can?(current_user, :create_subgroup, @group) +- @content_class = "limit-container-width" unless fluid_layout = content_for :meta_tags do = auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity") -= render 'groups/home_panel' - -.groups-listing{ class: container_class, data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } - .top-area.group-nav-container - .group-search - = render "shared/groups/search_form" - - if can? current_user, :create_projects, @group - - new_project_label = _("New project") - - new_subgroup_label = _("New subgroup") - - if can_create_subgroups - .btn-group.new-project-subgroup.droplab-dropdown.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } } - %input.btn.btn-success.dropdown-primary.js-new-group-child.qa-new-in-group-button{ type: "button", value: new_project_label, data: { action: "new-project" } } - %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle.qa-new-project-or-subgroup-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown", 'display' => 'static' } } - = icon("caret-down", class: "dropdown-btn-icon") - %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-right{ data: { dropdown: true } } - %li.droplab-item-selected.qa-new-project-option{ role: "button", data: { value: "new-project", text: new_project_label } } - .menu-item - .icon-container - = icon("check", class: "list-item-checkmark") - .description - %strong= new_project_label - %span= s_("GroupsTree|Create a project in this group.") - %li.divider.droplap-item-ignore - %li.qa-new-subgroup-option{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } } - .menu-item - .icon-container - = icon("check", class: "list-item-checkmark") - .description - %strong= new_subgroup_label - %span= s_("GroupsTree|Create a subgroup in this group.") - - else - = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success" - - .scrolling-tabs-container.inner-page-scroll-tabs - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') - %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs - %li.js-subgroups_and_projects-tab - = link_to group_path, data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab'} do - = _("Subgroups and projects") - %li.js-shared-tab - = link_to group_shared_path, data: { target: 'div#shared', action: 'shared', toggle: 'tab'} do - = _("Shared projects") - %li.js-archived-tab - = link_to group_archived_path, data: { target: 'div#archived', action: 'archived', toggle: 'tab'} do - = _("Archived projects") - - .nav-controls - = render "shared/groups/dropdown", options_hash: subgroups_sort_options_hash - - .tab-content - #subgroups_and_projects.tab-pane - = render "subgroups_and_projects", group: @group - - #shared.tab-pane - = render "shared_projects", group: @group - - #archived.tab-pane - = render "archived_projects", group: @group +%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } + = render 'groups/home_panel' + + .groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } + .top-area.group-nav-container + .scrolling-tabs-container.inner-page-scroll-tabs + .fade-left= icon('angle-left') + .fade-right= icon('angle-right') + %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs + %li.js-subgroups_and_projects-tab + = link_to group_path, data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab'} do + = _("Subgroups and projects") + %li.js-shared-tab + = link_to group_shared_path, data: { target: 'div#shared', action: 'shared', toggle: 'tab'} do + = _("Shared projects") + %li.js-archived-tab + = link_to group_archived_path, data: { target: 'div#archived', action: 'archived', toggle: 'tab'} do + = _("Archived projects") + + .nav-controls.d-block.d-md-flex + .group-search + = render "shared/groups/search_form" + + = render "shared/groups/dropdown", options_hash: subgroups_sort_options_hash + + .tab-content + #subgroups_and_projects.tab-pane + = render "subgroups_and_projects", group: @group + + #shared.tab-pane + = render "shared_projects", group: @group + + #archived.tab-pane + = render "archived_projects", group: @group diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index 28ffb2dd63c..efb3815b257 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -356,6 +356,18 @@ %td.shortcut %kbd l %td Change Label + %tr + %td.shortcut + %kbd ] + \/ + %kbd j + %td Move to next file + %tr + %td.shortcut + %kbd [ + \/ + %kbd k + %td Move to previous file %tbody.hidden-shortcut{ style: 'display:none' } %tr %th diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml index b24d6e27536..057225d021f 100644 --- a/app/views/ide/_show.html.haml +++ b/app/views/ide/_show.html.haml @@ -4,7 +4,7 @@ - content_for :page_specific_javascripts do = stylesheet_link_tag 'page_bundles/ide' -#ide.ide-loading{ data: ide_data() } +#ide.ide-loading{ data: ide_data } .text-center = icon('spinner spin 2x') %h2.clgray= _('Loading the GitLab IDE...') diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml index f4a29ed18dc..b05c039c85c 100644 --- a/app/views/import/_githubish_status.html.haml +++ b/app/views/import/_githubish_status.html.haml @@ -1,56 +1,9 @@ - provider = local_assigns.fetch(:provider) - provider_title = Gitlab::ImportSources.title(provider) -%p.light - = import_githubish_choose_repository_message -%hr -%p - = button_tag class: "btn btn-import btn-success js-import-all" do - = import_all_githubish_repositories_button_label - = icon("spinner spin", class: "loading-icon") - -.table-responsive - %table.table.import-jobs - %colgroup.import-jobs-from-col - %colgroup.import-jobs-to-col - %colgroup.import-jobs-status-col - %thead - %tr - %th= _('From %{provider_title}') % { provider_title: provider_title } - %th= _('To GitLab') - %th= _('Status') - %tbody - - @already_added_projects.each do |project| - %tr{ id: "project_#{project.id}", class: project_status_css_class(project.import_status) } - %td - = provider_project_link(provider, project.import_source) - %td - = link_to project.full_path, [project.namespace.becomes(Namespace), project] - %td.job-status - = render 'import/project_status', project: project - - - @repos.each do |repo| - %tr{ id: "repo_#{repo.id}", data: { qa: { repo_path: repo.full_name } } } - %td - = provider_project_link(provider, repo.full_name) - %td.import-target - %fieldset.row - .input-group - .project-path.input-group-prepend - - if current_user.can_select_namespace? - - selected = params[:namespace_id] || :current_user - - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner.login, path: repo.owner.login) } : {} - = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace qa-project-namespace-select', tabindex: 1 } - - else - = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true - %span.input-group-prepend - .input-group-text / - = text_field_tag :path, sanitize_project_name(repo.name), class: "input-mini form-control", tabindex: 2, autofocus: true, required: true - %td.import-actions.job-status - = button_tag class: "btn btn-import js-add-to-import" do - = has_ci_cd_only_params? ? _('Connect') : _('Import') - = icon("spinner spin", class: "loading-icon") - -.js-importer-status{ data: { jobs_import_path: url_for([:jobs, :import, provider]), - import_path: url_for([:import, provider]), - ci_cd_only: has_ci_cd_only_params?.to_s } } +#import-projects-mount-element{ data: { provider: provider, provider_title: provider_title, + can_select_namespace: current_user.can_select_namespace?.to_s, + ci_cd_only: has_ci_cd_only_params?.to_s, + repos_path: url_for([:status, :import, provider, format: :json]), + jobs_path: url_for([:realtime_changes, :import, provider, format: :json]), + import_path: url_for([:import, provider, format: :json]) } } diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml index ef69197e453..9280f12e187 100644 --- a/app/views/import/bitbucket_server/status.html.haml +++ b/app/views/import/bitbucket_server/status.html.haml @@ -56,7 +56,7 @@ .project-path.input-group-prepend - if current_user.can_select_namespace? - selected = params[:namespace_id] || :extra_group - - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.project_key, path: repo.project_key) } : {} + - opts = current_user.can_create_group? ? { extra_group: Group.new(name: sanitize_project_name(repo.project_key), path: sanitize_project_name(repo.project_key)) } : {} = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace', tabindex: 1 } - else = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index 6ff25f2c842..cf32c5c9387 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -4,7 +4,7 @@ - header_title _("Projects"), root_path %h3.page-title - = icon 'github', text: import_github_title + = icon 'github', text: _('Import repositories from GitHub') - if github_import_configured? %p diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml index be057be6d1a..ee295e70cce 100644 --- a/app/views/import/github/status.html.haml +++ b/app/views/import/github/status.html.haml @@ -2,7 +2,7 @@ - page_title title - breadcrumb_title title - header_title _("Projects"), root_path -%h3.page-title - = icon 'github', text: import_github_title +%h3.page-title.mb-0 + = icon 'github', class: 'fa-2x', text: _('Import repositories from GitHub') = render 'import/githubish_status', provider: 'github' diff --git a/app/views/import/manifest/status.html.haml b/app/views/import/manifest/status.html.haml index 5b2e1005398..3d4abc32b88 100644 --- a/app/views/import/manifest/status.html.haml +++ b/app/views/import/manifest/status.html.haml @@ -7,7 +7,7 @@ %p = button_tag class: "btn btn-import btn-success js-import-all" do - = import_all_githubish_repositories_button_label + = _('Import all repositories') = icon("spinner spin", class: "loading-icon") .table-responsive diff --git a/app/views/instance_statistics/conversational_development_index/_callout.html.haml b/app/views/instance_statistics/conversational_development_index/_callout.html.haml index 33a4dab1e00..a4256e23979 100644 --- a/app/views/instance_statistics/conversational_development_index/_callout.html.haml +++ b/app/views/instance_statistics/conversational_development_index/_callout.html.haml @@ -2,12 +2,12 @@ .user-callout{ data: { uid: 'convdev_intro_callout_dismissed' } } .bordered-box.landing.content-block %button.btn.btn-default.close.js-close-callout{ type: 'button', - 'aria-label' => 'Dismiss ConvDev introduction' } + 'aria-label' => _('Dismiss ConvDev introduction') } = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true') .user-callout-copy %h4 - Introducing Your Conversational Development Index + = _('Introducing Your Conversational Development Index') %p - Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers. + = _('Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.') .svg-container.convdev = custom_icon('convdev_overview') diff --git a/app/views/instance_statistics/conversational_development_index/_card.html.haml b/app/views/instance_statistics/conversational_development_index/_card.html.haml index 57eda06630b..76af55dcf7a 100644 --- a/app/views/instance_statistics/conversational_development_index/_card.html.haml +++ b/app/views/instance_statistics/conversational_development_index/_card.html.haml @@ -9,11 +9,11 @@ .board-card-score .board-card-score-value = format_score(card.instance_score) - .board-card-score-name You + .board-card-score-name= _('You') .board-card-score .board-card-score-value = format_score(card.leader_score) - .board-card-score-name Lead + .board-card-score-name= _('Lead') .board-card-score-big = number_to_percentage(card.percentage_score, precision: 1) .board-card-buttons diff --git a/app/views/instance_statistics/conversational_development_index/_no_data.html.haml b/app/views/instance_statistics/conversational_development_index/_no_data.html.haml index dd795aee135..4e8f34cd574 100644 --- a/app/views/instance_statistics/conversational_development_index/_no_data.html.haml +++ b/app/views/instance_statistics/conversational_development_index/_no_data.html.haml @@ -1,7 +1,7 @@ .container.convdev-empty .col-sm-12.justify-content-center.text-center = custom_icon('convdev_no_data') - %h4 Data is still calculating... + %h4= _('Data is still calculating...') %p - In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index. - = link_to 'Learn more', help_page_path('user/instance_statistics/convdev'), target: '_blank' + = _('In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index.') + = link_to _('Learn more'), help_page_path('user/instance_statistics/convdev'), target: '_blank' diff --git a/app/views/instance_statistics/conversational_development_index/index.html.haml b/app/views/instance_statistics/conversational_development_index/index.html.haml index 1e7db4982d6..23f90b876a0 100644 --- a/app/views/instance_statistics/conversational_development_index/index.html.haml +++ b/app/views/instance_statistics/conversational_development_index/index.html.haml @@ -17,9 +17,9 @@ %h2.convdev-header-title{ class: "convdev-#{score_level(@metric.average_percentage_score)}-score" } = number_to_percentage(@metric.average_percentage_score, precision: 1) .convdev-header-subtitle - index + = _('index') %br - score + = _('score') = link_to icon('question-circle', 'aria-hidden' => 'true'), help_page_path('user/instance_statistics/convdev') .convdev-cards.board-card-container diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 08a6359f777..11e83ddfe64 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -11,20 +11,20 @@ %meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' } -# Open Graph - http://ogp.me/ - %meta{ property: 'og:type', content: "object" } - %meta{ property: 'og:site_name', content: site_name } - %meta{ property: 'og:title', content: page_title } + %meta{ property: 'og:type', content: "object" } + %meta{ property: 'og:site_name', content: site_name } + %meta{ property: 'og:title', content: page_title } %meta{ property: 'og:description', content: page_description } - %meta{ property: 'og:image', content: page_image } + %meta{ property: 'og:image', content: page_image } %meta{ property: 'og:image:width', content: '64' } %meta{ property: 'og:image:height', content: '64' } - %meta{ property: 'og:url', content: request.base_url + request.fullpath } + %meta{ property: 'og:url', content: request.base_url + request.fullpath } -# Twitter Card - https://dev.twitter.com/cards/types/summary - %meta{ property: 'twitter:card', content: "summary" } - %meta{ property: 'twitter:title', content: page_title } - %meta{ property: 'twitter:description', content: page_description } - %meta{ property: 'twitter:image', content: page_image } + %meta{ property: 'twitter:card', content: "summary" } + %meta{ property: 'twitter:title', content: page_title } + %meta{ property: 'twitter:description', content: page_description } + %meta{ property: 'twitter:image', content: page_image } = page_card_meta_tags %title= page_title(site_name) @@ -33,11 +33,13 @@ = favicon_link_tag favicon, id: 'favicon', data: { original_href: favicon }, type: 'image/png' = stylesheet_link_tag "application", media: "all" - = stylesheet_link_tag "print", media: "print" - = stylesheet_link_tag "test", media: "all" if Rails.env.test? + = stylesheet_link_tag "print", media: "print" + = stylesheet_link_tag "test", media: "all" if Rails.env.test? = stylesheet_link_tag 'performance_bar' if performance_bar_enabled? = stylesheet_link_tag 'csslab' if Feature.enabled?(:csslab) + = stylesheet_link_tag "highlight/themes/#{user_color_scheme}", media: "all" + = Gon::Base.render_data - if content_for?(:library_javascripts) @@ -61,10 +63,10 @@ %meta{ name: 'theme-color', content: '#474D57' } -# Apple Safari/iOS home screen icons - = favicon_link_tag 'touch-icon-iphone.png', rel: 'apple-touch-icon' - = favicon_link_tag 'touch-icon-ipad.png', rel: 'apple-touch-icon', sizes: '76x76' + = favicon_link_tag 'touch-icon-iphone.png', rel: 'apple-touch-icon' + = favicon_link_tag 'touch-icon-ipad.png', rel: 'apple-touch-icon', sizes: '76x76' = favicon_link_tag 'touch-icon-iphone-retina.png', rel: 'apple-touch-icon', sizes: '120x120' - = favicon_link_tag 'touch-icon-ipad-retina.png', rel: 'apple-touch-icon', sizes: '152x152' + = favicon_link_tag 'touch-icon-ipad-retina.png', rel: 'apple-touch-icon', sizes: '152x152' %link{ rel: 'mask-icon', href: image_path('logo.svg'), color: 'rgb(226, 67, 41)' } -# Windows 8 pinned site tile diff --git a/app/views/layouts/_init_client_detection_flags.html.haml b/app/views/layouts/_init_client_detection_flags.html.haml new file mode 100644 index 00000000000..c729f8aa696 --- /dev/null +++ b/app/views/layouts/_init_client_detection_flags.html.haml @@ -0,0 +1,7 @@ +- client = client_js_flags + +- if client + -# haml-lint:disable InlineJavaScript + :javascript + gl = window.gl || {}; + gl.client = #{client.to_json}; diff --git a/app/views/layouts/_mailer.html.haml b/app/views/layouts/_mailer.html.haml index ddc1cdb24b5..e13490ed410 100644 --- a/app/views/layouts/_mailer.html.haml +++ b/app/views/layouts/_mailer.html.haml @@ -49,9 +49,10 @@ %table#body{ border: "0", cellpadding: "0", cellspacing: "0", style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;" } %tbody %tr.line - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#6b4fbb;height:4px;font-size:4px;line-height:4px;" } + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#6b4fbb;height:4px;font-size:4px;line-height:4px;" } %tr.header %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } + = html_header_message = header_logo %tr %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" } @@ -72,3 +73,6 @@ = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} · %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link } = yield :additional_footer + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } + = html_footer_message diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 1f4d24d996c..043cca6ad38 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,10 +1,13 @@ !!! 5 %html{ lang: I18n.locale, class: page_class } = render "layouts/head" - %body{ class: "#{user_application_theme} #{@body_class}", data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}", find_file: find_file_path } } + %body{ class: "#{user_application_theme} #{@body_class} #{client_class_list}", data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}", find_file: find_file_path } } = render "layouts/init_auto_complete" if @gfm_form + = render "layouts/init_client_detection_flags" = render 'peek/bar' - = render partial: "layouts/header/default", locals: { project: @project, group: @group } + = header_message + = render partial: "layouts/header/default", locals: { project: @project, group: @group } = render 'layouts/page', sidebar: sidebar, nav: nav + = footer_message = yield :scripts_body diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 6003d973c88..2f3c13aaf6e 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -2,6 +2,7 @@ %html.devise-layout-html{ class: system_message_class } = render "layouts/head" %body.ui-indigo.login-page.application.navless.qa-login-page{ data: { page: body_data_page } } + = header_message .page-wrap = render "layouts/header/empty" .login-page-broadcast @@ -34,3 +35,4 @@ = link_to _("Explore"), explore_root_path = link_to _("Help"), help_path = link_to _("About GitLab"), "https://about.gitlab.com/" + = footer_message diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index 663e5b24368..6c9c8aa4431 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -2,6 +2,7 @@ %html{ lang: "en", class: system_message_class } = render "layouts/head" %body.ui-indigo.login-page.application.navless + = header_message = render "layouts/header/empty" = render "layouts/broadcast" .container.navless-container @@ -15,3 +16,4 @@ = link_to _("Explore"), explore_root_path = link_to _("Help"), help_path = link_to _("About GitLab"), "https://about.gitlab.com/" + = footer_message diff --git a/app/views/layouts/empty_mailer.html.haml b/app/views/layouts/empty_mailer.html.haml new file mode 100644 index 00000000000..a25dcefd445 --- /dev/null +++ b/app/views/layouts/empty_mailer.html.haml @@ -0,0 +1,5 @@ += html_header_message + += yield + += html_footer_message diff --git a/app/views/layouts/empty_mailer.text.erb b/app/views/layouts/empty_mailer.text.erb new file mode 100644 index 00000000000..6ab0dbead07 --- /dev/null +++ b/app/views/layouts/empty_mailer.text.erb @@ -0,0 +1,5 @@ +<%= text_header_message %> + +<%= yield -%> + +<%= text_footer_message %> diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml index bfbfeee7c4b..1d40b78fa83 100644 --- a/app/views/layouts/group.html.haml +++ b/app/views/layouts/group.html.haml @@ -1,7 +1,7 @@ - page_title @group.name - page_description @group.description unless page_description - header_title group_title(@group) unless header_title -- nav "group" +- nav "group" - @left_sidebar = true - content_for :page_specific_javascripts do diff --git a/app/views/layouts/header/_empty.html.haml b/app/views/layouts/header/_empty.html.haml index 2dfc787b7a8..348ce18b122 100644 --- a/app/views/layouts/header/_empty.html.haml +++ b/app/views/layouts/header/_empty.html.haml @@ -1,4 +1,2 @@ %header.navbar.fixed-top.navbar-empty - .container - .mx-auto - = brand_header_logo + = brand_header_logo diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index 953c0e7f46c..cd9128c452b 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -2,5 +2,8 @@ - if current_user_menu?(:help) %li = link_to _("Help"), help_path + %li.divider + %li + = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback" - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) = render 'shared/user_dropdown_contributing_link' diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml index e42251f9ec8..5a66b02c048 100644 --- a/app/views/layouts/header/_new_dropdown.haml +++ b/app/views/layouts/header/_new_dropdown.haml @@ -1,4 +1,4 @@ -%li.header-new.dropdown{ data: { track_label: "new_dropdown", track_event: "click_dropdown" } } +%li.header-new.dropdown{ data: { track_label: "new_dropdown", track_event: "click_dropdown" } } = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", title: _("New..."), ref: 'tooltip', aria: { label: _("New...") }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static' } do = sprite_icon('plus-square', size: 16) = sprite_icon('angle-down', css_class: 'caret-down') diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb index 8e11174f8d7..f8032f3262b 100644 --- a/app/views/layouts/mailer.text.erb +++ b/app/views/layouts/mailer.text.erb @@ -1,4 +1,8 @@ +<%= text_header_message %> + <%= yield -%> -- <%# signature marker %> <%= _("You're receiving this email because of your account on %{host}.") % { host: Gitlab.config.gitlab.host } %> + +<%= text_footer_message %> diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index ddd30efe062..f659c89dd30 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -10,7 +10,7 @@ = render "layouts/nav/projects_dropdown/show" - if dashboard_nav_link?(:groups) - = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "home dropdown header-groups qa-groups-dropdown", data: { track_label: "groups_dropdown", track_event: "click_dropdown" } }) do + = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "home dropdown header-groups qa-groups-dropdown", data: { track_label: "groups_dropdown", track_event: "click_dropdown" } }) do %button{ type: 'button', data: { toggle: "dropdown" } } = _('Groups') = sprite_icon('angle-down', css_class: 'caret-down') diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 5f15ba87729..2fdd65f639b 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -207,7 +207,7 @@ = _('Settings') %li.divider.fly-out-top-item = nav_link(path: 'application_settings#show') do - = link_to admin_application_settings_path, title: _('General') do + = link_to admin_application_settings_path, title: _('General'), class: 'qa-admin-settings-general-item' do %span = _('General') = nav_link(path: 'application_settings#integrations') do diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 477030a20c1..21ea9f3b2f3 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -6,7 +6,7 @@ .nav-sidebar-inner-scroll .context-header = link_to group_path(@group), title: @group.name do - .avatar-container.s40.group-avatar + .avatar-container.rect-avatar.s40.group-avatar = group_icon(@group, class: "avatar s40 avatar-tile") .sidebar-context-title = @group.name @@ -36,7 +36,7 @@ %span = _('Activity') - = render_if_exists 'groups/sidebar/security_dashboard' + = render_if_exists 'groups/sidebar/security_dashboard' # EE-specific - if group_sidebar_link?(:contribution_analytics) = nav_link(path: 'analytics#show') do @@ -103,19 +103,6 @@ = _('Merge Requests') %span.badge.badge-pill.count.merge_counter.js-merge-counter.fly-out-badge= number_with_delimiter(merge_requests_count) - - if group_sidebar_link?(:group_members) - = nav_link(path: 'group_members#index') do - = link_to group_group_members_path(@group) do - .nav-icon-container - = sprite_icon('users') - %span.nav-item-name.qa-group-members-item - = _('Members') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(path: 'group_members#index', html_options: { class: "fly-out-top-item" } ) do - = link_to group_group_members_path(@group) do - %strong.fly-out-top-item-name - = _('Members') - - if group_sidebar_link?(:kubernetes) = nav_link(controller: [:clusters]) do = link_to group_clusters_path(@group) do @@ -129,6 +116,19 @@ %strong.fly-out-top-item-name = _('Kubernetes') + - if group_sidebar_link?(:group_members) + = nav_link(path: 'group_members#index') do + = link_to group_group_members_path(@group) do + .nav-icon-container + = sprite_icon('users') + %span.nav-item-name.qa-group-members-item + = _('Members') + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(path: 'group_members#index', html_options: { class: "fly-out-top-item" } ) do + = link_to group_group_members_path(@group) do + %strong.fly-out-top-item-name + = _('Members') + - if group_sidebar_link?(:settings) = nav_link(path: group_nav_link_paths) do = link_to edit_group_path(@group) do diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml index 69167edb1df..1e3bb8f1224 100644 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ b/app/views/layouts/nav/sidebar/_profile.html.haml @@ -3,7 +3,7 @@ .context-header = link_to profile_path, title: _('Profile Settings') do .avatar-container.s40.settings-avatar - = sprite_icon('user', size: 24) + = image_tag avatar_icon_for_user(current_user, 40), class: "avatar s40 avatar-tile", alt: current_user.name .sidebar-context-title User Settings %ul.sidebar-top-level-items = nav_link(path: 'profiles#show', html_options: {class: 'home'}) do diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 59557c70904..7b492efeb09 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -3,7 +3,7 @@ - can_edit = can?(current_user, :admin_project, @project) .context-header = link_to project_path(@project), title: @project.name do - .avatar-container.s40.project-avatar + .avatar-container.rect-avatar.s40.project-avatar = project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile', width: 40, height: 40) .sidebar-context-title = @project.name @@ -26,10 +26,10 @@ %span= _('Details') = nav_link(path: 'projects#activity') do - = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do + = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity qa-activity-link' do %span= _('Activity') - - if project_nav_tab?(:releases) && Feature.enabled?(:releases_page) + - if project_nav_tab?(:releases) = nav_link(controller: :releases) do = link_to project_releases_path(@project), title: _('Releases'), class: 'shortcuts-project-releases' do %span= _('Releases') @@ -43,7 +43,7 @@ - if project_nav_tab? :files = nav_link(controller: sidebar_repository_paths) do - = link_to project_tree_path(@project), class: 'shortcuts-tree' do + = link_to project_tree_path(@project), class: 'shortcuts-tree qa-project-menu-repo' do .nav-icon-container = sprite_icon('doc-text') %span.nav-item-name @@ -64,7 +64,7 @@ = _('Commits') = nav_link(html_options: {class: branches_tab_class}) do - = link_to project_branches_path(@project) do + = link_to project_branches_path(@project), class: 'qa-branches-link' do = _('Branches') = nav_link(controller: [:tags]) do @@ -146,7 +146,7 @@ - if project_nav_tab? :merge_requests = nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :labels, :milestones]) do - = link_to project_merge_requests_path(@project), class: 'shortcuts-merge_requests' do + = link_to project_merge_requests_path(@project), class: 'shortcuts-merge_requests qa-merge-requests-link' do .nav-icon-container = sprite_icon('git-merge') %span.nav-item-name @@ -170,7 +170,7 @@ = _('CI / CD') %ul.sidebar-sub-level-items - = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts], html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts], html_options: { class: "fly-out-top-item" }) do = link_to project_pipelines_path(@project) do %strong.fly-out-top-item-name = _('CI / CD') @@ -201,7 +201,7 @@ - if project_nav_tab? :operations = nav_link(controller: sidebar_operations_paths) do - = link_to sidebar_operations_link_path, class: 'shortcuts-operations' do + = link_to sidebar_operations_link_path, class: 'shortcuts-operations qa-link-operations' do .nav-icon-container = sprite_icon('cloud-gear') %span.nav-item-name @@ -227,6 +227,12 @@ %span = _('Environments') + - if project_nav_tab?(:error_tracking) + = nav_link(controller: :error_tracking) do + = link_to project_error_tracking_index_path(@project), title: _('Error Tracking'), class: 'shortcuts-tracking qa-operations-tracking-link' do + %span + = _('Error Tracking') + - if project_nav_tab? :serverless = nav_link(controller: :functions) do = link_to project_serverless_functions_path(@project), title: _('Serverless') do @@ -275,19 +281,34 @@ %strong.fly-out-top-item-name = _('Registry') - - if project_nav_tab? :wiki + - if project_nav_tab?(:wiki) + - wiki_url = project_wiki_path(@project, :home) = nav_link(controller: :wikis) do - = link_to get_project_wiki_path(@project), class: 'shortcuts-wiki' do + = link_to wiki_url, class: 'shortcuts-wiki qa-wiki-link' do .nav-icon-container = sprite_icon('book') %span.nav-item-name = _('Wiki') %ul.sidebar-sub-level-items.is-fly-out-only = nav_link(controller: :wikis, html_options: { class: "fly-out-top-item" } ) do - = link_to get_project_wiki_path(@project) do + = link_to wiki_url do %strong.fly-out-top-item-name = _('Wiki') + - if project_nav_tab?(:external_wiki) + - external_wiki_url = @project.external_wiki.external_wiki_url + = nav_link do + = link_to external_wiki_url, class: 'shortcuts-external_wiki' do + .nav-icon-container + = sprite_icon('issue-external') + %span.nav-item-name + = _('External Wiki') + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(html_options: { class: "fly-out-top-item" } ) do + = link_to external_wiki_url do + %strong.fly-out-top-item-name + = _('External Wiki') + - if project_nav_tab? :snippets = nav_link(controller: :snippets) do = link_to project_snippets_path(@project), class: 'shortcuts-snippets' do @@ -339,7 +360,10 @@ = link_to project_settings_ci_cd_path(@project), title: _('CI / CD') do %span = _('CI / CD') - = render_if_exists 'projects/sidebar/settings_operations' + - if settings_operations_available? + = nav_link(controller: [:operations]) do + = link_to project_settings_operations_path(@project), title: _('Operations') do + = _('Operations') - if @project.pages_available? = nav_link(controller: :pages) do = link_to project_pages_path(@project), title: _('Pages') do diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml index 1c3e05e07f4..8dff12c1b7f 100644 --- a/app/views/layouts/notify.html.haml +++ b/app/views/layouts/notify.html.haml @@ -7,6 +7,7 @@ = yield :head %body .content + = html_header_message = yield .footer{ style: "margin-top: 10px;" } %p @@ -30,3 +31,4 @@ adjust your notification settings. = email_action @target_url + = html_footer_message diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb index 9dc490efa9a..248916fba63 100644 --- a/app/views/layouts/notify.text.erb +++ b/app/views/layouts/notify.text.erb @@ -1,3 +1,5 @@ +<%= text_header_message %> + <%= yield -%> -- <%# signature marker %> @@ -10,3 +12,5 @@ <% end -%> <%= "You're receiving this email because #{notification_reason_text(@reason)}." %> + +<%= text_footer_message -%> diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml index 6418500d5d1..5f986c81ff4 100644 --- a/app/views/layouts/snippets.html.haml +++ b/app/views/layouts/snippets.html.haml @@ -1,4 +1,4 @@ -- header_title _("Snippets"), snippets_path +- header_title _("Snippets"), snippets_path - content_for :page_specific_javascripts do - if @snippet && current_user diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb index 50209c46ed1..5a67214059c 100644 --- a/app/views/notify/_note_email.text.erb +++ b/app/views/notify/_note_email.text.erb @@ -3,7 +3,7 @@ <% discussion = note.discussion if note.part_of_discussion? -%> <% if discussion && !discussion.individual_note? -%> -<%= note.author_name -%> +<%= sanitize_name(note.author_name) -%> <% if discussion.new_discussion? -%> <%= " started a new discussion" -%> <% else -%> @@ -16,7 +16,7 @@ <% elsif Gitlab::CurrentSettings.email_author_in_body -%> -<%= "#{note.author_name} commented:" -%> +<%= "#{sanitize_name(note.author_name)} commented:" -%> <% end -%> diff --git a/app/views/notify/autodevops_disabled_email.text.erb b/app/views/notify/autodevops_disabled_email.text.erb index 695780c3145..bf863952478 100644 --- a/app/views/notify/autodevops_disabled_email.text.erb +++ b/app/views/notify/autodevops_disabled_email.text.erb @@ -3,7 +3,7 @@ Auto DevOps pipeline was disabled for <%= @project.name %> The Auto DevOps pipeline failed for pipeline <%= @pipeline.iid %> (<%= pipeline_url(@pipeline) %>) and has been disabled for <%= @project.name %>. In order to use the Auto DevOps pipeline with your project, please review the currently supported languagues (https://docs.gitlab.com/ee/topics/autodevops/#currently-supported-languages), adjust your project accordingly, and turn on the Auto DevOps pipeline within your CI/CD project settings (<%= project_settings_ci_cd_url(@project) %>). <% if @pipeline.user -%> - Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> ) + Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> ) <% else -%> Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API <% end -%> diff --git a/app/views/notify/closed_issue_email.html.haml b/app/views/notify/closed_issue_email.html.haml index b7284dd819b..eb148d72da1 100644 --- a/app/views/notify/closed_issue_email.html.haml +++ b/app/views/notify/closed_issue_email.html.haml @@ -1,2 +1,2 @@ %p - Issue was closed by #{@updated_by.name} + Issue was closed by #{sanitize_name(@updated_by.name)} diff --git a/app/views/notify/closed_issue_email.text.haml b/app/views/notify/closed_issue_email.text.haml index b35d4b7502d..b1f0a3f37ec 100644 --- a/app/views/notify/closed_issue_email.text.haml +++ b/app/views/notify/closed_issue_email.text.haml @@ -1,3 +1,3 @@ -Issue was closed by #{@updated_by.name} +Issue was closed by #{sanitize_name(@updated_by.name)} Issue ##{@issue.iid}: #{project_issue_url(@issue.project, @issue)} diff --git a/app/views/notify/closed_merge_request_email.html.haml b/app/views/notify/closed_merge_request_email.html.haml index 44e018304e1..2aa753e0d55 100644 --- a/app/views/notify/closed_merge_request_email.html.haml +++ b/app/views/notify/closed_merge_request_email.html.haml @@ -1,2 +1,2 @@ %p - Merge Request #{@merge_request.to_reference} was closed by #{@updated_by.name} + Merge Request #{@merge_request.to_reference} was closed by #{sanitize_name(@updated_by.name)} diff --git a/app/views/notify/closed_merge_request_email.text.haml b/app/views/notify/closed_merge_request_email.text.haml index c4e06cb3cb1..1094d584a1c 100644 --- a/app/views/notify/closed_merge_request_email.text.haml +++ b/app/views/notify/closed_merge_request_email.text.haml @@ -1,8 +1,8 @@ -Merge Request #{@merge_request.to_reference} was closed by #{@updated_by.name} +Merge Request #{@merge_request.to_reference} was closed by #{sanitize_name(@updated_by.name)} Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)} = merge_path_description(@merge_request, 'to') -Author: #{@merge_request.author_name} -Assignee: #{@merge_request.assignee_name} +Author: #{sanitize_name(@merge_request.author_name)} +Assignee: #{sanitize_name(@merge_request.assignee_name)} diff --git a/app/views/notify/import_issues_csv_email.html.haml b/app/views/notify/import_issues_csv_email.html.haml new file mode 100644 index 00000000000..f30d2b5f078 --- /dev/null +++ b/app/views/notify/import_issues_csv_email.html.haml @@ -0,0 +1,18 @@ +- text_style = 'font-size:16px; text-align:center; line-height:30px;' + +%p{ style: text_style } + Your CSV import for project + %a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none;" } + = @project.full_name + has been completed. + +%p{ style: text_style } + #{pluralize(@results[:success], 'issue')} imported. + +- if @results[:error_lines].present? + %p{ style: text_style } + Errors found on line #{'number'.pluralize(@results[:error_lines].size)}: #{@results[:error_lines].join(', ')}. Please check if these lines have an issue title. + +- if @results[:parse_error] + %p{ style: text_style } + Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values. diff --git a/app/views/notify/import_issues_csv_email.text.erb b/app/views/notify/import_issues_csv_email.text.erb new file mode 100644 index 00000000000..1117f90714d --- /dev/null +++ b/app/views/notify/import_issues_csv_email.text.erb @@ -0,0 +1,11 @@ +Your CSV import for project <%= @project.full_name %> (<%= project_url(@project) %>) has been completed. + +<%= pluralize(@results[:success], 'issue') %> imported. + +<% if @results[:error_lines].present? %> +Errors found on line <%= 'number'.pluralize(@results[:error_lines].size) %>: <%= @results[:error_lines].join(', ') %>. Please check if these lines have an issue title. +<% end %> + +<% if @results[:parse_error] %> +Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values. +<% end %> diff --git a/app/views/notify/issue_moved_email.html.haml b/app/views/notify/issue_moved_email.html.haml index 472c31e9a5e..b766cb1a523 100644 --- a/app/views/notify/issue_moved_email.html.haml +++ b/app/views/notify/issue_moved_email.html.haml @@ -1,6 +1,9 @@ %p Issue was moved to another project. -%p - New issue: - = link_to project_issue_url(@new_project, @new_issue) do - = @new_issue.title +- if @can_access_project + %p + New issue: + = link_to project_issue_url(@new_project, @new_issue) do + = @new_issue.title +- else + You don't have access to the project. diff --git a/app/views/notify/issue_moved_email.text.erb b/app/views/notify/issue_moved_email.text.erb index 66ede43635b..985e689aa9d 100644 --- a/app/views/notify/issue_moved_email.text.erb +++ b/app/views/notify/issue_moved_email.text.erb @@ -1,4 +1,8 @@ Issue was moved to another project. +<% if @can_access_project %> New issue location: <%= project_issue_url(@new_project, @new_issue) %> +<% else %> +You don't have access to the project. +<% end %> diff --git a/app/views/notify/issue_status_changed_email.html.haml b/app/views/notify/issue_status_changed_email.html.haml index b6051b11cea..66e73a9b03f 100644 --- a/app/views/notify/issue_status_changed_email.html.haml +++ b/app/views/notify/issue_status_changed_email.html.haml @@ -1,2 +1,2 @@ %p - Issue was #{@issue_status} by #{@updated_by.name} + Issue was #{@issue_status} by #{sanitize_name(@updated_by.name)} diff --git a/app/views/notify/issue_status_changed_email.text.erb b/app/views/notify/issue_status_changed_email.text.erb index 4200881f7e8..f38b09e9820 100644 --- a/app/views/notify/issue_status_changed_email.text.erb +++ b/app/views/notify/issue_status_changed_email.text.erb @@ -1,4 +1,4 @@ -Issue was <%= @issue_status %> by <%= @updated_by.name %> +Issue was <%= @issue_status %> by <%= sanitize_name(@updated_by.name) %> Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %> diff --git a/app/views/notify/member_access_requested_email.text.erb b/app/views/notify/member_access_requested_email.text.erb index 9c5ee0eaf26..ddb4a7b3d2c 100644 --- a/app/views/notify/member_access_requested_email.text.erb +++ b/app/views/notify/member_access_requested_email.text.erb @@ -1,3 +1,3 @@ -<%= member.user.name %> (<%= user_url(member.user) %>) requested <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>. +<%= sanitize_name(member.user.name) %> (<%= user_url(member.user) %>) requested <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>. <%= polymorphic_url([member_source, :members]) %> diff --git a/app/views/notify/member_invite_accepted_email.text.erb b/app/views/notify/member_invite_accepted_email.text.erb index cef87101427..c824533eac2 100644 --- a/app/views/notify/member_invite_accepted_email.text.erb +++ b/app/views/notify/member_invite_accepted_email.text.erb @@ -1,3 +1,3 @@ -<%= member.invite_email %>, now known as <%= member.user.name %>, has accepted your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>. +<%= member.invite_email %>, now known as <%= sanitize_name(member.user.name) %>, has accepted your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>. <%= member_source.web_url %> diff --git a/app/views/notify/member_invited_email.text.erb b/app/views/notify/member_invited_email.text.erb index 0a6393355be..d944c3b4a50 100644 --- a/app/views/notify/member_invited_email.text.erb +++ b/app/views/notify/member_invited_email.text.erb @@ -1,4 +1,4 @@ -You have been invited <%= "by #{member.created_by.name} " if member.created_by %>to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> as <%= member.human_access %>. +You have been invited <%= "by #{sanitize_name(member.created_by.name)} " if member.created_by %>to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> as <%= member.human_access %>. Accept invitation: <%= invite_url(@token) %> Decline invitation: <%= decline_invite_url(@token) %> diff --git a/app/views/notify/merge_request_status_email.html.haml b/app/views/notify/merge_request_status_email.html.haml index b487e26b122..ffb416abf72 100644 --- a/app/views/notify/merge_request_status_email.html.haml +++ b/app/views/notify/merge_request_status_email.html.haml @@ -1,2 +1,2 @@ %p - Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{@updated_by.name} + Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{sanitize_name(@updated_by.name)} diff --git a/app/views/notify/merge_request_status_email.text.haml b/app/views/notify/merge_request_status_email.text.haml index ae2a2933865..b9b9e0c3ad7 100644 --- a/app/views/notify/merge_request_status_email.text.haml +++ b/app/views/notify/merge_request_status_email.text.haml @@ -1,8 +1,8 @@ -Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{@updated_by.name} +Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{sanitize_name(@updated_by.name)} Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)} = merge_path_description(@merge_request, 'to') -Author: #{@merge_request.author_name} -Assignee: #{@merge_request.assignee_name} +Author: #{sanitize_name(@merge_request.author_name)} +Assignee: #{sanitize_name(@merge_request.assignee_name)} diff --git a/app/views/notify/merge_request_unmergeable_email.text.haml b/app/views/notify/merge_request_unmergeable_email.text.haml index dcdd6db69d6..0c7bf1bb044 100644 --- a/app/views/notify/merge_request_unmergeable_email.text.haml +++ b/app/views/notify/merge_request_unmergeable_email.text.haml @@ -4,5 +4,5 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m = merge_path_description(@merge_request, 'to') -Author: #{@merge_request.author_name} -Assignee: #{@merge_request.assignee_name} +Author: #{sanitize_name(@merge_request.author_name)} +Assignee: #{sanitize_name(@merge_request.assignee_name)} diff --git a/app/views/notify/merged_merge_request_email.text.haml b/app/views/notify/merged_merge_request_email.text.haml index 661c23bcbe2..045a43cbc84 100644 --- a/app/views/notify/merged_merge_request_email.text.haml +++ b/app/views/notify/merged_merge_request_email.text.haml @@ -4,5 +4,5 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m = merge_path_description(@merge_request, 'to') -Author: #{@merge_request.author_name} -Assignee: #{@merge_request.assignee_name} +Author: #{sanitize_name(@merge_request.author_name)} +Assignee: #{sanitize_name(@merge_request.assignee_name)} diff --git a/app/views/notify/new_gpg_key_email.html.haml b/app/views/notify/new_gpg_key_email.html.haml index 4b9350c4e88..b857705e01f 100644 --- a/app/views/notify/new_gpg_key_email.html.haml +++ b/app/views/notify/new_gpg_key_email.html.haml @@ -1,5 +1,5 @@ %p - Hi #{@user.name}! + Hi #{sanitize_name(@user.name)}! %p A new GPG key was added to your account: %p diff --git a/app/views/notify/new_gpg_key_email.text.erb b/app/views/notify/new_gpg_key_email.text.erb index 80b5a1fd7ff..92ea851eee4 100644 --- a/app/views/notify/new_gpg_key_email.text.erb +++ b/app/views/notify/new_gpg_key_email.text.erb @@ -1,4 +1,4 @@ -Hi <%= @user.name %>! +Hi <%= sanitize_name(@user.name) %>! A new GPG key was added to your account: diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb index 3c716f77296..58a2bcbe5eb 100644 --- a/app/views/notify/new_issue_email.text.erb +++ b/app/views/notify/new_issue_email.text.erb @@ -1,7 +1,7 @@ New Issue was created. Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %> -Author: <%= @issue.author_name %> +Author: <%= sanitize_name(@issue.author_name) %> Assignee: <%= @issue.assignee_list %> <%= @issue.description %> diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb index 23213106c5b..173091e4a80 100644 --- a/app/views/notify/new_mention_in_issue_email.text.erb +++ b/app/views/notify/new_mention_in_issue_email.text.erb @@ -1,7 +1,7 @@ You have been mentioned in an issue. Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %> -Author: <%= @issue.author_name %> -Assignee: <%= @issue.assignee_list %> +Author: <%= sanitize_name(@issue.author_name) %> +Assignee: <%= sanitize_name(@issue.assignee_list) %> <%= @issue.description %> diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb index 6fcebb22fc4..96a4f3f9eac 100644 --- a/app/views/notify/new_mention_in_merge_request_email.text.erb +++ b/app/views/notify/new_mention_in_merge_request_email.text.erb @@ -3,7 +3,7 @@ You have been mentioned in Merge Request <%= @merge_request.to_reference %> <%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %> <%= merge_path_description(@merge_request, 'to') %> -Author: <%= @merge_request.author_name %> -Assignee: <%= @merge_request.assignee_name %> +Author: <%= sanitize_name(@merge_request.author_name) %> +Assignee: <%= sanitize_name(@merge_request.assignee_name) %> <%= @merge_request.description %> diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml index 5acd45b74a7..db23447dd39 100644 --- a/app/views/notify/new_merge_request_email.html.haml +++ b/app/views/notify/new_merge_request_email.html.haml @@ -7,7 +7,7 @@ - if @merge_request.assignee_id.present? %p - Assignee: #{@merge_request.assignee_name} + Assignee: #{sanitize_name(@merge_request.assignee_name)} = render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter diff --git a/app/views/notify/new_ssh_key_email.html.haml b/app/views/notify/new_ssh_key_email.html.haml index 63b0cbbd205..d031842be95 100644 --- a/app/views/notify/new_ssh_key_email.html.haml +++ b/app/views/notify/new_ssh_key_email.html.haml @@ -1,5 +1,5 @@ %p - Hi #{@user.name}! + Hi #{sanitize_name(@user.name)}! %p A new public key was added to your account: %p diff --git a/app/views/notify/new_ssh_key_email.text.erb b/app/views/notify/new_ssh_key_email.text.erb index 05b551c89a0..690357d69ed 100644 --- a/app/views/notify/new_ssh_key_email.text.erb +++ b/app/views/notify/new_ssh_key_email.text.erb @@ -1,4 +1,4 @@ -Hi <%= @user.name %>! +Hi <%= sanitize_name(@user.name) %>! A new public key was added to your account: diff --git a/app/views/notify/new_user_email.html.haml b/app/views/notify/new_user_email.html.haml index db4424a01f9..dfbb5c75bd3 100644 --- a/app/views/notify/new_user_email.html.haml +++ b/app/views/notify/new_user_email.html.haml @@ -1,5 +1,5 @@ %p - Hi #{@user['name']}! + Hi #{sanitize_name(@user['name'])}! %p - if Gitlab::CurrentSettings.allow_signup? Your account has been created successfully. diff --git a/app/views/notify/new_user_email.text.erb b/app/views/notify/new_user_email.text.erb index dd9b71e3b84..f3f20f3bfba 100644 --- a/app/views/notify/new_user_email.text.erb +++ b/app/views/notify/new_user_email.text.erb @@ -1,4 +1,4 @@ -Hi <%= @user.name %>! +Hi <%= sanitize_name(@user.name) %>! The Administrator created an account for you. Now you are a member of the company GitLab application. diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb index 294238eee51..722eedf90be 100644 --- a/app/views/notify/pipeline_failed_email.text.erb +++ b/app/views/notify/pipeline_failed_email.text.erb @@ -10,20 +10,20 @@ Commit: <%= @pipeline.short_sha %> ( <%= commit_url(@pipeline) %> ) Commit Message: <%= @pipeline.git_commit_message.truncate(50) %> <% commit = @pipeline.commit -%> <% if commit.author -%> -Commit Author: <%= commit.author.name %> ( <%= user_url(commit.author) %> ) +Commit Author: <%= sanitize_name(commit.author.name) %> ( <%= user_url(commit.author) %> ) <% else -%> Commit Author: <%= commit.author_name %> <% end -%> <% if commit.different_committer? -%> <% if commit.committer -%> -Committed by: <%= commit.committer.name %> ( <%= user_url(commit.committer) %> ) +Committed by: <%= sanitize_name(commit.committer.name) %> ( <%= user_url(commit.committer) %> ) <% else -%> Committed by: <%= commit.committer_name %> <% end -%> <% end -%> <% if @pipeline.user -%> -Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> ) +Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> ) <% else -%> Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API <% end -%> diff --git a/app/views/notify/pipeline_success_email.text.erb b/app/views/notify/pipeline_success_email.text.erb index 39622cf7f02..9aadf380f79 100644 --- a/app/views/notify/pipeline_success_email.text.erb +++ b/app/views/notify/pipeline_success_email.text.erb @@ -10,13 +10,13 @@ Commit: <%= @pipeline.short_sha %> ( <%= commit_url(@pipeline) %> ) Commit Message: <%= @pipeline.git_commit_message.truncate(50) %> <% commit = @pipeline.commit -%> <% if commit.author -%> -Commit Author: <%= commit.author.name %> ( <%= user_url(commit.author) %> ) +Commit Author: <%= sanitize_name(commit.author.name) %> ( <%= user_url(commit.author) %> ) <% else -%> Commit Author: <%= commit.author_name %> <% end -%> <% if commit.different_committer? -%> <% if commit.committer -%> -Committed by: <%= commit.committer.name %> ( <%= user_url(commit.committer) %> ) +Committed by: <%= sanitize_name(commit.committer.name) %> ( <%= user_url(commit.committer) %> ) <% else -%> Committed by: <%= commit.committer_name %> <% end -%> @@ -25,7 +25,7 @@ Committed by: <%= commit.committer_name %> <% job_count = @pipeline.total_size -%> <% stage_count = @pipeline.stages_count -%> <% if @pipeline.user -%> -Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> ) +Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> ) <% else -%> Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API <% end -%> diff --git a/app/views/notify/push_to_merge_request_email.html.haml b/app/views/notify/push_to_merge_request_email.html.haml index 67744ec1cee..97258833cfc 100644 --- a/app/views/notify/push_to_merge_request_email.html.haml +++ b/app/views/notify/push_to_merge_request_email.html.haml @@ -1,5 +1,5 @@ %h3 - = @updated_by_user.name + = sanitize_name(@updated_by_user.name) pushed new commits to merge request = link_to(@merge_request.to_reference, project_merge_request_url(@merge_request.target_project, @merge_request)) diff --git a/app/views/notify/push_to_merge_request_email.text.haml b/app/views/notify/push_to_merge_request_email.text.haml index 95759d127e2..10c8e158846 100644 --- a/app/views/notify/push_to_merge_request_email.text.haml +++ b/app/views/notify/push_to_merge_request_email.text.haml @@ -1,4 +1,4 @@ -#{@updated_by_user.name} pushed new commits to merge request #{@merge_request.to_reference} +#{sanitize_name(@updated_by_user.name)} pushed new commits to merge request #{@merge_request.to_reference} \ #{url_for(project_merge_request_url(@merge_request.target_project, @merge_request))} \ diff --git a/app/views/notify/reassigned_issue_email.html.haml b/app/views/notify/reassigned_issue_email.html.haml index ee2f40e1683..6d25488a7e2 100644 --- a/app/views/notify/reassigned_issue_email.html.haml +++ b/app/views/notify/reassigned_issue_email.html.haml @@ -2,7 +2,7 @@ Assignee changed - if @previous_assignees.any? from - %strong= @previous_assignees.map(&:name).to_sentence + %strong= sanitize_name(@previous_assignees.map(&:name).to_sentence) to - if @issue.assignees.any? %strong= @issue.assignee_list diff --git a/app/views/notify/reassigned_issue_email.text.erb b/app/views/notify/reassigned_issue_email.text.erb index 6c357f1074a..7bf2e8e6ce3 100644 --- a/app/views/notify/reassigned_issue_email.text.erb +++ b/app/views/notify/reassigned_issue_email.text.erb @@ -2,5 +2,5 @@ Reassigned Issue <%= @issue.iid %> <%= url_for([@issue.project.namespace.becomes(Namespace), @issue.project, @issue, { only_path: false }]) %> -Assignee changed <%= "from #{@previous_assignees.map(&:name).to_sentence}" if @previous_assignees.any? -%> +Assignee changed <%= "from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%> to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %> diff --git a/app/views/notify/reassigned_merge_request_email.html.haml b/app/views/notify/reassigned_merge_request_email.html.haml index 24c2b08810b..e4f19bc3200 100644 --- a/app/views/notify/reassigned_merge_request_email.html.haml +++ b/app/views/notify/reassigned_merge_request_email.html.haml @@ -2,9 +2,9 @@ Assignee changed - if @previous_assignee from - %strong= @previous_assignee.name + %strong= sanitize_name(@previous_assignee.name) to - if @merge_request.assignee_id - %strong= @merge_request.assignee_name + %strong= sanitize_name(@merge_request.assignee_name) - else %strong Unassigned diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb index 998a40fefde..96c770b5219 100644 --- a/app/views/notify/reassigned_merge_request_email.text.erb +++ b/app/views/notify/reassigned_merge_request_email.text.erb @@ -2,5 +2,5 @@ Reassigned Merge Request <%= @merge_request.iid %> <%= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) %> -Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%> - to <%= "#{@merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned'}" %> +Assignee changed <%= "from #{sanitize_name(@previous_assignee.name)}" if @previous_assignee -%> + to <%= "#{@merge_request.assignee_id ? sanitize_name(@merge_request.assignee_name) : 'Unassigned'}" %> diff --git a/app/views/notify/resolved_all_discussions_email.html.haml b/app/views/notify/resolved_all_discussions_email.html.haml index 522421b7cc3..502b8f21e35 100644 --- a/app/views/notify/resolved_all_discussions_email.html.haml +++ b/app/views/notify/resolved_all_discussions_email.html.haml @@ -1,2 +1,2 @@ %p - All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{@resolved_by.name} + All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{sanitize_name(@resolved_by.name)} diff --git a/app/views/notify/resolved_all_discussions_email.text.erb b/app/views/notify/resolved_all_discussions_email.text.erb index 2881f3e699e..c4b36bfe1a8 100644 --- a/app/views/notify/resolved_all_discussions_email.text.erb +++ b/app/views/notify/resolved_all_discussions_email.text.erb @@ -1,3 +1,3 @@ -All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= @resolved_by.name %> +All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= sanitize_name(@resolved_by.name) %> <%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %> diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index e167e094240..ee2c5a13b8a 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -1,37 +1,37 @@ -- page_title "Account" +- page_title _('Account') - @content_class = "limit-container-width" unless fluid_layout - if current_user.ldap_user? .alert.alert-info - Some options are unavailable for LDAP accounts + = s_('Profiles|Some options are unavailable for LDAP accounts') .row.prepend-top-default .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 - Two-Factor Authentication + = s_('Profiles|Two-Factor Authentication') %p - Increase your account's security by enabling Two-Factor Authentication (2FA). + = s_("Profiles|Increase your account's security by enabling Two-Factor Authentication (2FA)") .col-lg-8 %p - Status: #{current_user.two_factor_enabled? ? 'Enabled' : 'Disabled'} + #{_('Status')}: #{current_user.two_factor_enabled? ? _('Enabled') : _('Disabled')} - if current_user.two_factor_enabled? - = link_to 'Manage two-factor authentication', profile_two_factor_auth_path, class: 'btn btn-info' + = link_to _('Manage two-factor authentication'), profile_two_factor_auth_path, class: 'btn btn-info' - else .append-bottom-10 - = link_to 'Enable two-factor authentication', profile_two_factor_auth_path, class: 'btn btn-success' + = link_to _('Enable two-factor authentication'), profile_two_factor_auth_path, class: 'btn btn-success' %hr - if display_providers_on_profile? .row.prepend-top-default .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 - Social sign-in + = s_('Profiles|Social sign-in') %p - Activate signin with one of the following services + = s_('Profiles|Activate signin with one of the following services') .col-lg-8 %label.label-bold - Connected Accounts - %p Click on icon to activate signin with one of the following services + = s_('Profiles|Connected Accounts') + %p= s_('Profiles|Click on icon to activate signin with one of the following services') - button_based_providers.each do |provider| .provider-btn-group .provider-btn-image @@ -39,24 +39,24 @@ - if auth_active?(provider) - if unlink_allowed?(provider) = link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do - Disconnect + = s_('Profiles|Disconnect') - else %a.provider-btn - Active + = s_('Profiles|Active') - else = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active' do - Connect + = s_('Profiles|Connect') = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: local_assigns[:group_saml_identities] %hr - if current_user.can_change_username? .row.prepend-top-default .col-lg-4.profile-settings-sidebar %h4.prepend-top-0.warning-title - Change username + = s_('Profiles|Change username') %p - Changing your username can have unintended side effects. + = s_('Profiles|Changing your username can have unintended side effects.') = succeed '.' do - = link_to 'Learn more', help_page_path('user/profile/index', anchor: 'changing-your-username'), target: '_blank' + = link_to s_('Profiles|Learn more'), help_page_path('user/profile/index', anchor: 'changing-your-username'), target: '_blank' .col-lg-8 - data = { initial_username: current_user.username, root_url: root_url, action_url: update_username_profile_path(format: :json) } #update-username{ data: data } diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml index 23ef31a0c85..2bf514d72a5 100644 --- a/app/views/profiles/active_sessions/_active_session.html.haml +++ b/app/views/profiles/active_sessions/_active_session.html.haml @@ -23,9 +23,3 @@ %strong Signed in on = l(active_session.created_at, format: :short) - - - unless is_current_session - .float-right - = link_to profile_active_session_path(active_session.session_id), data: { confirm: 'Are you sure? The device will be signed out of GitLab.' }, method: :delete, class: "btn btn-danger prepend-left-10" do - %span.sr-only Revoke - Revoke diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml index d265f3c44ba..4b84835429c 100644 --- a/app/views/profiles/passwords/new.html.haml +++ b/app/views/profiles/passwords/new.html.haml @@ -1,12 +1,13 @@ -- page_title "New Password" -- header_title "New Password" -%h3.page-title Set up new password +- page_title _('New Password') +- breadcrumb_title _('New Password') + +%h3.page-title= _('Set up new password') %hr = form_for @user, url: profile_password_path, method: :post do |f| %p.slead - Please set a new password before proceeding. + = _('Please set a new password before proceeding.') %br - After a successful password update you will be redirected to login screen. + = _('After a successful password update you will be redirected to login screen.') = form_errors(@user) @@ -22,4 +23,4 @@ .col-sm-10 = f.password_field :password_confirmation, required: true, class: 'form-control' .form-actions - = f.submit 'Set new password', class: "btn btn-success" + = f.submit _('Set new password'), class: 'btn btn-success' diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 7c378633667..bfe1c3ddf33 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -51,11 +51,36 @@ = f.label :dashboard, class: 'label-bold' do Default dashboard = f.select :dashboard, dashboard_choices, {}, class: 'form-control' + + = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific + .form-group = f.label :project_view, class: 'label-bold' do Project overview content = f.select :project_view, project_view_choices, {}, class: 'form-control' .form-text.text-muted Choose what content you want to see on a project’s overview page. + + .col-sm-12 + %hr + + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 + = _('Localization') + %p + = _('Customize language and region related settings.') + = succeed '.' do + = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'localization'), target: '_blank' + .col-lg-8 + .form-group + = f.label :preferred_language, class: 'label-bold' do + = _('Language') + = f.select :preferred_language, language_choices, {}, class: 'select2' + .form-text.text-muted + = s_('Preferences|This feature is experimental and translations are not complete yet') + .form-group + = f.label :first_day_of_week, class: 'label-bold' do + = _('First day of the week') + = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'form-control' .form-group - = f.submit 'Save changes', class: 'btn btn-success' + = f.submit _('Save changes'), class: 'btn btn-success' diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 2629b374e7c..4d3d92d09c0 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -2,7 +2,7 @@ - @content_class = "limit-container-width" unless fluid_layout - gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host -= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f| += bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f| = form_errors(@user) .row @@ -77,10 +77,10 @@ .col-lg-8 .row - if @user.read_only_attribute?(:name) - = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9' }, + = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9 qa-full-name' }, help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) } - else - = f.text_field :name, label: 'Full name', required: true, wrapper: { class: 'col-md-9' }, help: s_("Profiles|Enter your name, so people you know can recognize you") + = f.text_field :name, label: 'Full name', required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you") = f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' } - if @user.read_only_attribute?(:email) @@ -95,9 +95,6 @@ = f.select :commit_email, options_for_select(commit_email_select_options(@user), selected: selected_commit_email(@user)), { help: s_("Profiles|This email will be used for web based operations, such as edits and merges. %{learn_more}").html_safe % { learn_more: commit_email_docs_link } }, control_class: 'select2 input-lg' - = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] }, - { help: s_("Profiles|This feature is experimental and translations are not complete yet") }, - control_class: 'select2 input-lg' = f.text_field :skype, class: 'input-md', placeholder: s_("Profiles|username") = f.text_field :linkedin, class: 'input-md', help: s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename") = f.text_field :twitter, class: 'input-md', placeholder: s_("Profiles|@username") diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml index 6bf21570d41..12da62f4c64 100644 --- a/app/views/projects/_activity.html.haml +++ b/app/views/projects/_activity.html.haml @@ -1,8 +1,8 @@ %div{ class: container_class } - .nav-block.activity-filter-block.activities + .nav-block.d-none.d-sm-flex.activities = render 'shared/event_filter' .controls - = link_to project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'btn rss-btn has-tooltip' do + = link_to project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'btn d-none d-sm-inline-block has-tooltip' do = icon('rss') .content_list.project-activity{ :"data-href" => activity_project_path(@project) } diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index aa980da7e95..409b6dba9ca 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -19,7 +19,7 @@ %ul %li Project and wiki repositories %li Project uploads - %li Project configuration including web hooks and services + %li Project configuration, including services %li Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities %li LFS objects %p @@ -28,13 +28,14 @@ %li Job traces and artifacts %li Container registry images %li CI variables + %li Webhooks %li Any encrypted tokens %p Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page. - if project.export_status == :finished - = link_to 'Download export', download_export_project_path(project), + = link_to 'Download export', download_export_project_path(project), rel: 'nofollow', download: '', method: :get, class: "btn btn-default" - = link_to 'Generate new export', generate_new_export_project_path(project), + = link_to 'Generate new export', generate_new_export_project_path(project), method: :post, class: "btn btn-default" - else = link_to 'Export project', export_project_path(project), diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 82b2ab64a5d..1d7287410ea 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -1,17 +1,18 @@ - empty_repo = @project.empty_repo? - show_auto_devops_callout = show_auto_devops_callout?(@project) +- max_project_topic_length = 15 .project-home-panel{ class: ("empty-project" if empty_repo) } - .project-header.row.append-bottom-8 - .project-title-row.col-md-12.col-lg-6.d-flex - .avatar-container.project-avatar.float-none + .row.append-bottom-8 + .home-panel-title-row.col-md-12.col-lg-6.d-flex + .avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none = project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64) .d-flex.flex-column.flex-wrap.align-items-baseline .d-inline-flex.align-items-baseline - %h1.project-title.qa-project-name + %h1.home-panel-title.prepend-top-8.append-bottom-5.qa-project-name = @project.name - %span.project-visibility.prepend-left-8.d-inline-flex.align-items-baseline.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } + %span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } = visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'}) - .project-metadata.d-flex.align-items-center + .home-panel-metadata.d-flex.align-items-center.text-secondary - if can?(current_user, :read_project, @project) %span.text-secondary = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id } @@ -19,16 +20,28 @@ %span.access-request-links.prepend-left-8 = render 'shared/members/access_request_links', source: @project - if @project.tag_list.present? - %span.project-tag-list.d-inline-flex.prepend-left-8.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_tags? ? @project.tag_list.join(', ') : nil } + %span.home-panel-topic-list.d-inline-flex.prepend-left-8 = sprite_icon('tag', size: 16, css_class: 'icon append-right-4') - = @project.tags_to_show - - if @project.has_extra_tags? - = _("+ %{count} more") % { count: @project.count_of_extra_tags_not_shown } + + - @project.topics_to_show.each do |topic| + - project_topics_classes = "badge badge-pill badge-secondary append-right-5" + - explore_project_topic_path = explore_projects_path(tag: 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 } + = topic.titleize + - else + %a{ class: project_topics_classes, href: explore_project_topic_path } + = topic.titleize + + - if @project.has_extra_topics? + .text-nowrap.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_topics? ? @project.topics_not_shown.join(', ') : nil } + = _("+ %{count} more") % { count: @project.count_of_extra_topics_not_shown } + .project-repo-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end - if current_user .d-inline-flex - = render 'projects/buttons/notifications', notification_setting: @notification_setting, btn_class: 'btn-xs' + = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn-xs' .count-buttons.d-inline-flex = render 'projects/buttons/star' @@ -43,13 +56,13 @@ - if can?(current_user, :download_code, @project) %nav.project-stats - .nav-links.quick-links.mt-3 + .nav-links.quick-links = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) - .project-home-desc.mt-1 + .home-panel-home-desc.mt-1 - if @project.description.present? - .project-description - .project-description-markdown.read-more-container + .home-panel-description + .home-panel-description-markdown.read-more-container = markdown_field(@project, :description) %button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" } = _("Read more") diff --git a/app/views/projects/_issuable_by_email.html.haml b/app/views/projects/_issuable_by_email.html.haml index d59191a6f87..0b2d179456d 100644 --- a/app/views/projects/_issuable_by_email.html.haml +++ b/app/views/projects/_issuable_by_email.html.haml @@ -21,7 +21,7 @@ = clipboard_button(target: '#issuable_email', class: 'btn btn-clipboard input-group-text btn-transparent d-none d-sm-block') - if issuable_type == 'issue' - - enter_title_text = _('Enter the issue title') + - enter_title_text = _('Enter the issue title') - enter_description_text = _('Enter the issue description') - else - enter_title_text = _('Enter the merge request title') @@ -36,7 +36,12 @@ %p = render 'by_email_description' %p - This is a private email address, generated just for you. + This is a private email address + + %a{ href: 'https://docs.gitlab.com/ee/development/emails.html#email-namespace', target: "_blank", rel: "noopener" } + %i.fa.fa-question-circle{ 'aria-label': "Learn more about incoming email addresses" } + + generated just for you. Anyone who gets ahold of it can create issues or merge requests as if they were you. You should diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml index 0f709c65d0e..830cfa80d58 100644 --- a/app/views/projects/_md_preview.html.haml +++ b/app/views/projects/_md_preview.html.haml @@ -12,23 +12,13 @@ %ul.nav.nav-tabs.nav-links.clearfix %li.md-header-tab.active %button.js-md-write-button{ tabindex: -1 } - Write + = _("Write") %li.md-header-tab %button.js-md-preview-button{ tabindex: -1 } - Preview + = _("Preview") %li.md-header-toolbar.active - = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: s_("MarkdownToolbar|Add bold text") }) - = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: s_("MarkdownToolbar|Add italic text") }) - = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: s_("MarkdownToolbar|Insert a quote") }) - = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: s_("MarkdownToolbar|Insert code") }) - = markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: s_("MarkdownToolbar|Add a link") }) - = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a bullet list") }) - = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a numbered list") }) - = markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a task list") }) - = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a table") }) - %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: s_("MarkdownToolbar|Go full screen"), data: { container: "body" } } - = sprite_icon("screen-full") + = render 'projects/blob/markdown_buttons', show_fullscreen_button: true .md-write-holder = yield diff --git a/app/views/projects/_merge_request_merge_settings.html.haml b/app/views/projects/_merge_request_merge_settings.html.haml index f178c94e008..6ac2e06afa5 100644 --- a/app/views/projects/_merge_request_merge_settings.html.haml +++ b/app/views/projects/_merge_request_merge_settings.html.haml @@ -9,6 +9,7 @@ %span.descr Pipelines need to be configured to enable this feature. = link_to icon('question-circle'), help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds'), target: '_blank' + = render_if_exists 'projects/merge_pipelines_settings', form: form .form-check = form.check_box :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-input' = form.label :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-label' do diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index ba7d3154326..276363df7da 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -43,7 +43,7 @@ = f.label :description, class: 'label-bold' do Project description %span (optional) - = f.text_area :description, placeholder: 'Description format', class: "form-control", rows: 3, maxlength: 250, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_description", track_value: "" } + = f.text_area :description, placeholder: 'Description format', class: "form-control", rows: 3, maxlength: 250, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_description", track_value: "" } = f.label :visibility_level, class: 'label-bold' do Visibility Level diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml index 45e1d32980c..de4653dad2c 100644 --- a/app/views/projects/_wiki.html.haml +++ b/app/views/projects/_wiki.html.haml @@ -2,7 +2,7 @@ %div{ class: container_class } .prepend-top-default.append-bottom-default .wiki - = render_wiki_content(@wiki_home, legacy_render_context(params)) + = render_wiki_content(@wiki_home) - else - can_create_wiki = can?(current_user, :create_wiki, @project) .landing{ class: [('row-content-block row p-0 align-items-center' if can_create_wiki), ('content-block' unless can_create_wiki)] } diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml index cfb91568061..f42d5128715 100644 --- a/app/views/projects/artifacts/_tree_file.html.haml +++ b/app/views/projects/artifacts/_tree_file.html.haml @@ -14,4 +14,4 @@ = link_to path_to_file, class: 'str-truncated' do %span= blob.name %td - = number_to_human_size(blob.size, precision: 2) + = number_to_human_size(blob.size) diff --git a/app/views/projects/badges/badge_flat-square.svg.erb b/app/views/projects/badges/badge_flat-square.svg.erb new file mode 100644 index 00000000000..5b90da15ef5 --- /dev/null +++ b/app/views/projects/badges/badge_flat-square.svg.erb @@ -0,0 +1,17 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="<%= badge.width %>" height="20"> + <g shape-rendering="crispEdges"> + <path fill="<%= badge.key_color %>" d="M0 0 h<%= badge.key_width %> v20 H0 z"/> + <path fill="<%= badge.value_color %>" d="M<%= badge.key_width %> 0 h<%= badge.value_width %> v20 H<%= badge.key_width %> z"/> + </g> + + <g fill="#fff" text-anchor="middle"> + <g font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"> + <text x="<%= badge.key_text_anchor %>" y="14"> + <%= badge.key_text %> + </text> + <text x="<%= badge.value_text_anchor %>" y="14"> + <%= badge.value_text %> + </text> + </g> + </g> +</svg> diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 3c1f33ea95e..a54460f1196 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -1,4 +1,6 @@ - action = current_action?(:edit) || current_action?(:update) ? 'edit' : 'create' +- file_name = params[:id].split("/").last ||= "" +- is_markdown = Gitlab::MarkupHelper.gitlab_markdown?(file_name) .file-holder-bottom-radius.file-holder.file.append-bottom-default .js-file-title.file-title.clearfix{ data: { current_action: action } } @@ -17,6 +19,8 @@ required: true, class: 'form-control new-file-name js-file-path-name-input' .file-buttons + - if is_markdown + = render 'projects/blob/markdown_buttons', show_fullscreen_button: false = button_tag class: 'soft-wrap-toggle btn', type: 'button', tabindex: '-1' do %span.no-wrap = custom_icon('icon_no_wrap') diff --git a/app/views/projects/blob/_markdown_buttons.html.haml b/app/views/projects/blob/_markdown_buttons.html.haml new file mode 100644 index 00000000000..28d1ff97825 --- /dev/null +++ b/app/views/projects/blob/_markdown_buttons.html.haml @@ -0,0 +1,13 @@ +.md-header-toolbar.active + = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: _("Add bold text") }) + = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: _("Add italic text") }) + = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: _("Insert a quote") }) + = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: _("Insert code") }) + = markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: _("Add a link") }) + = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: _("Add a bullet list") }) + = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") }) + = markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: _("Add a task list") }) + = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: _("Add a table") }) + - if show_fullscreen_button + %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: _("Go full screen"), data: { container: "body" } } + = sprite_icon("screen-full") diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index 3f2d96b70e5..4520cca8cf5 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -21,7 +21,7 @@ Write %li - = link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id, legacy_render: params[:legacy_render]) do + = link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id) do = editing_preview_title(@blob.name) = 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 diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml index ff460a3831c..66687f087ff 100644 --- a/app/views/projects/blob/preview.html.haml +++ b/app/views/projects/blob/preview.html.haml @@ -2,7 +2,7 @@ .diff-content - if markup?(@blob.name) .file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } - = markup(@blob.name, @content, legacy_render_context(params)) + = markup(@blob.name, @content) - else .file-content.code.js-syntax-highlight - unless @diff_lines.empty? diff --git a/app/views/projects/blob/viewers/_dependency_manager.html.haml b/app/views/projects/blob/viewers/_dependency_manager.html.haml index 87aa7c1dbf8..5970d41fdab 100644 --- a/app/views/projects/blob/viewers/_dependency_manager.html.haml +++ b/app/views/projects/blob/viewers/_dependency_manager.html.haml @@ -3,9 +3,4 @@ This project manages its dependencies using %strong= viewer.manager_name - - if viewer.package_name - and defines a #{viewer.package_type} named - %strong< - = link_to_if viewer.package_url.present?, viewer.package_name, viewer.package_url, target: '_blank', rel: 'noopener noreferrer' - = link_to 'Learn more', viewer.manager_url, target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml index 5be7cc7f25a..61d67a88a5a 100644 --- a/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml +++ b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml @@ -1,9 +1,9 @@ -- if viewer.valid?(@project, @commit.sha) +- if viewer.valid?(project: @project, sha: @commit.sha, user: @current_user) = icon('check fw') This GitLab CI configuration is valid. - else = icon('warning fw') This GitLab CI configuration is invalid: - = viewer.validation_message(@project, @commit.sha) + = viewer.validation_message(project: @project, sha: @commit.sha, user: @current_user) = link_to 'Learn more', help_page_path('ci/yaml/README') diff --git a/app/views/projects/blob/viewers/_loading.html.haml b/app/views/projects/blob/viewers/_loading.html.haml index 120c0540335..df1f3e4e01b 100644 --- a/app/views/projects/blob/viewers/_loading.html.haml +++ b/app/views/projects/blob/viewers/_loading.html.haml @@ -1,2 +1,2 @@ .text-center.prepend-top-default.append-bottom-default - = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content…') + = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content…', class: 'qa-spinner') diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml index 6edbfd91b21..1a77eb078be 100644 --- a/app/views/projects/blob/viewers/_markup.html.haml +++ b/app/views/projects/blob/viewers/_markup.html.haml @@ -1,6 +1,4 @@ - blob = viewer.blob -- context = legacy_render_context(params) -- unless context[:markdown_engine] == :redcarpet - - context[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup) +- context = blob.respond_to?(:rendered_markup) ? { rendered: blob.rendered_markup } : {} .file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } = markup(blob.name, blob.data, context) diff --git a/app/views/projects/blob/viewers/_readme.html.haml b/app/views/projects/blob/viewers/_readme.html.haml index d8492abc638..c2329a7aa66 100644 --- a/app/views/projects/blob/viewers/_readme.html.haml +++ b/app/views/projects/blob/viewers/_readme.html.haml @@ -1,4 +1,4 @@ = icon('info-circle fw') = succeed '.' do To learn more about this project, read - = link_to "the wiki", get_project_wiki_path(viewer.project) + = link_to "the wiki", project_wiki_path(viewer.project, :home) diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 88f9b7dfc9f..91c51d5e091 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -2,6 +2,7 @@ - commit = @repository.commit(branch.dereferenced_target) - bar_graph_width_factor = @max_commits > 0 ? 100.0/@max_commits : 0 - diverging_commit_counts = @repository.diverging_commit_counts(branch) +- number_commits_distance = diverging_commit_counts[:distance] - number_commits_behind = diverging_commit_counts[:behind] - number_commits_ahead = diverging_commit_counts[:ahead] - merge_project = merge_request_source_project_for_project(@project) @@ -28,16 +29,23 @@ = s_('Branches|Cant find HEAD commit for this branch') - if branch.name != @repository.root_ref - .divergence-graph.d-none.d-md-block{ title: s_('%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead') % { number_commits_behind: diverging_count_label(number_commits_behind), - default_branch: @repository.root_ref, - number_commits_ahead: diverging_count_label(number_commits_ahead) } } - .graph-side - .bar.bar-behind{ style: "width: #{number_commits_behind * bar_graph_width_factor}%" } - %span.count.count-behind= diverging_count_label(number_commits_behind) - .graph-separator - .graph-side - .bar.bar-ahead{ style: "width: #{number_commits_ahead * bar_graph_width_factor}%" } - %span.count.count-ahead= diverging_count_label(number_commits_ahead) + - if number_commits_distance.nil? + .divergence-graph.d-none.d-md-block{ title: s_('%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead') % { number_commits_behind: diverging_count_label(number_commits_behind), + default_branch: @repository.root_ref, + number_commits_ahead: diverging_count_label(number_commits_ahead) } } + .graph-side + .bar.bar-behind{ style: "width: #{number_commits_behind * bar_graph_width_factor}%" } + %span.count.count-behind= diverging_count_label(number_commits_behind) + .graph-separator + .graph-side + .bar.bar-ahead{ style: "width: #{number_commits_ahead * bar_graph_width_factor}%" } + %span.count.count-ahead= diverging_count_label(number_commits_ahead) + - else + .divergence-graph.d-none.d-md-block{ title: s_('More than %{number_commits_distance} commits different with %{default_branch}') % { number_commits_distance: diverging_count_label(number_commits_distance), + default_branch: @repository.root_ref} } + .graph-side.full + .bar{ style: "width: #{number_commits_distance * bar_graph_width_factor}%" } + %span.count= diverging_count_label(number_commits_distance) .controls.d-none.d-md-block< - if merge_project && create_mr_button?(@repository.root_ref, branch.name) @@ -45,9 +53,8 @@ = _('Merge request') - if branch.name != @repository.root_ref - = link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name), + = link_to project_compare_path(@project, @repository.root_ref, branch.name), class: "btn btn-default #{'prepend-left-10' unless merge_project}", - method: :post, title: s_('Branches|Compare') do = s_('Branches|Compare') @@ -76,7 +83,7 @@ = icon("trash-o") - else = link_to project_branch_path(@project, branch.name), - class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip", + class: "btn btn-remove remove-row qa-remove-btn js-ajax-loading-spinner has-tooltip", title: s_('Branches|Delete branch'), method: :delete, data: { confirm: s_("Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?") % { branch_name: branch.name } }, diff --git a/app/views/projects/branches/_panel.html.haml b/app/views/projects/branches/_panel.html.haml index 0e4b119bb54..93061452e12 100644 --- a/app/views/projects/branches/_panel.html.haml +++ b/app/views/projects/branches/_panel.html.haml @@ -10,7 +10,7 @@ .card.prepend-top-10 .card-header = panel_title - %ul.content-list.all-branches + %ul.content-list.all-branches.qa-all-branches - branches.first(overview_max_branches).each do |branch| = render "projects/branches/branch", branch: branch, merged: project.repository.merged_to_root_ref?(branch) - if branches.size > overview_max_branches diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index ca867961f6b..43f1cd01b67 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -35,7 +35,7 @@ - if can? current_user, :push_code, @project = link_to project_merged_branches_path(@project), - class: 'btn btn-inverted btn-remove has-tooltip', + class: 'btn btn-inverted btn-remove has-tooltip qa-delete-merged-branches', title: s_("Branches|Delete all branches that are merged into '%{default_branch}'") % { default_branch: @project.repository.root_ref }, method: :delete, data: { confirm: s_('Branches|Deleting the merged branches cannot be undone. Are you sure?'), diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index 53d427ec40a..09f05b30433 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -5,18 +5,18 @@ %span.append-right-4.js-clone-dropdown-label = _('Clone') = sprite_icon("arrow-down", css_class: "icon") - %form.p-3.dropdown-menu.dropdown-menu-right.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options - - if ssh_enabled? - %li.pb-2 - %label.label-bold - = _('Clone with SSH') - .input-group - = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control qa-ssh-clone-url", readonly: true, aria: { label: 'Project clone URL' } - .input-group-append - = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard") - = render_if_exists 'projects/buttons/geo' - %li - - if http_enabled? + %ul.p-3.dropdown-menu.dropdown-menu-right.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options + - if ssh_enabled? + %li + %label.label-bold + = _('Clone with SSH') + .input-group + = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control qa-ssh-clone-url", readonly: true, aria: { label: 'Project clone URL' } + .input-group-append + = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard") + = render_if_exists 'projects/buttons/geo' + - if http_enabled? + %li.pt-2 %label.label-bold = _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase } .input-group @@ -24,5 +24,6 @@ .input-group-append = clipboard_button(target: '#http_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard") = render_if_exists 'projects/buttons/geo' + = render_if_exists 'projects/buttons/kerberos_clone_field' = render_if_exists 'shared/geo_info_modal', project: project diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml index 45515fb492f..bbe0a2c97fd 100644 --- a/app/views/projects/buttons/_dropdown.html.haml +++ b/app/views/projects/buttons/_dropdown.html.haml @@ -35,8 +35,8 @@ - elsif can_collaborate_with_project?(@project) %li= link_to _('New file'), project_new_blob_path(@project, @project.default_branch || 'master') - elsif create_mr_from_new_fork - - continue_params = { to: project_new_blob_path(@project, @project.default_branch || 'master'), - notice: edit_in_new_fork_notice, + - continue_params = { to: project_new_blob_path(@project, @project.default_branch || 'master'), + notice: edit_in_new_fork_notice, notice_now: edit_in_new_fork_notice_now } - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) %li= link_to _('New file'), fork_path, method: :post diff --git a/app/views/projects/buttons/_notifications.html.haml b/app/views/projects/buttons/_notifications.html.haml deleted file mode 100644 index 745983ace7e..00000000000 --- a/app/views/projects/buttons/_notifications.html.haml +++ /dev/null @@ -1,27 +0,0 @@ -- btn_class = local_assigns.fetch(:btn_class, "btn-xs") - -- if notification_setting - .js-notification-dropdown.notification-dropdown.project-action-button.dropdown.inline - = form_for notification_setting, remote: true, html: { class: "inline notification-form no-label" } do |f| - = hidden_setting_source_input(notification_setting) - = hidden_field_tag "hide_label", true - = f.hidden_field :level, class: "notification_setting_level" - .js-notification-toggle-btns - %div{ class: ("btn-group" if notification_setting.custom?) } - - if notification_setting.custom? - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } - = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon") - %span.js-notification-loading.fa.hidden - %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } - = sprite_icon("arrow-down", css_class: "icon") - .sr-only Toggle dropdown - - else - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting - #{notification_title(notification_setting.level)}", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } - = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon") - %span.js-notification-loading.fa.hidden - = sprite_icon("arrow-down", css_class: "icon") - - = render "shared/notifications/notification_dropdown", notification_setting: notification_setting - - = content_for :scripts_body do - = render "shared/notifications/custom_notifications", notification_setting: notification_setting diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 44e9cb84341..9d069c025ba 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -25,7 +25,7 @@ = job.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite') = link_to job.ref, project_ref_path(job.project, job.ref), class: "ref-name" - else - .light none + .light= _('none') .icon-container.commit-icon = custom_icon("icon_commit") @@ -33,10 +33,10 @@ = link_to job.short_sha, project_commit_path(job.project, job.sha), class: "commit-sha" - if job.stuck? - = icon('warning', class: 'text-warning has-tooltip', title: 'Job is stuck. Check runners.') + = icon('warning', class: 'text-warning has-tooltip', title: _('Job is stuck. Check runners.')) - if retried - = icon('refresh', class: 'text-warning has-tooltip', title: 'Job was retried') + = icon('refresh', class: 'text-warning has-tooltip', title: _('Job was retried')) .label-container - if job.tags.any? @@ -44,13 +44,13 @@ %span.badge.badge-primary = tag - if job.try(:trigger_request) - %span.badge.badge-info triggered + %span.badge.badge-info= _('triggered') - if job.try(:allow_failure) - %span.badge.badge-danger allowed to fail + %span.badge.badge-danger= _('allowed to fail') - if job.schedulable? %span.badge.badge-info= s_('DelayedJobs|delayed') - elsif job.action? - %span.badge.badge-info manual + %span.badge.badge-info= _('manual') - if pipeline_link %td @@ -70,7 +70,7 @@ - if job.try(:runner) = runner_link(job.runner) - else - .light none + .light= _('none') - if stage %td @@ -97,11 +97,11 @@ %td .float-right - if can?(current_user, :read_build, job) && job.artifacts? - = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do + = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), class: 'btn btn-build' do = sprite_icon('download') - if can?(current_user, :update_build, job) - if job.active? - = link_to cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: 'Cancel', class: 'btn btn-build' do + = link_to cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), class: 'btn btn-build' do = icon('remove', class: 'cred') - elsif job.scheduled? .btn-group @@ -123,8 +123,8 @@ = sprite_icon('time-out') - elsif allow_retry - if job.playable? && !admin && can?(current_user, :update_build, job) - = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do + = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), class: 'btn btn-build' do = custom_icon('icon_play') - elsif job.retryable? - = link_to retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do + = link_to retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Retry'), class: 'btn btn-build' do = icon('repeat') diff --git a/app/views/projects/ci/lints/_create.html.haml b/app/views/projects/ci/lints/_create.html.haml index 30bf1384b22..59b5b9f8a30 100644 --- a/app/views/projects/ci/lints/_create.html.haml +++ b/app/views/projects/ci/lints/_create.html.haml @@ -1,45 +1,48 @@ - if @status %p - %b Status: - syntax is correct + %b= _("Status:") + = _("syntax is correct") %i.fa.fa-ok.correct-syntax .table-holder %table.table.table-bordered %thead %tr - %th Parameter - %th Value + %th= _("Parameter") + %th= _("Value") %tbody - @stages.each do |stage| - @builds.select { |build| build[:stage] == stage }.each do |build| + - job = @jobs[build[:name].to_sym] %tr %td #{stage.capitalize} Job - #{build[:name]} %td - %pre= build[:commands] + %pre= job[:before_script].to_a.join('\n') + %pre= job[:script].to_a.join('\n') + %pre= job[:after_script].to_a.join('\n') %br - %b Tag list: + %b= _("Tag list:") = build[:tag_list].to_a.join(", ") %br - %b Only policy: - = @jobs[build[:name].to_sym][:only].to_a.join(", ") + %b= _("Only policy:") + = job[:only].to_a.join(", ") %br - %b Except policy: - = @jobs[build[:name].to_sym][:except].to_a.join(", ") + %b= _("Except policy:") + = job[:except].to_a.join(", ") %br - %b Environment: + %b= _("Environment:") = build[:environment] %br - %b When: + %b= _("When:") = build[:when] - if build[:allow_failure] - %b Allowed to fail + %b= _("Allowed to fail") - else %p - %b Status: - syntax is incorrect + %b= _("Status:") + = _("syntax is incorrect") %i.fa.fa-remove.incorrect-syntax - %b Error: + %b= _("Error:") = @error diff --git a/app/views/projects/ci/lints/show.html.haml b/app/views/projects/ci/lints/show.html.haml index cbda6bf2107..7b87664961e 100644 --- a/app/views/projects/ci/lints/show.html.haml +++ b/app/views/projects/ci/lints/show.html.haml @@ -1,9 +1,9 @@ -- page_title "CI Lint" -- page_description "Validate your GitLab CI configuration file" +- page_title _("CI Lint") +- page_description _("Validate your GitLab CI configuration file") - content_for :library_javascripts do = page_specific_javascript_tag('lib/ace.js') -%h2.pt-3.pb-3 Check your .gitlab-ci.yml +%h2.pt-3.pb-3= _("Check your .gitlab-ci.yml") .project-ci-linter = form_tag project_ci_lint_path(@project), method: :post do @@ -11,14 +11,14 @@ .col-sm-12 .file-holder .js-file-title.file-title.clearfix - Content of .gitlab-ci.yml + = _("Contents of .gitlab-ci.yml") #ci-editor.ci-editor= @content = text_area_tag(:content, @content, class: 'hidden form-control span1', rows: 7, require: true) .col-sm-12 .float-left.prepend-top-10 - = submit_tag('Validate', class: 'btn btn-success submit-yml') + = submit_tag(_('Validate'), class: 'btn btn-success submit-yml') .float-right.prepend-top-10 - = button_tag('Clear', type: 'button', class: 'btn btn-default clear-yml') + = button_tag(_('Clear'), type: 'button', class: 'btn btn-default clear-yml') .row.prepend-top-20 .col-sm-12 diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml index cecc139b183..888be4ee282 100644 --- a/app/views/projects/cleanup/_show.html.haml +++ b/app/views/projects/cleanup/_show.html.haml @@ -24,6 +24,6 @@ = _("No file selected") = f.file_field :bfg_object_map, accept: 'text/plain', class: "hidden js-object-map-input", required: true .form-text.text-muted - = _("The maximum file size allowed is %{max_attachment_size}mb") % { max_attachment_size: Gitlab::CurrentSettings.max_attachment_size } + = _("The maximum file size allowed is %{size}.") % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) } = f.submit _('Start cleanup'), class: 'btn btn-success' diff --git a/app/views/projects/commit/_ajax_signature.html.haml b/app/views/projects/commit/_ajax_signature.html.haml index eb677cff5f0..ae9aef5a9b0 100644 --- a/app/views/projects/commit/_ajax_signature.html.haml +++ b/app/views/projects/commit/_ajax_signature.html.haml @@ -1,2 +1,2 @@ - if commit.has_signature? - %a{ href: 'javascript:void(0)', tabindex: 0, class: commit_signature_badge_classes('js-loading-gpg-badge'), data: { toggle: 'tooltip', placement: 'top', title: 'GPG signature (loading...)', 'commit-sha' => commit.sha } } + %a{ href: 'javascript:void(0)', tabindex: 0, class: commit_signature_badge_classes('js-loading-gpg-badge'), data: { toggle: 'tooltip', placement: 'top', title: _('GPG signature (loading...)'), 'commit-sha' => commit.sha } } diff --git a/app/views/projects/commit/_ci_menu.html.haml b/app/views/projects/commit/_ci_menu.html.haml index f6666921a25..41f5fb3dcbd 100644 --- a/app/views/projects/commit/_ci_menu.html.haml +++ b/app/views/projects/commit/_ci_menu.html.haml @@ -1,10 +1,12 @@ +- 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 + = _('Changes') %span.badge.badge-pill= @diffs.size - - if can?(current_user, :read_pipeline, @project) + - if any_pipelines = nav_link(path: 'commit#pipelines') do = link_to pipelines_project_commit_path(@project, @commit.id) do - Pipelines + = _('Pipelines') %span.badge.badge-pill.js-pipelines-mr-count= @commit.pipelines.size diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 2a919a767c0..a0db48bf8ff 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -6,8 +6,8 @@ %strong #{ s_('CommitBoxTitle|Commit') } %span.commit-sha= @commit.short_id - = clipboard_button(text: @commit.id, title: _("Copy commit SHA to clipboard")) - %span.d-none.d-sm-inline authored + = clipboard_button(text: @commit.id, title: _('Copy commit SHA to clipboard')) + %span.d-none.d-sm-inline= _('authored') #{time_ago_with_tooltip(@commit.authored_date)} %span= s_('ByAuthor|by') = author_avatar(@commit, size: 24, has_tooltip: false) @@ -43,13 +43,13 @@ = cherry_pick_commit_link(@commit, project_commit_path(@project, @commit.id), has_tooltip: false) - if can?(current_user, :push_code, @project) %li.clearfix - = link_to s_("CreateTag|Tag"), new_project_tag_path(@project, ref: @commit) + = link_to s_('CreateTag|Tag'), new_project_tag_path(@project, ref: @commit) %li.divider %li.dropdown-header #{ _('Download') } - unless @commit.parents.length > 1 - %li= link_to s_("DownloadCommit|Email Patches"), project_commit_path(@project, @commit, format: :patch), class: "qa-email-patches" - %li= link_to s_("DownloadCommit|Plain Diff"), project_commit_path(@project, @commit, format: :diff), class: "qa-plain-diff" + %li= link_to s_('DownloadCommit|Email Patches'), project_commit_path(@project, @commit, format: :patch), class: "qa-email-patches" + %li= link_to s_('DownloadCommit|Plain Diff'), project_commit_path(@project, @commit, format: :diff), class: "qa-plain-diff" .commit-box{ data: { project_path: project_path(@project) } } %h3.commit-title @@ -74,8 +74,8 @@ %span.commit-info.merge-requests{ 'data-project-commit-path' => merge_requests_project_commit_path(@project, @commit.id, format: :json) } = icon('spinner spin') - - if @commit.last_pipeline - - last_pipeline = @commit.last_pipeline + - last_pipeline = @commit.last_pipeline + - if can?(current_user, :read_pipeline, last_pipeline) .well-segment.pipeline-info .status-icon-container = link_to project_pipeline_path(@project, last_pipeline.id), class: "ci-status-icon-#{last_pipeline.status}" do @@ -95,8 +95,5 @@ .well-segment = icon('info-circle fw') - This commit is part of merge request - = succeed '.' do - = link_to @merge_request.to_reference, diffs_project_merge_request_path(@project, @merge_request, commit_id: @commit.id) - - Comments created here will be created in the context of that merge request. + - link_to_merge_request = link_to(@merge_request.to_reference, diffs_project_merge_request_path(@project, @merge_request, commit_id: @commit.id)) + = _('This commit is part of merge request %{link_to_merge_request}. Comments created here will be created in the context of that merge request.').html_safe % { link_to_merge_request: link_to_merge_request } diff --git a/app/views/projects/commit/_limit_exceeded_message.html.haml b/app/views/projects/commit/_limit_exceeded_message.html.haml index a264f3517c4..7d3c0582d0b 100644 --- a/app/views/projects/commit/_limit_exceeded_message.html.haml +++ b/app/views/projects/commit/_limit_exceeded_message.html.haml @@ -1,8 +1,8 @@ -.has-tooltip{ class: "limit-box limit-box-#{objects} prepend-left-5", data: { title: "Project has too many #{label_for_message} to search"} } +.has-tooltip{ class: "limit-box limit-box-#{objects} prepend-left-5", data: { title: _('Project has too many %{label_for_message} to search') % { label_for_message: label_for_message } } } .limit-icon - if objects == :branch = sprite_icon('fork', size: 12) - else = icon('tag') .limit-message - %span #{label_for_message.capitalize} unavailable + %span= _('%{label_for_message} unavailable') % { label_for_message: label_for_message.capitalize } diff --git a/app/views/projects/commit/_other_user_signature_badge.html.haml b/app/views/projects/commit/_other_user_signature_badge.html.haml index d7bf2dc0cb6..bb843bee7c9 100644 --- a/app/views/projects/commit/_other_user_signature_badge.html.haml +++ b/app/views/projects/commit/_other_user_signature_badge.html.haml @@ -1,6 +1,6 @@ - title = capture do - This commit was signed with a different user's verified signature. + = _("This commit was signed with a different user's verified signature.") -- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'status_notfound_borderless', show_user: true } +- locals = { signature: signature, title: title, label: _('Unverified'), css_class: 'invalid', icon: 'status_notfound_borderless', show_user: true } = render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml index 22ffd66ff8e..d282ab4f520 100644 --- a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml +++ b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml @@ -1,7 +1,6 @@ - title = capture do - This commit was signed with a verified signature, but the committer email - is <strong>not verified</strong> to belong to the same user. + = _('This commit was signed with a verified signature, but the committer email is <strong>not verified</strong> to belong to the same user.').html_safe -- locals = { signature: signature, title: title, label: 'Unverified', css_class: ['invalid'], icon: 'status_notfound_borderless', show_user: true } +- locals = { signature: signature, title: title, label: _('Unverified'), css_class: ['invalid'], icon: 'status_notfound_borderless', show_user: true } = render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index c4d986ef742..1331fa179fc 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -19,10 +19,10 @@ .clearfix = render partial: 'projects/commit/signature_badge_user', locals: { signature: signature } - GPG Key ID: + = _('GPG Key ID:') %span.monospace= signature.gpg_key_primary_keyid - = link_to('Learn more about signing commits', help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') + = link_to(_('Learn more about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') %a{ href: 'javascript:void(0)', tabindex: 0, class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } = label diff --git a/app/views/projects/commit/_unverified_signature_badge.html.haml b/app/views/projects/commit/_unverified_signature_badge.html.haml index 00e1efe0582..294f916d18f 100644 --- a/app/views/projects/commit/_unverified_signature_badge.html.haml +++ b/app/views/projects/commit/_unverified_signature_badge.html.haml @@ -1,6 +1,6 @@ - title = capture do - This commit was signed with an <strong>unverified</strong> signature. + = _('This commit was signed with an <strong>unverified</strong> signature.').html_safe -- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'status_notfound_borderless' } +- 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/commit/_verified_signature_badge.html.haml b/app/views/projects/commit/_verified_signature_badge.html.haml index 31408806be7..4964b1b8ee7 100644 --- a/app/views/projects/commit/_verified_signature_badge.html.haml +++ b/app/views/projects/commit/_verified_signature_badge.html.haml @@ -1,7 +1,6 @@ - title = capture do - This commit was signed with a <strong>verified</strong> signature and the - committer email is verified to belong to the same user. + = _('This commit was signed with a <strong>verified</strong> signature and the committer email is verified to belong to the same user.').html_safe -- locals = { signature: signature, title: title, label: 'Verified', css_class: 'valid', icon: 'status_success_borderless', show_user: true } +- locals = { signature: signature, title: title, label: _('Verified'), css_class: 'valid', icon: 'status_success_borderless', show_user: true } = render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/pipelines.html.haml b/app/views/projects/commit/pipelines.html.haml index c66ea873dba..f8c27f4c026 100644 --- a/app/views/projects/commit/pipelines.html.haml +++ b/app/views/projects/commit/pipelines.html.haml @@ -1,4 +1,4 @@ -- page_title 'Pipelines', "#{@commit.title} (#{@commit.short_id})", 'Commits' +- page_title _('Pipelines'), "#{@commit.title} (#{@commit.short_id})", _('Commits') = render 'commit_box' = render 'ci_menu' diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index 79e32949db9..34226167288 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -1,19 +1,16 @@ - @no_container = true -- add_to_breadcrumbs "Commits", project_commits_path(@project) +- add_to_breadcrumbs _('Commits'), project_commits_path(@project) - breadcrumb_title @commit.short_id - container_class = !fluid_layout && diff_view == :inline ? 'container-limited' : '' - limited_container_width = fluid_layout ? '' : 'limit-container-width' - @content_class = limited_container_width -- page_title "#{@commit.title} (#{@commit.short_id})", "Commits" +- page_title "#{@commit.title} (#{@commit.short_id})", _('Commits') - page_description @commit.description .container-fluid{ class: [limited_container_width, container_class] } = render "commit_box" - - if @commit.status - = render "ci_menu" - - else - .block-connector - = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, is_commit: true + = render "ci_menu" + = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, diff_page_context: "is-commit" .limited-width-notes = render "shared/notes/notes_with_form", :autocomplete => true diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 1a74b120c26..0d3c6e7027c 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -6,6 +6,7 @@ - merge_request = local_assigns.fetch(:merge_request, nil) - project = local_assigns.fetch(:project) { merge_request&.project } - ref = local_assigns.fetch(:ref) { merge_request&.source_branch } +- commit_status = commit.present(current_user: current_user).status_for(ref) - link = commit_path(project, commit, merge_request: merge_request) %li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" } @@ -22,7 +23,7 @@ %span.commit-row-message.d-block.d-sm-none · = commit.short_id - - if commit.status(ref) + - if commit_status .d-block.d-sm-none = render_commit_status(commit, ref: ref) - if commit.description? @@ -45,7 +46,7 @@ - else = render partial: 'projects/commit/ajax_signature', locals: { commit: commit } - - if commit.status(ref) + - if commit_status = render_commit_status(commit, ref: ref) .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } } diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml index b6bebbabed0..5774b48a054 100644 --- a/app/views/projects/compare/show.html.haml +++ b/app/views/projects/compare/show.html.haml @@ -8,7 +8,7 @@ - if @commits.present? = render "projects/commits/commit_list" - = render "projects/diffs/diffs", diffs: @diffs, environment: @environment + = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, diff_page_context: "is-compare" - else .card.bg-light .center diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index 062aa423bde..24d665761cc 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -3,7 +3,7 @@ .settings-header %h4 Deploy Keys - %button.btn.js-settings-toggle.qa-expand-deploy-keys{ type: 'button' } + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Deploy keys allow read-only or read-write (if enabled) access to your repository. Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one. diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml index f4c91377ecb..e45c5be76de 100644 --- a/app/views/projects/deployments/_actions.haml +++ b/app/views/projects/deployments/_actions.haml @@ -11,4 +11,4 @@ - next unless can?(current_user, :update_build, action) %li = link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow', class: 'btn' do - %span= action.name.humanize + %span= action.name diff --git a/app/views/projects/deployments/_confirm_rollback_modal.html.haml b/app/views/projects/deployments/_confirm_rollback_modal.html.haml new file mode 100644 index 00000000000..ff40e404e5f --- /dev/null +++ b/app/views/projects/deployments/_confirm_rollback_modal.html.haml @@ -0,0 +1,23 @@ +- commit_sha = link_to deployment.short_sha, project_commit_path(@project, deployment.sha), class: "commit-sha has-tooltip", title: h(deployment.commit_title) +.modal.ws-normal.fade{ tabindex: -1, id: "confirm-rollback-modal-#{deployment.id}" } + .modal-dialog + .modal-content + .modal-header + %h4.modal-title.d-flex.mw-100 + - if deployment.last? + = s_("Environments|Re-deploy environment %{environment_name}?") % {environment_name: @environment.name} + - else + = s_("Environments|Rollback environment %{environment_name}?") % {environment_name: @environment.name} + .modal-body + - if deployment.last? + %p= s_('Environments|This action will relaunch the job for commit %{commit_id}, putting the environment in a previous version. Are you sure you want to continue?').html_safe % {commit_id: commit_sha} + - else + %p + = s_('Environments|This action will run the job defined by staging for commit %{commit_id}, putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?').html_safe % {commit_id: commit_sha} + .modal-footer + = button_tag _('Cancel'), type: 'button', class: 'btn btn-cancel', data: { dismiss: 'modal' } + = link_to [:retry, @project.namespace.becomes(Namespace), @project, deployment.deployable], method: :post, class: 'btn btn-danger' do + - if deployment.last? + = s_('Environments|Re-deploy') + - else + = s_('Environments|Rollback') diff --git a/app/views/projects/deployments/_rollback.haml b/app/views/projects/deployments/_rollback.haml index 1bd538a08ff..d6bf8d564de 100644 --- a/app/views/projects/deployments/_rollback.haml +++ b/app/views/projects/deployments/_rollback.haml @@ -1,7 +1,8 @@ - if can?(current_user, :create_deployment, deployment) - tooltip = deployment.last? ? s_('Environments|Re-deploy to environment') : s_('Environments|Rollback environment') - = link_to [:retry, @project.namespace.becomes(Namespace), @project, deployment.deployable], method: :post, class: 'btn btn-build has-tooltip', title: tooltip do + = button_tag class: 'btn btn-default btn-build has-tooltip', type: 'button', data: { toggle: 'modal', target: "#confirm-rollback-modal-#{deployment.id}" }, title: tooltip do - if deployment.last? = sprite_icon('repeat') - else = sprite_icon('redo') + = render 'projects/deployments/confirm_rollback_modal', deployment: deployment diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index cc2d0d3b2d8..2dba3fcd664 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -2,7 +2,7 @@ - show_whitespace_toggle = local_assigns.fetch(:show_whitespace_toggle, true) - can_create_note = !@diff_notes_disabled && can?(current_user, :create_note, diffs.project) - diff_files = diffs.diff_files -- is_commit = local_assigns.fetch(:is_commit, false) +- diff_page_context = local_assigns.fetch(:diff_page_context, nil) .content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed .files-changed-inner @@ -25,4 +25,4 @@ = render 'projects/diffs/warning', diff_files: diffs .files{ data: { can_create_note: can_create_note } } - = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, is_commit: is_commit } + = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, diff_page_context: diff_page_context } diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index 5565ae1d98b..855b719dc45 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -1,11 +1,11 @@ - environment = local_assigns.fetch(:environment, nil) -- is_commit = local_assigns.fetch(:is_commit, false) +- diff_page_context = local_assigns.fetch(:diff_page_context, nil) - file_hash = hexdigest(diff_file.file_path) - image_diff = diff_file.rich_viewer && diff_file.rich_viewer.partial_name == 'image' - image_replaced = diff_file.old_content_sha && diff_file.old_content_sha != diff_file.content_sha .diff-file.file-holder{ id: file_hash, data: diff_file_html_data(project, diff_file.file_path, diff_file.content_sha) } - .js-file-title.file-title-flex-parent{ class: is_commit ? "is-commit" : "" } + .js-file-title.file-title-flex-parent{ class: diff_page_context } .file-header-content = render "projects/diffs/file_header", diff_file: diff_file, url: "##{file_hash}" diff --git a/app/views/projects/diffs/_render_error.html.haml b/app/views/projects/diffs/_render_error.html.haml index c3dc47a56a7..7dbd9897e83 100644 --- a/app/views/projects/diffs/_render_error.html.haml +++ b/app/views/projects/diffs/_render_error.html.haml @@ -1,6 +1,2 @@ .nothing-here-block - = _("This %{viewer} could not be displayed because %{reason}.") % { viewer: viewer.switcher_title, reason: diff_render_error_reason(viewer) } - - You can - = diff_render_error_options(viewer).to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe - instead. + = viewer.render_error_message.html_safe diff --git a/app/views/projects/diffs/_warning.html.haml b/app/views/projects/diffs/_warning.html.haml index 2eef599cf84..2cc3d921abc 100644 --- a/app/views/projects/diffs/_warning.html.haml +++ b/app/views/projects/diffs/_warning.html.haml @@ -9,4 +9,4 @@ = link_to _("Plain diff"), merge_request_path(@merge_request, format: :diff), class: "btn btn-sm" = link_to _("Email patch"), merge_request_path(@merge_request, format: :patch), class: "btn btn-sm" %p - = _("To preserve performance only <strong>%{display_size} of ${real_size}</strong> files are displayed.").html_safe % { display_size: diff_files.size, real_size: diff_files.real_size } + = _("To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed.").html_safe % { display_size: diff_files.size, real_size: diff_files.real_size } diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 1b52821af15..a6a8ca489a9 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -1,17 +1,15 @@ -- breadcrumb_title "General Settings" -- page_title "General" +- breadcrumb_title _("General Settings") +- page_title _("General") - @content_class = "limit-container-width" unless fluid_layout - expanded = Rails.env.test? .project-edit-container %section.settings.general-settings.no-animate#js-general-project-settings{ class: ('expanded' if expanded) } .settings-header - %h4 - General project - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' - %p - Update your project name, description, avatar, and other general settings. + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, tags, avatar') + %button.btn.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand') + %p= _('Update your project name, tags, description and avatar.') + .settings-content .project-edit-errors = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "edit-project" }, authenticity_token: true do |f| @@ -39,14 +37,14 @@ = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project .form-group - = f.label :tag_list, "Tags", class: 'label-bold' + = f.label :tag_list, "Topics", class: 'label-bold' = f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control" - %p.form-text.text-muted Separate tags with commas. + %p.form-text.text-muted Separate topics with commas. %fieldset.features %h5.prepend-top-0= _("Project avatar") .form-group - if @project.avatar? - .avatar-container.s160.append-bottom-15 + .avatar-container.rect-avatar.s160.append-bottom-15 = project_icon(@project, alt: '', class: 'avatar project-avatar s160', width: 160, height: 160) - if @project.avatar_in_git %p.light @@ -63,12 +61,10 @@ %section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded) } .settings-header - %h4 - Permissions - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' - %p - Enable or disable certain project features and choose access levels. + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Visibility, project features, permissions') + %button.btn.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand') + %p= _('Choose visibility level, enable/disable project features (issues, repository, wiki, snippets) and set permissions.') + .settings-content = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f| %input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' } @@ -81,12 +77,10 @@ %section.qa-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)] } .settings-header - %h4 - Merge request - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' - %p - Customize your merge request restrictions. + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Merge requests') + %button.btn.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand') + %p= _('Choose your merge method, set up a default merge request description template.') + .settings-content = render_if_exists 'shared/promotions/promote_mr_features' @@ -97,11 +91,10 @@ = render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded - = render_if_exists 'projects/service_desk_settings' %section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header - %h4 + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = s_('ProjectSettings|Badges') %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' @@ -111,16 +104,15 @@ .settings-content = render 'shared/badges/badge_settings' + = render_if_exists 'projects/service_desk_settings' = render 'export', project: @project %section.qa-advanced-settings.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded) } .settings-header - %h4 - Advanced - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' - %p - Perform advanced options such as housekeeping, archiving, renaming, transferring, or removing your project. + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Advanced') + %button.btn.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand') + %p= _('Housekeeping, export, path, transfer, remove, archive.') + .settings-content .sub-section %h4 Housekeeping diff --git a/app/views/projects/environments/folder.html.haml b/app/views/projects/environments/folder.html.haml index b7e1cf85cb7..aebd176af9b 100644 --- a/app/views/projects/environments/folder.html.haml +++ b/app/views/projects/environments/folder.html.haml @@ -1,8 +1,5 @@ - @no_container = true - page_title _("Environments") -#environments-folder-list-view{ data: { endpoint: folder_project_environments_path(@project, @folder, format: :json), - "folder-name" => @folder, - "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s, - "can-read-environment" => can?(current_user, :read_environment, @project).to_s, +#environments-folder-list-view{ data: { environments_data: environments_folder_list_view_data, "css-class" => container_class } } diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index d66de7ab698..99cbbc11acd 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -2,7 +2,6 @@ - page_title _("Environments") #environments-list-view{ data: { environments_data: environments_list_data, - "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s, "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), diff --git a/app/views/projects/error_tracking/index.html.haml b/app/views/projects/error_tracking/index.html.haml new file mode 100644 index 00000000000..bc02c5f0e5a --- /dev/null +++ b/app/views/projects/error_tracking/index.html.haml @@ -0,0 +1,3 @@ +- page_title _('Errors') + +#js-error_tracking{ data: error_tracking_data(@project) } diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml index a69146513d8..3f0798a898d 100644 --- a/app/views/projects/forks/_fork_button.html.haml +++ b/app/views/projects/forks/_fork_button.html.haml @@ -5,7 +5,7 @@ .bordered-box.fork-thumbnail.text-center.prepend-left-default.append-right-default.prepend-top-default.append-bottom-default.forked = link_to project_path(forked_project) do - if /no_((\w*)_)*avatar/.match(avatar) - = group_icon(namespace, class: "avatar s100 identicon") + = group_icon(namespace, class: "avatar rect-avatar s100 identicon") - else .avatar-container.s100 = image_tag(avatar, class: "avatar s100") @@ -18,7 +18,7 @@ class: ("disabled has-tooltip" unless can_create_project), title: (_('You have reached your project limit') unless can_create_project) do - if /no_((\w*)_)*avatar/.match(avatar) - = group_icon(namespace, class: "avatar s100 identicon") + = group_icon(namespace, class: "avatar rect-avatar s100 identicon") - else .avatar-container.s100 = image_tag(avatar, class: "avatar s100") diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index b0e22a35fe1..60160f521ad 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -55,23 +55,23 @@ #{@commits_graph.authors} = (_("Authors: %{authors}") % { authors: "<strong>#{authors}</strong>" }).html_safe .col-md-6 + %p.slead + = _("Commits per day of month") %div - %p.slead - = _("Commits per day of month") %canvas#month-chart .row .col-md-6 .col-md-6 + %p.slead + = _("Commits per weekday") %div - %p.slead - = _("Commits per weekday") %canvas#weekday-chart .row .col-md-6 .col-md-6 + %p.slead + = _("Commits per day hour (UTC)") %div - %p.slead - = _("Commits per day hour (UTC)") %canvas#hour-chart -# haml-lint:disable InlineJavaScript diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 4917f4b8903..42b6aaa2634 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -5,7 +5,7 @@ = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' -%section.js-vue-notes-event +%section.issuable-discussion.js-vue-notes-event #js-vue-notes{ data: { notes_data: notes_data(@issue).to_json, noteable_data: serialize_issuable(@issue), noteable_type: 'Issue', diff --git a/app/views/projects/issues/_form.html.haml b/app/views/projects/issues/_form.html.haml index 1e4e9450ffa..1be1087b36f 100644 --- a/app/views/projects/issues/_form.html.haml +++ b/app/views/projects/issues/_form.html.haml @@ -1,4 +1,3 @@ = form_for [@project.namespace.becomes(Namespace), @project, @issue], - html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' }, - data: { markdown_version: @issue.cached_markdown_version } do |f| + html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' } do |f| = render 'shared/issuable/form', f: f, issuable: @issue diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 31c72f2f759..ce7c7091c93 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -36,7 +36,7 @@ = issue.due_date.to_s(:medium) - if issue.labels.any? - - issue.labels.each do |label| + - labels_sorted_by_title(issue.labels).each do |label| = link_to_label(label, subject: issue.project, css_class: 'label-link') .issuable-meta diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml index 5c36d2202a6..6a66c2e57cc 100644 --- a/app/views/projects/issues/_merge_requests.html.haml +++ b/app/views/projects/issues/_merge_requests.html.haml @@ -1,35 +1,36 @@ - if @merge_requests.any? - %h2.merge-requests-title - = pluralize(@merge_requests.count, 'Related Merge Request') - %ul.unstyled-list.related-merge-requests - - has_any_head_pipeline = @merge_requests.any?(&:head_pipeline_id) - - @merge_requests.each do |merge_request| - %li - %span.merge-request-ci-status - - if merge_request.head_pipeline - = render_pipeline_status(merge_request.head_pipeline) - - elsif has_any_head_pipeline - = icon('blank fw') - %span.merge-request-id - = merge_request.to_reference - %span.merge-request-info - %strong - = link_to merge_request.title, merge_request_path(merge_request), class: "row_title" - - unless @issue.project.id == merge_request.target_project.id - in - - project = merge_request.target_project - = link_to project.full_name, project_path(project) + .card-slim.mt-3 + .card-header + %h2.card-title.mt-0.mb-0.h5.merge-requests-title + %span.mr-1.bold + = _('Related merge requests') + .d-inline-flex.lh-100.align-middle + .mr-count-badge + .mr-count-badge-count + = sprite_icon('merge-request', size: 16, css_class: 'mr-1 text-secondary') + = @merge_requests.count + %ul.content-list.related-items-list + - has_any_head_pipeline = @merge_requests.any?(&:head_pipeline_id) + - @merge_requests.each do |merge_request| + - merge_request = merge_request.present(current_user: current_user) + %li.list-item.py-0.px-0 + .item-body.issuable-info-container.py-lg-3.px-lg-3.pl-md-3 + .item-contents + .item-title.d-flex.align-items-center.mr-title + = render partial: 'projects/issues/merge_requests_status', locals: { merge_request: merge_request, css_class: 'd-none d-xl-block append-right-8' } + = link_to merge_request.title, merge_request_path(merge_request), { class: 'mr-title-link'} + .item-meta + = render partial: 'projects/issues/merge_requests_status', locals: { merge_request: merge_request, css_class: 'd-xl-none d-lg-block append-right-5' } + %span.d-flex.align-items-center.append-right-8.mr-item-path.item-path-id.mt-0 + %span.path-id-text.bold.text-truncate{ data: { toggle: 'tooltip'}, title: merge_request.target_project.full_path } + = merge_request.target_project.full_path + = merge_request.to_reference + %span.mr-ci-status.flex-md-grow-1.justify-content-end.d-flex.ml-md-2 + - if merge_request.can_read_pipeline? + = render 'ci/status/icon', status: merge_request.head_pipeline.detailed_status(current_user), tooltip_placement: 'bottom' + - elsif has_any_head_pipeline + = icon('blank fw') - - if merge_request.merged? - %span.merge-request-status.prepend-left-10.merged - Merged - - elsif merge_request.closed? - %span.merge-request-status.prepend-left-10.closed - Closed - - else - %span.merge-request-status.prepend-left-10.open - Open - - - if @closed_by_merge_requests.present? - %li - = render partial: 'projects/issues/closed_by_box', locals: {merge_request_count: @merge_requests.count} + - if @closed_by_merge_requests.present? + %p + = render partial: 'projects/issues/closed_by_box', locals: {merge_request_count: @merge_requests.count} diff --git a/app/views/projects/issues/_merge_requests_status.html.haml b/app/views/projects/issues/_merge_requests_status.html.haml new file mode 100644 index 00000000000..90838a75214 --- /dev/null +++ b/app/views/projects/issues/_merge_requests_status.html.haml @@ -0,0 +1,25 @@ +- time_format = '%b %e, %Y %l:%M%P %Z%z' + +- if merge_request.merged? + - mr_status_date = merge_request.merged_at + - mr_status_title = _('Merged') + - mr_status_icon = 'merge' + - mr_status_class = 'merged' +- elsif merge_request.closed? + - mr_status_date = merge_request.closed_event&.created_at + - mr_status_title = _('Closed') + - mr_status_icon = 'issue-close' + - mr_status_class = 'closed' +- else + - mr_status_date = merge_request.created_at + - mr_status_title = mr_status_date ? _('Opened') : _('Open') + - mr_status_icon = 'issue-open-m' + - mr_status_class = 'open' + +- if mr_status_date + - mr_status_tooltip = "<div><span class=\"bold\">#{mr_status_title}</span> #{time_ago_in_words(mr_status_date)} ago</div><span class=\"text-tertiary\">#{l(mr_status_date.to_time, format: time_format)}</span>" +- else + - mr_status_tooltip = "<div><span class=\"bold\">#{mr_status_title}</span></div>" + +%span.mr-status-wrapper.suggestion-help-hover{ class: css_class, data: { toggle: 'tooltip', placement: 'bottom', html: 'true', title: mr_status_tooltip } } + = sprite_icon(mr_status_icon, size: 16, css_class: "merge-request-status #{mr_status_class}") diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml index e4a0d4b8479..329efa0cdbf 100644 --- a/app/views/projects/issues/_nav_btns.html.haml +++ b/app/views/projects/issues/_nav_btns.html.haml @@ -1,11 +1,30 @@ -= render 'shared/issuable/feed_buttons' - -- if @can_bulk_update - = button_tag "Edit issues", class: "btn btn-default append-right-10 js-bulk-update-toggle" -- if show_new_issue_link?(@project) - = link_to "New issue", new_project_issue_path(@project, - issue: { assignee_id: finder.assignee.try(:id), - milestone_id: finder.milestones.first.try(:id) }), - class: "btn btn-success", - title: "New issue", - id: "new_issue_link" +- show_feed_buttons = local_assigns.fetch(:show_feed_buttons, true) +- show_import_button = local_assigns.fetch(:show_import_button, true) && can?(current_user, :import_issues, @project) +- show_export_button = local_assigns.fetch(:show_export_button, true) + +.nav-controls.issues-nav-controls + - if show_feed_buttons + = render 'shared/issuable/feed_buttons' + + .btn-group.append-right-10< + - if show_export_button + = render_if_exists 'projects/issues/export_csv/button' + + - if show_import_button + = render 'projects/issues/import_csv/button' + + - if @can_bulk_update + = button_tag _("Edit issues"), class: "btn btn-default append-right-10 js-bulk-update-toggle" + - if show_new_issue_link?(@project) + = link_to _("New issue"), new_project_issue_path(@project, + issue: { assignee_id: finder.assignee.try(:id), + milestone_id: finder.milestones.first.try(:id) }), + class: "btn btn-success", + title: _("New issue"), + id: "new_issue_link" + +- if show_export_button + = render_if_exists 'projects/issues/export_csv/modal' + +- if show_import_button + = render 'projects/issues/import_csv/modal' diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 5374f4a1de0..fbd70cd1906 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -30,7 +30,7 @@ = icon('check', class: 'icon') = _('Create merge request and branch') - %li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: _('Create branch') } } + %li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: _('Create branch') } } .menu-item = icon('check', class: 'icon') = _('Create branch') diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index 1df38db9fd4..6da4956a036 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -6,9 +6,9 @@ %li - target = @project.repository.find_branch(branch).dereferenced_target - pipeline = @project.pipeline_for(branch, target.sha) if target - - if pipeline + - if can?(current_user, :read_pipeline, pipeline) %span.related-branch-ci-status - = render_pipeline_status(pipeline) + = render 'ci/status/icon', status: pipeline.detailed_status(current_user) %span.related-branch-info %strong = link_to branch, project_compare_path(@project, from: @project.default_branch, to: branch), class: "ref-name" diff --git a/app/views/projects/issues/import_csv/_button.html.haml b/app/views/projects/issues/import_csv/_button.html.haml new file mode 100644 index 00000000000..acc2c50294f --- /dev/null +++ b/app/views/projects/issues/import_csv/_button.html.haml @@ -0,0 +1,9 @@ +- type = local_assigns.fetch(:type, :icon) + +%button.csv-import-button.btn{ title: _('Import CSV'), class: ('has-tooltip' if type == :icon), + data: { toggle: 'modal', target: '.issues-import-modal' } } + - if type == :icon + = sprite_icon('upload') + - else + = _('Import CSV') + diff --git a/app/views/projects/issues/import_csv/_modal.html.haml b/app/views/projects/issues/import_csv/_modal.html.haml new file mode 100644 index 00000000000..86bc54786ad --- /dev/null +++ b/app/views/projects/issues/import_csv/_modal.html.haml @@ -0,0 +1,24 @@ +.issues-import-modal.modal + .modal-dialog + .modal-content + = form_tag import_csv_namespace_project_issues_path, multipart: true do + .modal-header + %h3 + = _('Import issues') + .svg-content.import-export-svg-container + = image_tag 'illustrations/export-import.svg', alt: _('Import/Export illustration'), class: 'illustration' + %a.close{ href: '#', 'data-dismiss' => 'modal' } × + .modal-body + .modal-text + %p + = _("Your issues will be imported in the background. Once finished, you'll get a confirmation email.") + .form-group + = label_tag :file, _('Upload CSV file'), class: 'label-bold' + %div + = file_field_tag :file, accept: '.csv,text/csv', required: true + %p.text-secondary + = _('It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.') + = _('The maximum file size allowed is %{size}.') % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) } + .modal-footer + %button{ type: 'submit', class: 'btn btn-success', title: _('Import issues'), data: { track_label: "export_issues_csv", track_event: "click_button"} } + = _('Import issues') diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 1e7737aeb97..39e9e9171cf 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -11,8 +11,7 @@ %div{ class: (container_class) } .top-area = render 'shared/issuable/nav', type: :issues - .nav-controls - = render "projects/issues/nav_btns" + = render "projects/issues/nav_btns" = render 'shared/issuable/search_bar', type: :issues - if @can_bulk_update @@ -23,4 +22,4 @@ - if new_issue_email = render 'projects/issuable_by_email', email: new_issue_email, issuable_type: 'issue' - else - = render 'shared/empty_states/issues', button_path: new_project_issue_path(@project) + = render 'shared/empty_states/issues', button_path: new_project_issue_path(@project), show_import_button: true diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 8c2fe2625c7..3a674da6e87 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -14,8 +14,14 @@ .detail-page-header-body .issuable-status-box.status-box.status-box-issue-closed{ class: issue_button_visibility(@issue, false) } = sprite_icon('mobile-issue-close', size: 16, css_class: 'd-block d-sm-none') - %span.d-none.d-sm-block - Closed + .d-none.d-sm-block + - if @issue.moved? + - moved_link_start = "<a href=\"#{issue_path(@issue.moved_to)}\" class=\"text-white text-underline\">".html_safe + - moved_link_end = '</a>'.html_safe + = s_('IssuableStatus|Closed (%{moved_link_start}moved%{moved_link_end})').html_safe % {moved_link_start: moved_link_start, + moved_link_end: moved_link_end} + - else + = _("Closed") .issuable-status-box.status-box.status-box-open{ class: issue_button_visibility(@issue, true) } = sprite_icon('issue-open-m', size: 16, css_class: 'd-block d-sm-none') %span.d-none.d-sm-block Open @@ -55,7 +61,7 @@ - if can_report_spam = link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'd-none d-sm-none d-md-block btn btn-grouped btn-spam', title: 'Submit as spam' - if can_create_issue - = link_to new_project_issue_path(@project), class: 'd-none d-sm-none d-md-block btn btn-grouped new-issue-link btn-success btn-inverted', title: 'New issue', id: 'new_issue_link' do + = link_to new_project_issue_path(@project), class: 'd-none d-sm-none d-md-block btn btn-grouped btn-success btn-inverted', title: 'New issue', id: 'new_issue_link' do New issue .issue-details.issuable-details @@ -85,7 +91,6 @@ #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@issue), notes_filters: UserPreference.notes_filters.to_json } } = render 'new_branch' unless @issue.confidential? - %section.issuable-discussion - = render 'projects/issues/discussion' + = render_if_exists 'projects/issues/discussion' = render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @issue.assignees diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml index 59592abcf6a..afea5268006 100644 --- a/app/views/projects/jobs/index.html.haml +++ b/app/views/projects/jobs/index.html.haml @@ -8,10 +8,6 @@ .nav-controls - if can?(current_user, :update_build, @project) - - if @all_builds.running_or_pending.limit(1).any? # rubocop: disable CodeReuse/ActiveRecord - = link_to 'Cancel running', cancel_all_project_jobs_path(@project), - data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post - - unless @repository.gitlab_ci_yml = link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info' diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 56b06374d6d..bb7c297ba1f 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -5,15 +5,10 @@ - subscribed = params[:subscribed] - labels_or_filters = @labels.exists? || @prioritized_labels.exists? || search.present? || subscribed.present? -- if labels_or_filters && can_admin_label - - content_for(:header_content) do - .nav-controls - = link_to _('New label'), new_project_label_path(@project), class: "btn btn-success qa-label-create-new" - - if labels_or_filters #promote-label-modal %div{ class: container_class } - = render 'shared/labels/nav' + = render 'shared/labels/nav', labels_or_filters: labels_or_filters, can_admin_label: can_admin_label .labels-container.prepend-top-10 - if can_admin_label diff --git a/app/views/projects/merge_requests/_form.html.haml b/app/views/projects/merge_requests/_form.html.haml index 13b967beba1..a7c9e54506d 100644 --- a/app/views/projects/merge_requests/_form.html.haml +++ b/app/views/projects/merge_requests/_form.html.haml @@ -1,4 +1,3 @@ = form_for [@project.namespace.becomes(Namespace), @project, @merge_request], - html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' }, - data: { markdown_version: @merge_request.cached_markdown_version } do |f| + html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f| = render 'shared/issuable/form', f: f, issuable: @merge_request, presenter: @mr_presenter diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index faa070d0389..b8e0b66e277 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -34,7 +34,7 @@ = merge_request.target_branch - if merge_request.labels.any? - - merge_request.labels.each do |label| + - labels_sorted_by_title(merge_request.labels).each do |label| = link_to_label(label, subject: merge_request.project, type: :merge_request, css_class: 'label-link') .issuable-meta @@ -46,9 +46,9 @@ %li.issuable-status.d-none.d-sm-inline-block = icon('ban') CLOSED - - if merge_request.head_pipeline + - if can?(current_user, :read_pipeline, merge_request.head_pipeline) %li.issuable-pipeline-status.d-none.d-sm-inline-block - = render_pipeline_status(merge_request.head_pipeline) + = render 'ci/status/icon', status: merge_request.head_pipeline.detailed_status(current_user) - if merge_request.open? && merge_request.broken? %li.issuable-pipeline-broken.d-none.d-sm-inline-block = link_to merge_request_path(merge_request), class: "has-tooltip", title: _('Cannot be merged automatically') do @@ -56,6 +56,7 @@ - if merge_request.assignee %li = link_to_member(merge_request.source_project, merge_request.assignee, name: false, title: _('Assigned to :name')) + = render_if_exists 'projects/merge_requests/approvals_count', merge_request: merge_request = render 'shared/issuable_meta_data', issuable: merge_request diff --git a/app/views/projects/merge_requests/_merge_requests.html.haml b/app/views/projects/merge_requests/_merge_requests.html.haml index bd6f1c05949..57fbd360d46 100644 --- a/app/views/projects/merge_requests/_merge_requests.html.haml +++ b/app/views/projects/merge_requests/_merge_requests.html.haml @@ -1,5 +1,5 @@ %ul.content-list.mr-list.issuable-list - - if @merge_requests.exists? + - if @merge_requests.present? = render @merge_requests - else = render 'shared/empty_states/merge_requests' diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index d6f340d0ee2..5111c9fab8d 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -7,7 +7,7 @@ - page_card_attributes @merge_request.card_attributes - suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes') -.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project) } } +.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } } = render "projects/merge_requests/mr_title" .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } @@ -59,6 +59,7 @@ #js-vue-discussion-counter .tab-content#diff-notes-app + #js-diff-file-finder #notes.notes.tab-pane.voting_notes .row %section.col-md-12 diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index ebd3229e42b..5cc6b5a173b 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -1,27 +1,28 @@ = form_for [@project.namespace.becomes(Namespace), @project, @milestone], - html: {class: 'milestone-form common-note-form js-quick-submit js-requires-input'}, - data: { markdown_version: @milestone.cached_markdown_version } do |f| + html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f| = form_errors(@milestone) .row .col-md-6 .form-group.row - = f.label :title, "Title", class: "col-form-label col-sm-2" + .col-form-label.col-sm-2 + = f.label :title, _('Title') .col-sm-10 - = f.text_field :title, maxlength: 255, class: "qa-milestone-title form-control", required: true, autofocus: true + = f.text_field :title, maxlength: 255, class: 'qa-milestone-title form-control', required: true, autofocus: true .form-group.row.milestone-description - = f.label :description, "Description", class: "col-form-label col-sm-2" + .col-form-label.col-sm-2 + = f.label :description, _('Description') .col-sm-10 = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project) } do - = render 'projects/zen', f: f, attr: :description, classes: 'qa-milestone-description note-textarea', placeholder: 'Write milestone description...' + = render 'projects/zen', f: f, attr: :description, classes: 'qa-milestone-description note-textarea', placeholder: _('Write milestone description...') = render 'shared/notes/hints' .clearfix .error-alert - = render "shared/milestones/form_dates", f: f + = render 'shared/milestones/form_dates', f: f .form-actions - if @milestone.new_record? - = f.submit 'Create milestone', class: "btn-success btn qa-milestone-create-button" - = link_to "Cancel", project_milestones_path(@project), class: "btn btn-cancel" + = f.submit _('Create milestone'), class: 'btn-create btn qa-milestone-create-button' + = link_to _('Cancel'), project_milestones_path(@project), class: 'btn btn-cancel' - else - = f.submit 'Save changes', class: "btn-success btn" - = link_to "Cancel", project_milestone_path(@project, @milestone), class: "btn btn-cancel" + = f.submit _('Save changes'), class: 'btn-success btn' + = link_to _('Cancel'), project_milestone_path(@project, @milestone), class: 'btn btn-cancel' diff --git a/app/views/projects/milestones/edit.html.haml b/app/views/projects/milestones/edit.html.haml index 4006a468792..aa564e00af9 100644 --- a/app/views/projects/milestones/edit.html.haml +++ b/app/views/projects/milestones/edit.html.haml @@ -1,14 +1,14 @@ - @no_container = true -- breadcrumb_title "Edit" -- add_to_breadcrumbs "Milestones", project_milestones_path(@project) -- page_title "Edit", @milestone.title, "Milestones" +- breadcrumb_title _('Edit') +- add_to_breadcrumbs _('Milestones'), project_milestones_path(@project) +- page_title _('Edit'), @milestone.title, _('Milestones') %div{ class: container_class } %h3.page-title - Edit Milestone + = _('Edit Milestone') %hr - = render "form" + = render 'form' diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml index 57f3c640696..a3414c16d73 100644 --- a/app/views/projects/milestones/index.html.haml +++ b/app/views/projects/milestones/index.html.haml @@ -1,15 +1,16 @@ - @no_container = true -- page_title 'Milestones' +- page_title _('Milestones') %div{ class: container_class } .top-area = render 'shared/milestones_filter', counts: milestone_counts(@project.milestones) .nav-controls + = render 'shared/milestones/search_form' = render 'shared/milestones_sort_dropdown' - if can?(current_user, :admin_milestone, @project) - = link_to new_project_milestone_path(@project), class: "btn btn-success qa-new-project-milestone", title: 'New milestone' do - New milestone + = link_to new_project_milestone_path(@project), class: 'btn btn-success qa-new-project-milestone', title: _('New milestone') do + = _('New milestone') .milestones #delete-milestone-modal @@ -20,6 +21,6 @@ - if @milestones.blank? %li - .nothing-here-block No milestones to show + .nothing-here-block= _('No milestones to show') = paginate @milestones, theme: 'gitlab' diff --git a/app/views/projects/milestones/new.html.haml b/app/views/projects/milestones/new.html.haml index 01cc951e8c2..79207fd70b5 100644 --- a/app/views/projects/milestones/new.html.haml +++ b/app/views/projects/milestones/new.html.haml @@ -1,12 +1,12 @@ - @no_container = true -- add_to_breadcrumbs "Milestones", project_milestones_path(@project) -- breadcrumb_title "New" -- page_title "New Milestone" +- add_to_breadcrumbs _('Milestones'), project_milestones_path(@project) +- breadcrumb_title _('New') +- page_title _('New Milestone') %div{ class: container_class } %h3.page-title - New Milestone + = _('New Milestone') %hr - = render "form" + = render 'form' diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index 5859de61d71..0542b349e44 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -1,30 +1,30 @@ - @no_container = true -- add_to_breadcrumbs "Milestones", project_milestones_path(@project) +- add_to_breadcrumbs _('Milestones'), project_milestones_path(@project) - breadcrumb_title @milestone.title -- page_title @milestone.title, "Milestones" +- page_title @milestone.title, _('Milestones') - page_description @milestone.description %div{ class: container_class } .detail-page-header.milestone-page-header .status-box{ class: status_box_class(@milestone) } - if @milestone.closed? - Closed + = _('Closed') - elsif @milestone.expired? - Past due + = _('Past due') - elsif @milestone.upcoming? - Upcoming + = _('Upcoming') - else - Open + = _('Open') .header-text-content %span.identifier %strong - Milestone + = _('Milestone') - if @milestone.due_date || @milestone.start_date = milestone_date_range(@milestone) .milestone-buttons - if can?(current_user, :admin_milestone, @project) - = link_to edit_project_milestone_path(@project, @milestone), class: "btn btn-grouped btn-nr" do - Edit + = link_to edit_project_milestone_path(@project, @milestone), class: 'btn btn-grouped btn-nr' do + = _('Edit') - if @project.group %button.js-promote-project-milestone-button.btn.btn-grouped{ data: { toggle: 'modal', @@ -39,13 +39,13 @@ #promote-milestone-modal - if @milestone.active? - = link_to 'Close milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped" + = link_to _('Close milestone'), project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: 'btn btn-close btn-nr btn-grouped' - else - = link_to 'Reopen milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped" + = link_to _('Reopen milestone'), project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: 'btn btn-reopen btn-nr btn-grouped' = render 'shared/milestones/delete_button' - %a.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ href: "#" } + %a.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ href: '#' } = icon('angle-double-left') .detail-page-description.milestone-detail @@ -62,10 +62,10 @@ - if can?(current_user, :read_issue, @project) && @milestone.total_items_count(current_user).zero? .alert.alert-success.prepend-top-default - %span Assign some issues to this milestone. + %span= _('Assign some issues to this milestone.') - elsif @milestone.complete?(current_user) && @milestone.active? .alert.alert-success.prepend-top-default - %span All issues for this milestone are closed. You may close this milestone now. + %span= _('All issues for this milestone are closed. You may close this milestone now.') = render 'deprecation_message' = render 'shared/milestones/tabs', milestone: @milestone diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index 21b105e6f80..c031815200b 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -57,7 +57,7 @@ %td - if mirror.last_error.present? .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error') - %td.mirror-action-buttons + %td .btn-group.mirror-actions-group.pull-right{ role: 'group' } - if mirror.ssh_key_auth? = clipboard_button(text: mirror.ssh_public_key, class: 'btn btn-default', title: _('Copy SSH public key')) diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index a760d02c4c3..ff7c36c2d5b 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -16,6 +16,9 @@ = _('A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}.').html_safe % { among_other_things_link: among_other_things_link } %p = _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.') + %p + - pages_getting_started_guide = link_to _('Pages getting started guide'), help_page_path("user/project/pages/getting_started_part_two", anchor: "fork-a-project-to-get-started-from"), target: '_blank' + = _('Information about additional Pages templates and how to install them can be found in our %{pages_getting_started_guide}.').html_safe % { pages_getting_started_guide: pages_getting_started_guide } .md = brand_new_project_guidelines %p @@ -33,7 +36,7 @@ %span.d-block.d-sm-none Blank %li.nav-item{ role: 'presentation' } %a.nav-link{ href: '#create-from-template-pane', id: 'create-from-template-tab', data: { toggle: 'tab', track_label: 'create_from_template', track_event: "click_tab" }, role: 'tab' } - %span.d-none.d-sm-block Create from template + %span.d-none.d-sm-block.qa-project-create-from-template-tab Create from template %span.d-block.d-sm-none Template %li.nav-item{ role: 'presentation' } %a.nav-link{ href: '#import-project-pane', id: 'import-project-tab', data: { toggle: 'tab', track_label: 'import_project', track_event: "click_tab" }, role: 'tab' } diff --git a/app/views/projects/pages/_destroy.haml b/app/views/projects/pages/_destroy.haml index ae8c801b705..138e2864bad 100644 --- a/app/views/projects/pages/_destroy.haml +++ b/app/views/projects/pages/_destroy.haml @@ -9,4 +9,4 @@ .form-actions = link_to 'Remove pages', project_pages_path(@project), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove" - else - .nothing-here-block Only the project owner can remove pages + .nothing-here-block Only project maintainers can remove pages diff --git a/app/views/projects/pages/_https_only.html.haml b/app/views/projects/pages/_https_only.html.haml index ce3ef29c32e..74478ee011c 100644 --- a/app/views/projects/pages/_https_only.html.haml +++ b/app/views/projects/pages/_https_only.html.haml @@ -3,7 +3,7 @@ .form-check = f.check_box :pages_https_only, class: 'form-check-input', disabled: pages_https_only_disabled? = f.label :pages_https_only, class: pages_https_only_label_class do - %strong Force domains with SSL certificates to use HTTPS + %strong Force HTTPS (requires valid certificates) - unless pages_https_only_disabled? .prepend-top-10 diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml index 0d848f7899c..b7b46c56c37 100644 --- a/app/views/projects/pages_domains/_form.html.haml +++ b/app/views/projects/pages_domains/_form.html.haml @@ -6,25 +6,24 @@ .form-group.row = f.label :domain, class: 'col-form-label col-sm-2' do - Domain + = _("Domain") .col-sm-10 = f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control', disabled: @domain.persisted? - if Gitlab.config.pages.external_https .form-group.row = f.label :certificate, class: 'col-form-label col-sm-2' do - Certificate (PEM) + = _("Certificate (PEM)") .col-sm-10 = f.text_area :certificate, rows: 5, class: 'form-control' - %span.help-inline Upload a certificate for your domain with all intermediates + %span.help-inline= _("Upload a certificate for your domain with all intermediates") .form-group.row = f.label :key, class: 'col-form-label col-sm-2' do - Key (PEM) + = _("Key (PEM)") .col-sm-10 = f.text_area :key, rows: 5, class: 'form-control' - %span.help-inline Upload a private key for your certificate + %span.help-inline= _("Upload a private key for your certificate") - else .nothing-here-block - Support for custom certificates is disabled. - Ask your system's administrator to enable it. + = _("Support for custom certificates is disabled. Ask your system's administrator to enable it.") diff --git a/app/views/projects/pages_domains/edit.html.haml b/app/views/projects/pages_domains/edit.html.haml index 342b1482df7..e11387ae742 100644 --- a/app/views/projects/pages_domains/edit.html.haml +++ b/app/views/projects/pages_domains/edit.html.haml @@ -1,4 +1,4 @@ -- add_to_breadcrumbs "Pages", project_pages_path(@project) +- add_to_breadcrumbs _("Pages"), project_pages_path(@project) - breadcrumb_title @domain.domain - page_title @domain.domain %h3.page-title @@ -8,4 +8,4 @@ = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f| = render 'form', { f: f } .form-actions - = f.submit 'Save Changes', class: "btn btn-success" + = f.submit _('Save Changes'), class: "btn btn-success" diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml index 94ad1470052..c7cefa87c76 100644 --- a/app/views/projects/pages_domains/new.html.haml +++ b/app/views/projects/pages_domains/new.html.haml @@ -1,12 +1,12 @@ -- add_to_breadcrumbs "Pages", project_pages_path(@project) -- page_title 'New Pages Domain' +- add_to_breadcrumbs _("Pages"), project_pages_path(@project) +- page_title _('New Pages Domain') %h3.page-title - New Pages Domain + = _("New Pages Domain") %hr.clearfix %div = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f| = render 'form', { f: f } .form-actions - = f.submit 'Create New Domain', class: "btn btn-success" + = f.submit _('Create New Domain'), class: "btn btn-success" .float-right = link_to _('Cancel'), project_pages_path(@project), class: 'btn btn-cancel' diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml index a8484187493..82147568981 100644 --- a/app/views/projects/pages_domains/show.html.haml +++ b/app/views/projects/pages_domains/show.html.haml @@ -1,6 +1,6 @@ -- add_to_breadcrumbs "Pages", project_pages_path(@project) +- add_to_breadcrumbs _("Pages"), project_pages_path(@project) - breadcrumb_title @domain.domain -- page_title "#{@domain.domain}", 'Pages Domains' +- page_title "#{@domain.domain}", _('Pages Domains') - dns_record = "#{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}." - verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? @@ -9,37 +9,37 @@ = content_for :flash_message do .alert.alert-warning .container-fluid.container-limited - This domain is not verified. You will need to verify ownership before access is enabled. + = _("This domain is not verified. You will need to verify ownership before access is enabled.") %h3.page-title.with-button - = link_to 'Edit', edit_project_pages_domain_path(@project, @domain), class: 'btn btn-success float-right' - Pages Domain + = link_to _('Edit'), edit_project_pages_domain_path(@project, @domain), class: 'btn btn-success float-right' + = _("Pages Domain") .table-holder %table.table %tr %td - Domain + = _("Domain") %td = link_to @domain.url do = @domain.url = icon('external-link') %tr %td - DNS + = _("DNS") %td .input-group = text_field_tag :domain_dns, dns_record , class: "monospace js-select-on-focus form-control", readonly: true .input-group-append = clipboard_button(target: '#domain_dns', class: 'btn-default input-group-text d-none d-sm-block') %p.form-text.text-muted - To access this domain create a new DNS record + = _("To access this domain create a new DNS record") - if verification_enabled - verification_record = "#{@domain.verification_domain} TXT #{@domain.keyed_verification_code}" %tr %td - Verification status + = _("Verification status") %td = form_tag verify_project_pages_domain_path(@project, @domain) do .status-badge @@ -53,17 +53,16 @@ .input-group-append = clipboard_button(target: '#domain_verification', class: 'btn-default d-none d-sm-block') %p.form-text.text-muted - - help_link = help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') - To #{link_to 'verify ownership', help_link} of your domain, - add the above key to a TXT record within to your DNS configuration. + - link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')) + = _("To %{link_to_help} of your domain, add the above key to a TXT record within to your DNS configuration.").html_safe % { link_to_help: link_to_help } %tr %td - Certificate + = _("Certificate") %td - if @domain.certificate_text %pre = @domain.certificate_text - else .light - missing + = _("missing") diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml index 259979417e0..1121cf06b5c 100644 --- a/app/views/projects/pipeline_schedules/_form.html.haml +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -34,7 +34,7 @@ = n_('Reveal value', 'Reveal values', @schedule.variables.size) .form-group.row .col-md-9 - = f.label :active, s_('PipelineSchedules|Activated'), class: 'label-bold' + = f.label :active, s_('PipelineSchedules|Activated'), class: 'label-bold' %div = f.check_box :active, required: false, value: @schedule.active? = _('Active') diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 0f0114d513c..55adeb345ab 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -6,25 +6,24 @@ = preserve(markdown(commit.description, pipeline: :single_line)) .info-well - - if commit.status - .well-segment.pipeline-info - .icon-container - = icon('clock-o') - = pluralize @pipeline.total_size, "job" - - if @pipeline.ref - from - - if @pipeline.ref_exists? - = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name" - - else - %span.ref-name - = @pipeline.ref - - if @pipeline.duration - in - = time_interval_in_words(@pipeline.duration) - - if @pipeline.queued_duration - = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" + .well-segment.pipeline-info + .icon-container + = icon('clock-o') + = pluralize @pipeline.total_size, "job" + - if @pipeline.ref + from + - if @pipeline.ref_exists? + = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name" + - else + %span.ref-name + = @pipeline.ref + - if @pipeline.duration + in + = time_interval_in_words(@pipeline.duration) + - if @pipeline.queued_duration + = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" - .well-segment + .well-segment.qa-pipeline-badges .icon-container = sprite_icon('flag') - if @pipeline.latest? @@ -49,7 +48,7 @@ content: "<a class='autodevops-link' href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>", } } Auto DevOps - - if @pipeline.merge_request? + - if @pipeline.merge_request_event? %span.js-pipeline-url-mergerequest.badge.badge-info.has-tooltip{ title: "This pipeline is run in a merge request context" } merge request - if @pipeline.stuck? diff --git a/app/views/projects/pipelines/charts/_pipeline_times.haml b/app/views/projects/pipelines/charts/_pipeline_times.haml index c23fe6ff170..c0ac79ed5f8 100644 --- a/app/views/projects/pipelines/charts/_pipeline_times.haml +++ b/app/views/projects/pipelines/charts/_pipeline_times.haml @@ -1,7 +1,7 @@ -%div - %p.light - = _("Commit duration in minutes for last 30 commits") +%p.light + = _("Commit duration in minutes for last 30 commits") +%div %canvas#build_timesChart{ height: 200 } -# haml-lint:disable InlineJavaScript diff --git a/app/views/projects/pipelines/charts/_pipelines.haml b/app/views/projects/pipelines/charts/_pipelines.haml index 14b3d47a9c2..47f1f074210 100644 --- a/app/views/projects/pipelines/charts/_pipelines.haml +++ b/app/views/projects/pipelines/charts/_pipelines.haml @@ -13,18 +13,21 @@ %p.light = _("Pipelines for last week") (#{date_from_to(Date.today - 7.days, Date.today)}) - %canvas#weekChart{ height: 200 } + %div + %canvas#weekChart{ height: 200 } .prepend-top-default %p.light = _("Pipelines for last month") (#{date_from_to(Date.today - 30.days, Date.today)}) - %canvas#monthChart{ height: 200 } + %div + %canvas#monthChart{ height: 200 } .prepend-top-default %p.light = _("Pipelines for last year") - %canvas#yearChart.padded{ height: 250 } + %div + %canvas#yearChart.padded{ height: 250 } -# haml-lint:disable InlineJavaScript %script#pipelinesChartsData{ type: "application/json" } diff --git a/app/views/projects/project_members/_groups.html.haml b/app/views/projects/project_members/_groups.html.haml index 3f05e06b0c6..b5d397e3065 100644 --- a/app/views/projects/project_members/_groups.html.haml +++ b/app/views/projects/project_members/_groups.html.haml @@ -1,7 +1,6 @@ .card.project-members-groups .card-header - Groups with access to - %strong= @project.name + = _("Groups with access to <strong>%{project_name}</strong>").html_safe % { project_name: sanitize_project_name(@project.name) } %span.badge.badge-pill= group_links.size %ul.content-list.members-list = render partial: 'shared/members/group', collection: group_links, as: :group_link diff --git a/app/views/projects/project_members/_new_project_group.html.haml b/app/views/projects/project_members/_new_project_group.html.haml index 88e68f89024..079811e4e79 100644 --- a/app/views/projects/project_members/_new_project_group.html.haml +++ b/app/views/projects/project_members/_new_project_group.html.haml @@ -10,8 +10,9 @@ = select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control" = icon('chevron-down') .form-text.text-muted.append-bottom-10 - = link_to _("Read more"), help_page_path("user/permissions") - about role permissions + - permissions_docs_path = help_page_path('user/permissions') + - link_start = %q{<a href="%{url}">}.html_safe % { url: permissions_docs_path } + = _("%{link_start}Read more%{link_end} about role permissions").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } .form-group = label_tag :expires_at, _('Access expiration date'), class: 'label-bold' .clearable-input diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml index 1de7d9c6957..0590578c3fe 100644 --- a/app/views/projects/project_members/_new_project_member.html.haml +++ b/app/views/projects/project_members/_new_project_member.html.haml @@ -2,20 +2,21 @@ .col-sm-12 = form_for @project_member, as: :project_member, url: project_project_members_path(@project), html: { class: 'users-project-form' } do |f| .form-group - = label_tag :user_ids, "Select members to invite", class: "label-bold" + = label_tag :user_ids, _("Select members to invite"), class: "label-bold" = users_select_tag(:user_ids, multiple: true, class: "input-clamp qa-member-select-input", scope: :all, email_user: true, placeholder: "Search for members to update or invite") .form-group - = label_tag :access_level, "Choose a role permission", class: "label-bold" + = label_tag :access_level, _("Choose a role permission"), class: "label-bold" .select-wrapper = select_tag :access_level, options_for_select(ProjectMember.access_level_roles, @project_member.access_level), class: "form-control project-access-select select-control" = icon('chevron-down') .form-text.text-muted.append-bottom-10 - = link_to "Read more", help_page_path("user/permissions") - about role permissions + - permissions_docs_path = help_page_path('user/permissions') + - link_start = %q{<a href="%{url}">}.html_safe % { url: permissions_docs_path } + = _("%{link_start}Read more%{link_end} about role permissions").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } .form-group .clearable-input - = label_tag :expires_at, 'Access expiration date', class: 'label-bold' + = label_tag :expires_at, _('Access expiration date'), class: 'label-bold' = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date' %i.clear-icon.js-clear-input - = f.submit "Add to project", class: "btn btn-success qa-add-member-button" - = link_to "Import", import_project_project_members_path(@project), class: "btn btn-default", title: "Import members from another project" + = f.submit _("Add to project"), class: "btn btn-success qa-add-member-button" + = link_to _("Import"), import_project_project_members_path(@project), class: "btn btn-default", title: _("Import members from another project") diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml index 9682f8ac922..e0dd386fc5d 100644 --- a/app/views/projects/project_members/_team.html.haml +++ b/app/views/projects/project_members/_team.html.haml @@ -4,14 +4,13 @@ .card .card-header.flex-project-members-panel %span.flex-project-title - Members of - %strong= project.name + = _("Members of <strong>%{project_name}</strong>").html_safe % { project_name: sanitize_project_name(project.name) } %span.badge.badge-pill= members.total_count - = form_tag project_project_members_path(project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do + = form_tag project_project_members_path(project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do .form-group .position-relative - = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } - %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" } + = search_field_tag :search, params[:search], { placeholder: _('Find existing members by name'), class: 'form-control', spellcheck: false } + %button.member-search-btn{ type: "submit", "aria-label" => _("Submit search") } = icon("search") = render 'shared/members/sort_dropdown' %ul.content-list.members-list.qa-members-list diff --git a/app/views/projects/project_members/import.html.haml b/app/views/projects/project_members/import.html.haml index 8b93e81cd31..bcca943de6a 100644 --- a/app/views/projects/project_members/import.html.haml +++ b/app/views/projects/project_members/import.html.haml @@ -1,15 +1,15 @@ -- page_title "Import members" +- page_title _("Import members") %h3.page-title - Import members from another project + = _("Import members from another project") %p.light - Only project members will be imported. Group members will be skipped. + = _("Only project members will be imported. Group members will be skipped.") %hr = form_tag apply_import_project_project_members_path(@project), method: 'post' do .form-group.row - = label_tag :source_project_id, "Project", class: 'col-form-label col-sm-2' + = label_tag :source_project_id, _("Project"), class: 'col-form-label col-sm-2' .col-sm-10= select_tag(:source_project_id, options_from_collection_for_select(@projects, :id, :name_with_namespace), prompt: "Select project", class: "select2 lg", required: true) .form-actions - = button_tag 'Import project members', class: "btn btn-success" - = link_to "Cancel", project_project_members_path(@project), class: "btn btn-cancel" + = button_tag _('Import project members'), class: "btn btn-success" + = link_to _("Cancel"), project_project_members_path(@project), class: "btn btn-cancel" diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 14ed3345765..242ff91f539 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -1,39 +1,34 @@ -- page_title "Members" +- page_title _("Members") .row.prepend-top-default .col-lg-12 %h4 - Project members + = _("Project members") - if can?(current_user, :admin_project_member, @project) %p - You can invite a new member to - %strong= @project.name - or invite another group. + = _("You can invite a new member to <strong>%{project_name}</strong> or invite another group.").html_safe % { project_name: sanitize_project_name(@project.name) } - else %p - Members can be added by project - %i Maintainers - or - %i Owners + = _("Members can be added by project <i>Maintainers</i> or <i>Owners</i>").html_safe .light - if can?(current_user, :admin_project_member, @project) %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } %li.nav-tab{ role: 'presentation' } - %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' } Invite member + %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member") - if @project.allowed_to_share_with_group? %li.nav-tab{ role: 'presentation' } - %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab' }, role: 'tab' } Invite group + %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite group") .tab-content.gitlab-tab-content .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } - = render 'projects/project_members/new_project_member', tab_title: 'Invite member' + = render 'projects/project_members/new_project_member', tab_title: _('Invite member') .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' } - = render 'projects/project_members/new_project_group', tab_title: 'Invite group' + = render 'projects/project_members/new_project_group', tab_title: _('Invite group') = render 'shared/members/requests', membership_source: @project, requesters: @requesters .clearfix %h5.member.existing-title - Existing members and groups + = _("Existing members and groups") - if @group_links.any? = render 'projects/project_members/groups', group_links: @group_links diff --git a/app/views/projects/project_templates/_built_in_templates.html.haml b/app/views/projects/project_templates/_built_in_templates.html.haml index 5b4d8927045..6159f1c3542 100644 --- a/app/views/projects/project_templates/_built_in_templates.html.haml +++ b/app/views/projects/project_templates/_built_in_templates.html.haml @@ -1,7 +1,7 @@ - Gitlab::ProjectTemplate.all.each do |template| .template-option.d-flex.align-items-center - .logo.append-right-10 - = custom_icon(template.logo, size: 40) + .logo.append-right-10.px-1 + = image_tag template.logo, size: 32, class: "btn-template-icon icon-#{template.name}" .description %strong = template.title @@ -12,6 +12,6 @@ %a.btn.btn-default.append-right-10{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "create_from_template", track_property: "template_preview", track_event: "click_button", track_value: template.name } } = _("Preview") %label.btn.btn-success.template-button.choose-template.append-bottom-0{ for: template.name } - %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "create_from_template", track_property: "template_use", track_event: "click_button" } } + %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "create_from_template", track_property: "template_use", track_event: "click_button" } } %span = _("Use template") diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml index 539b184e5c2..4997770321e 100644 --- a/app/views/projects/protected_branches/shared/_index.html.haml +++ b/app/views/projects/protected_branches/shared/_index.html.haml @@ -12,7 +12,7 @@ %p By default, protected branches are designed to: %ul - %li prevent their creation, if not already created, from everybody except Maintainers + %li prevent their creation, if not already created, from everybody except users who are allowed to merge %li prevent pushes from everybody except Maintainers %li prevent <strong>anyone</strong> from force pushing to the branch %li prevent <strong>anyone</strong> from deleting the branch diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_protected_branch.html.haml index 81b07af22ad..bb7998f739d 100644 --- a/app/views/projects/protected_branches/shared/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml @@ -15,7 +15,7 @@ = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha') = time_ago_with_tooltip(commit.committed_date) - else - (branch was removed from repository) + (branch was deleted from repository) = yield diff --git a/app/views/projects/releases/index.html.haml b/app/views/projects/releases/index.html.haml index f01d4e826b9..28bb4e032eb 100644 --- a/app/views/projects/releases/index.html.haml +++ b/app/views/projects/releases/index.html.haml @@ -2,4 +2,4 @@ - page_title _('Releases') %div{ class: container_class } - #js-releases-page{ data: { project_id: @project.id, illustration_path: image_path('illustrations/releases.svg'), documentation_path: help_page_path('user/project/releases') } } + #js-releases-page{ data: { project_id: @project.id, illustration_path: image_path('illustrations/releases.svg'), documentation_path: help_page_path('user/project/releases/index') } } diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml index a6c16c70313..a24ada53bac 100644 --- a/app/views/projects/runners/_group_runners.html.haml +++ b/app/views/projects/runners/_group_runners.html.haml @@ -32,6 +32,6 @@ - else %h4.underlined-title - = _('Available group Runners : %{runners}').html_safe % { runners: @group_runners.count } + = _('Available group Runners: %{runners}').html_safe % { runners: @group_runners.count } %ul.bordered-list = render partial: 'projects/runners/runner', collection: @group_runners, as: :runner diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml index f650fa0f38f..635580eac5c 100644 --- a/app/views/projects/serverless/functions/index.html.haml +++ b/app/views/projects/serverless/functions/index.html.haml @@ -5,7 +5,7 @@ - status_path = project_serverless_functions_path(@project, format: :json) - clusters_path = project_clusters_path(@project) -.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, installed: @installed, clusters_path: clusters_path, help_path: help_page_path('user/project/clusters/serverless/index') } } +.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, installed: @installed, clusters_path: clusters_path, help_path: help_page_path('user/project/clusters/serverless/index') } } %div{ class: [container_class, ('limit-container-width' unless fluid_layout)] } .js-serverless-functions-notice diff --git a/app/views/projects/serverless/functions/show.html.haml b/app/views/projects/serverless/functions/show.html.haml new file mode 100644 index 00000000000..29737b7014a --- /dev/null +++ b/app/views/projects/serverless/functions/show.html.haml @@ -0,0 +1,16 @@ +- @no_container = true +- @content_class = "limit-container-width" unless fluid_layout + +- add_to_breadcrumbs('Serverless', project_serverless_functions_path(@project)) + +- page_title @service[:name] + +.serverless-function-details-page.js-serverless-function-details-page{ data: { service: @service.as_json } } +%div{ class: [container_class, ('limit-container-width' unless fluid_layout)] } + .top-area.adjust + .serverless-function-details#js-serverless-function-details + + .js-serverless-function-notice + .flash-container + + .function-holder.js-function-holder.input-group diff --git a/app/views/projects/services/prometheus/_metrics.html.haml b/app/views/projects/services/prometheus/_metrics.html.haml index 597c029f755..a1d74b91002 100644 --- a/app/views/projects/services/prometheus/_metrics.html.haml +++ b/app/views/projects/services/prometheus/_metrics.html.haml @@ -1,6 +1,6 @@ - project = local_assigns.fetch(:project) -.card.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/metrics') } } +.card.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/index') } } .card-header = s_('PrometheusService|Common metrics') %span.badge.badge-pill.js-monitored-count 0 diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml index 1d0b0265bb7..6aafa85e99a 100644 --- a/app/views/projects/services/prometheus/_show.html.haml +++ b/app/views/projects/services/prometheus/_show.html.haml @@ -4,7 +4,9 @@ = s_('PrometheusService|Metrics') %p = s_('PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters.') - = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus_library/metrics'), target: '_blank', rel: "noopener noreferrer" + = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus_library/index'), target: '_blank', rel: "noopener noreferrer" .col-lg-9 = render 'projects/services/prometheus/metrics', project: @project + += render_if_exists 'projects/services/prometheus/external_alerts', project: @project diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml index 5ec5a06396e..8c4d1c32ebe 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -4,10 +4,6 @@ = form_errors(@project) %fieldset.builds-feature.js-auto-devops-settings .form-group - - message = auto_devops_warning_message(@project) - - if message - %p.auto-devops-warning-message.settings-message.text-center - = message.html_safe = f.fields_for :auto_devops_attributes, @auto_devops do |form| .card.auto-devops-card .card-body @@ -21,19 +17,12 @@ = s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.') = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank' .card-footer.js-extra-settings{ class: @project.auto_devops_enabled? || 'hidden' } - = form.label :domain do - %strong= _('Domain') - = form.text_field :domain, class: 'form-control', placeholder: 'domain.com' - .form-text.text-muted - = s_('CICD|You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages.') - - if cluster_ingress_ip = cluster_ingress_ip(@project) - = s_('%{nip_domain} can be used as an alternative to a custom domain.').html_safe % { nip_domain: "<code>#{cluster_ingress_ip}.nip.io</code>".html_safe } - = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-devops-base-domain'), target: '_blank' - + %p.settings-message.text-center + - kubernetes_cluster_link = help_page_path('user/project/clusters/index') + - kubernetes_cluster_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: kubernetes_cluster_link } + = s_('CICD|You must add a %{kubernetes_cluster_start}Kubernetes cluster integration%{kubernetes_cluster_end} to this project with a domain in order for your deployment strategy to work correctly.').html_safe % { kubernetes_cluster_start: kubernetes_cluster_start, kubernetes_cluster_end: '</a>'.html_safe } %label.prepend-top-10 %strong= s_('CICD|Deployment strategy') - %p.settings-message.text-center - = s_('CICD|Deployment strategy needs a domain name to work correctly.') .form-check = form.radio_button :deploy_strategy, 'continuous', class: 'form-check-input' = form.label :deploy_strategy_continuous, class: 'form-check-label' do diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index bb328f5344c..bfb275b9ef5 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -110,6 +110,9 @@ %li go test -cover (Go) %code coverage: \d+.\d+% of statements + %li + nyc npm test (NodeJS) - + %code All files[^|]*\|[^|]*\s+([\d\.]+) = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml new file mode 100644 index 00000000000..6b15331db01 --- /dev/null +++ b/app/views/projects/settings/operations/_error_tracking.html.haml @@ -0,0 +1,18 @@ +- return unless can?(current_user, :read_environment, @project) + +- setting = error_tracking_setting + +%section.settings.expanded.border-0.no-animate + .settings-header + %h4 + = _('Error Tracking') + %p + = _('To link Sentry to GitLab, enter your Sentry URL and Auth Token.') + = link_to _('More information'), help_page_path('user/project/operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer' + .settings-content + .js-error-tracking-form{ data: { list_projects_endpoint: list_projects_project_error_tracking_index_path(@project, format: :json), + operations_settings_endpoint: project_settings_operations_path(@project), + project: error_tracking_setting_project_json, + api_host: setting.api_host, + enabled: setting.enabled.to_json, + token: setting.token } } diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml new file mode 100644 index 00000000000..2822debe426 --- /dev/null +++ b/app/views/projects/settings/operations/show.html.haml @@ -0,0 +1,6 @@ +- @content_class = 'limit-container-width' unless fluid_layout +- page_title _('Operations Settings') +- breadcrumb_title _('Operations Settings') + += render 'projects/settings/operations/error_tracking', expanded: true += render_if_exists 'projects/settings/operations/tracing' diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml index a4974d89c1a..7682d01a5a1 100644 --- a/app/views/projects/snippets/index.html.haml +++ b/app/views/projects/snippets/index.html.haml @@ -1,12 +1,16 @@ - page_title _("Snippets") -- if current_user - .top-area - - include_private = @project.team.member?(current_user) || current_user.admin? - = render partial: 'snippets/snippets_scope_menu', locals: { subject: @project, include_private: include_private } +- if @snippets.exists? + - if current_user + .top-area + - include_private = @project.team.member?(current_user) || current_user.admin? + = render partial: 'snippets/snippets_scope_menu', locals: { subject: @project, include_private: include_private } - .nav-controls - if can?(current_user, :create_project_snippet, @project) - = link_to _("New snippet"), new_project_snippet_path(@project), class: "btn btn-success", title: _("New snippet") + .nav-controls + - if can?(current_user, :create_project_snippet, @project) + = link_to _("New snippet"), new_project_snippet_path(@project), class: "btn btn-success", title: _("New snippet") -= render 'snippets/snippets' + = render 'shared/snippets/list' +- else + = render 'shared/empty_states/snippets', button_path: new_namespace_project_snippet_path(@project.namespace, @project) diff --git a/app/views/projects/snippets/new.html.haml b/app/views/projects/snippets/new.html.haml index 26b333d4ecf..d64e3a49a81 100644 --- a/app/views/projects/snippets/new.html.haml +++ b/app/views/projects/snippets/new.html.haml @@ -1,8 +1,8 @@ - add_to_breadcrumbs _("Snippets"), project_snippets_path(@project) - breadcrumb_title _("New") -- page_title _("New Snippets") +- page_title _("New Snippet") %h3.page-title - = _('New Snippet') + = _("New Snippet") %hr = render "shared/snippets/form", url: project_snippets_path(@project, @snippet) diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index f55202c2c5f..cc203cfad86 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -28,7 +28,7 @@ = render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name] - if can?(current_user, :push_code, @project) - = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do + = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do = icon("pencil") - if can?(current_user, :admin_project, @project) diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index 026bc44a05f..458096f9dd6 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -27,7 +27,7 @@ - if can?(current_user, :push_code, @project) = link_to new_project_tag_path(@project), class: 'btn btn-success new-tag-btn' do = s_('TagsPage|New tag') - = link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn rss-btn has-tooltip' do + = link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn d-none d-sm-inline-block has-tooltip' do = icon("rss") = render_if_exists 'projects/commits/mirror_status' diff --git a/app/views/projects/tags/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml index 52c6c7ec424..e4efeed04f0 100644 --- a/app/views/projects/tags/releases/edit.html.haml +++ b/app/views/projects/tags/releases/edit.html.haml @@ -12,8 +12,7 @@ = form_for(@release, method: :put, url: project_tag_release_path(@project, @tag.name), - html: { class: 'common-note-form release-form js-quick-submit' }, - data: { markdown_version: @release.cached_markdown_version }) do |f| + html: { class: 'common-note-form release-form js-quick-submit' }) do |f| = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here…" = render 'shared/notes/hints' diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index 15a960f81b8..8ee1407d9d9 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -20,10 +20,10 @@ .nav-controls.controls-flex - if can?(current_user, :push_code, @project) - = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do + = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn btn-edit controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do = icon("pencil") = link_to project_tree_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Browse files') do - = icon('files-o') + = sprite_icon('folder-open') = link_to project_commits_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Browse commits') do = icon('history') .btn-container.controls-item diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 4e9a119ac66..ec8e5234bd4 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -40,27 +40,24 @@ #{ _('New directory') } - elsif can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project) %li - - continue_params = { to: project_new_blob_path(@project, @id), - notice: edit_in_new_fork_notice, + - continue_params = { to: project_new_blob_path(@project, @id), + notice: edit_in_new_fork_notice, notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) = link_to fork_path, method: :post do #{ _('New file') } %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to upload a file again.", + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to upload a file again.", notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) = link_to fork_path, method: :post do #{ _('Upload file') } %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to create a new directory again.", + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to create a new directory again.", notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) = link_to fork_path, method: :post do #{ _('New directory') } diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml index 7e4618e1a88..6f6f1e5e0c5 100644 --- a/app/views/projects/triggers/_trigger.html.haml +++ b/app/views/projects/triggers/_trigger.html.haml @@ -1,6 +1,6 @@ %tr %td - - if can?(current_user, :admin_trigger, trigger) + - if trigger.has_token_exposed? %span= trigger.token = clipboard_button(text: trigger.token, title: "Copy trigger token to clipboard") - else diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index 7d8826e540c..5bb69563b51 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -1,13 +1,9 @@ - commit_message = @page.persisted? ? s_("WikiPageEdit|Update %{page_title}") : s_("WikiPageCreate|Create %{page_title}") - commit_message = commit_message % { page_title: @page.title } -- if params[:legacy_render] || !commonmark_for_repositories_enabled? - - markdown_version = CacheMarkdownField::CACHE_REDCARPET_VERSION -- else - - markdown_version = 0 = form_for [@project.namespace.becomes(Namespace), @project, @page], method: @page.persisted? ? :put : :post, html: { class: 'wiki-form common-note-form prepend-top-default js-quick-submit' }, - data: { markdown_version: markdown_version, uploads_path: uploads_path } do |f| + data: { uploads_path: uploads_path } do |f| = form_errors(@page) - if @page.persisted? @@ -16,7 +12,7 @@ .form-group.row .col-sm-12= f.label :title, class: 'control-label-full-width' .col-sm-12 - = f.text_field :title, class: 'form-control', value: @page.title + = f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title - if @page.persisted? %span.edit-wiki-page-slug-tip = icon('lightbulb-o') @@ -31,7 +27,7 @@ .col-sm-12= f.label :content, class: 'control-label-full-width' .col-sm-12 = render layout: 'projects/md_preview', locals: { url: project_wiki_preview_markdown_path(@project, @page.slug) } do - = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: s_("WikiPage|Write your content or drag files here…") + = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea qa-wiki-content-textarea', placeholder: s_("WikiPage|Write your content or drag files here…") = render 'shared/notes/hints' .clearfix @@ -47,14 +43,14 @@ .form-group.row .col-sm-12= f.label :commit_message, class: 'control-label-full-width' - .col-sm-12= f.text_field :message, class: 'form-control', rows: 18, value: commit_message + .col-sm-12= f.text_field :message, class: 'form-control qa-wiki-message-textbox', rows: 18, value: commit_message .form-actions - if @page && @page.persisted? - = f.submit _("Save changes"), class: 'btn-success btn' + = f.submit _("Save changes"), class: 'btn-success btn qa-save-changes-button' .float-right = link_to _("Cancel"), project_wiki_path(@project, @page), class: 'btn btn-cancel btn-grouped' - else - = f.submit s_("Wiki|Create page"), class: 'btn-success btn' + = f.submit s_("Wiki|Create page"), class: 'btn-success btn qa-create-page-button' .float-right = link_to _("Cancel"), project_wiki_path(@project, :home), class: 'btn btn-cancel' diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/projects/wikis/_sidebar.html.haml index 02c5a6ea55c..28353927135 100644 --- a/app/views/projects/wikis/_sidebar.html.haml +++ b/app/views/projects/wikis/_sidebar.html.haml @@ -12,7 +12,7 @@ .blocks-container .block.block-first - if @sidebar_page - = render_wiki_content(@sidebar_page, legacy_render_context(params)) + = render_wiki_content(@sidebar_page) - else %ul.wiki-pages = render @sidebar_wiki_entries, context: 'sidebar' diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml index 26671a7b7d2..1277ea6c743 100644 --- a/app/views/projects/wikis/edit.html.haml +++ b/app/views/projects/wikis/edit.html.haml @@ -23,9 +23,6 @@ = s_("Wiki|Create Page") .nav-controls - - if can?(current_user, :create_wiki, @project) - = link_to '#modal-new-wiki', class: "add-new-wiki btn btn-success", "data-toggle" => "modal" do - = s_("Wiki|New page") - if @page.persisted? = link_to project_wiki_history_path(@project, @page), class: "btn" do = s_("Wiki|Page history") diff --git a/app/views/projects/wikis/pages.html.haml b/app/views/projects/wikis/pages.html.haml index aeef64fd7eb..94267b6e0cf 100644 --- a/app/views/projects/wikis/pages.html.haml +++ b/app/views/projects/wikis/pages.html.haml @@ -1,5 +1,5 @@ - @no_container = true -- add_to_breadcrumbs "Wiki", get_project_wiki_path(@project) +- add_to_breadcrumbs "Wiki", project_wiki_path(@project, :home) - breadcrumb_title s_("Wiki|Pages") - page_title s_("Wiki|Pages"), _("Wiki") diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml index 4d5fd55364c..8e1c054b50c 100644 --- a/app/views/projects/wikis/show.html.haml +++ b/app/views/projects/wikis/show.html.haml @@ -2,7 +2,7 @@ - breadcrumb_title @page.human_title - wiki_breadcrumb_dropdown_links(@page.slug) - page_title @page.human_title, _("Wiki") -- add_to_breadcrumbs _("Wiki"), get_project_wiki_path(@project) +- add_to_breadcrumbs _("Wiki"), project_wiki_path(@project, :home) .wiki-page-header.has-sidebar-toggle %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } @@ -27,6 +27,6 @@ .prepend-top-default.append-bottom-default .wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } - = render_wiki_content(@page, legacy_render_context(params)) + = render_wiki_content(@page) = render 'sidebar' diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index ff9a7b53a86..aaf9b973cda 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -6,75 +6,75 @@ - if project_search_tabs?(:blobs) %li{ class: active_when(@scope == 'blobs') } = link_to search_filter_path(scope: 'blobs') do - Code + = _("Code") %span.badge.badge-pill = @search_results.blobs_count - if project_search_tabs?(:issues) %li{ class: active_when(@scope == 'issues') } = link_to search_filter_path(scope: 'issues') do - Issues + = _("Issues") %span.badge.badge-pill = limited_count(@search_results.limited_issues_count) - if project_search_tabs?(:merge_requests) %li{ class: active_when(@scope == 'merge_requests') } = link_to search_filter_path(scope: 'merge_requests') do - Merge requests + = _("Merge requests") %span.badge.badge-pill = limited_count(@search_results.limited_merge_requests_count) - if project_search_tabs?(:milestones) %li{ class: active_when(@scope == 'milestones') } = link_to search_filter_path(scope: 'milestones') do - Milestones + = _("Milestones") %span.badge.badge-pill = limited_count(@search_results.limited_milestones_count) - if project_search_tabs?(:notes) %li{ class: active_when(@scope == 'notes') } = link_to search_filter_path(scope: 'notes') do - Comments + = _("Comments") %span.badge.badge-pill = limited_count(@search_results.limited_notes_count) - if project_search_tabs?(:wiki) %li{ class: active_when(@scope == 'wiki_blobs') } = link_to search_filter_path(scope: 'wiki_blobs') do - Wiki + = _("Wiki") %span.badge.badge-pill = @search_results.wiki_blobs_count - if project_search_tabs?(:commits) %li{ class: active_when(@scope == 'commits') } = link_to search_filter_path(scope: 'commits') do - Commits + = _("Commits") %span.badge.badge-pill = @search_results.commits_count - elsif @show_snippets %li{ class: active_when(@scope == 'snippet_blobs') } = link_to search_filter_path(scope: 'snippet_blobs', snippets: true, group_id: nil, project_id: nil) do - Snippet Contents + = _("Snippet Contents") %span.badge.badge-pill = @search_results.snippet_blobs_count %li{ class: active_when(@scope == 'snippet_titles') } = link_to search_filter_path(scope: 'snippet_titles', snippets: true, group_id: nil, project_id: nil) do - Titles and Filenames + = _("Titles and Filenames") %span.badge.badge-pill = @search_results.snippet_titles_count - else %li{ class: active_when(@scope == 'projects') } = link_to search_filter_path(scope: 'projects') do - Projects + = _("Projects") %span.badge.badge-pill = limited_count(@search_results.limited_projects_count) %li{ class: active_when(@scope == 'issues') } = link_to search_filter_path(scope: 'issues') do - Issues + = _("Issues") %span.badge.badge-pill = limited_count(@search_results.limited_issues_count) %li{ class: active_when(@scope == 'merge_requests') } = link_to search_filter_path(scope: 'merge_requests') do - Merge requests + = _("Merge requests") %span.badge.badge-pill = limited_count(@search_results.limited_merge_requests_count) %li{ class: active_when(@scope == 'milestones') } = link_to search_filter_path(scope: 'milestones') do - Milestones + = _("Milestones") %span.badge.badge-pill = limited_count(@search_results.limited_milestones_count) diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml index 6837f64f132..c8b6a3258ab 100644 --- a/app/views/search/_filter.html.haml +++ b/app/views/search/_filter.html.haml @@ -3,31 +3,31 @@ - if params[:project_id].present? = hidden_field_tag :project_id, params[:project_id] .dropdown - %button.dropdown-menu-toggle.js-search-group-dropdown{ type: "button", data: { toggle: "dropdown", default_label: "Group:", group_id: params[:group_id] } } + %button.dropdown-menu-toggle.js-search-group-dropdown{ type: "button", data: { toggle: "dropdown", default_label: _('Group:'), group_id: params[:group_id] } } %span.dropdown-toggle-text - Group: + = _("Group:") - if @group.present? = @group.name - else - Any + = _("Any") = icon("chevron-down") .dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-right - = dropdown_title("Filter results by group") - = dropdown_filter("Search groups") + = dropdown_title(_("Filter results by group")) + = dropdown_filter(_("Search groups")) = dropdown_content = dropdown_loading .dropdown.project-filter - %button.dropdown-menu-toggle.js-search-project-dropdown{ type: "button", data: { toggle: "dropdown", default_label: "Project:" } } + %button.dropdown-menu-toggle.js-search-project-dropdown{ type: "button", data: { toggle: "dropdown", default_label: _('Project:') } } %span.dropdown-toggle-text - Project: + = _("Project:") - if @project.present? = @project.full_name - else - Any + = _("Any") = icon("chevron-down") .dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-right - = dropdown_title("Filter results by project") - = dropdown_filter("Search projects") + = dropdown_title(_("Filter results by project")) + = dropdown_filter(_("Search projects")) = dropdown_content = dropdown_loading diff --git a/app/views/search/_form.html.haml b/app/views/search/_form.html.haml index a4a5cec1314..4af0c6bf84a 100644 --- a/app/views/search/_form.html.haml +++ b/app/views/search/_form.html.haml @@ -4,12 +4,12 @@ .search-holder .search-field-holder - = search_field_tag :search, params[:search], placeholder: "Search for projects, issues etc", class: "form-control search-text-input js-search-input", id: "dashboard_search", autofocus: true, spellcheck: false + = search_field_tag :search, params[:search], placeholder: _("Search for projects, issues, etc."), class: "form-control search-text-input js-search-input", id: "dashboard_search", autofocus: true, spellcheck: false = icon("search", class: "search-icon") %button.search-clear.js-search-clear{ class: ("hidden" if !params[:search].present?), type: "button", tabindex: "-1" } = icon("times-circle") %span.sr-only - Clear search + = _("Clear search") - unless params[:snippets].eql? 'true' = render 'filter' - = button_tag "Search", class: "btn btn-success btn-search" + = button_tag _("Search"), class: "btn btn-success btn-search" diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index ab56f48ba4d..be7a2436d16 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,4 +1,4 @@ -- if @search_objects.empty? +- if @search_objects.to_a.empty? = render partial: "search/results/empty" - else .row-content-block @@ -6,9 +6,11 @@ = search_entries_info(@search_objects, @scope, @search_term) - unless @show_snippets - if @project - in project #{link_to @project.full_name, [@project.namespace.becomes(Namespace), @project]} + - link_to_project = link_to(@project.full_name, [@project.namespace.becomes(Namespace), @project]) + = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project } - elsif @group - in group #{link_to @group.name, @group} + - link_to_group = link_to(@group.name, @group) + = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } .results.prepend-top-10 - if @scope == 'commits' diff --git a/app/views/search/results/_empty.html.haml b/app/views/search/results/_empty.html.haml index 821a39d61f5..9d15995bb51 100644 --- a/app/views/search/results/_empty.html.haml +++ b/app/views/search/results/_empty.html.haml @@ -2,5 +2,5 @@ .search_glyph %h4 = icon('search') - We couldn't find any results matching + = _("We couldn't find any results matching") %code= @search_term diff --git a/app/views/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml index c413c3d4abb..796782035f2 100644 --- a/app/views/search/results/_issue.html.haml +++ b/app/views/search/results/_issue.html.haml @@ -4,7 +4,7 @@ = link_to [issue.project.namespace.becomes(Namespace), issue.project, issue] do %span.term.str-truncated= issue.title - if issue.closed? - %span.badge.badge-danger.prepend-left-5 Closed + %span.badge.badge-danger.prepend-left-5= _("Closed") .float-right ##{issue.iid} - if issue.description.present? .description.term diff --git a/app/views/search/results/_merge_request.html.haml b/app/views/search/results/_merge_request.html.haml index 519176af108..f0e0af11f27 100644 --- a/app/views/search/results/_merge_request.html.haml +++ b/app/views/search/results/_merge_request.html.haml @@ -3,9 +3,9 @@ = link_to [merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request] do %span.term.str-truncated= merge_request.title - if merge_request.merged? - %span.badge.badge-primary.prepend-left-5 Merged + %span.badge.badge-primary.prepend-left-5= _("Merged") - elsif merge_request.closed? - %span.badge.badge-danger.prepend-left-5 Closed + %span.badge.badge-danger.prepend-left-5= _("Closed") .float-right= merge_request.to_reference - if merge_request.description.present? .description.term diff --git a/app/views/search/results/_note.html.haml b/app/views/search/results/_note.html.haml index e4ab7b0541f..6717939d034 100644 --- a/app/views/search/results/_note.html.haml +++ b/app/views/search/results/_note.html.haml @@ -6,14 +6,14 @@ %h5.note-search-caption.str-truncated %i.fa.fa-comment = link_to_member(project, note.author, avatar: false) - commented on - = link_to project.full_name, project + - link_to_project = link_to(project.full_name, project) + = _("commented on %{link_to_project}").html_safe % { link_to_project: link_to_project } · - if note.for_commit? - = link_to_if(noteable_identifier, "Commit #{truncate_sha(note.commit_id)}", note_url) do + = link_to_if(noteable_identifier, _("Commit %{commit_id}") % { commit_id: truncate_sha(note.commit_id) }, note_url) do = truncate_sha(note.commit_id) - %span.light Commit deleted + %span.light= _("Commit deleted") - else %span #{note.noteable_type.titleize} ##{noteable_identifier} diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml index b4ecd7bbae9..a60a4501557 100644 --- a/app/views/search/results/_snippet_blob.html.haml +++ b/app/views/search/results/_snippet_blob.html.haml @@ -21,10 +21,10 @@ .file-content.wiki - snippet_chunks.each do |chunk| - unless chunk[:data].empty? - = markup(snippet.file_name, chunk[:data], legacy_render_context(params)) + = markup(snippet.file_name, chunk[:data]) - else .file-content.code - .nothing-here-block Empty file + .nothing-here-block= _("Empty file") - else .file-content.code.js-syntax-highlight .line-numbers @@ -42,4 +42,4 @@ = highlight(snippet.file_name, chunk[:data]) - else .file-content.code - .nothing-here-block Empty file + .nothing-here-block= _("Empty file") diff --git a/app/views/search/results/_snippet_title.html.haml b/app/views/search/results/_snippet_title.html.haml index 9c8afb2165b..1e01088d9e6 100644 --- a/app/views/search/results/_snippet_title.html.haml +++ b/app/views/search/results/_snippet_title.html.haml @@ -5,7 +5,7 @@ - if snippet_title.private? %span.badge.badge-gray %i.fa.fa-lock - private + = _("private") %span.cgray.monospace.tiny.float-right.term = snippet_title.file_name diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml index 499697f2777..3260d05f509 100644 --- a/app/views/search/show.html.haml +++ b/app/views/search/show.html.haml @@ -1,5 +1,5 @@ - @hide_top_links = true -- breadcrumb_title "Search" +- breadcrumb_title _("Search") - page_title @search_term .prepend-top-default diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml index 7d3e243495f..ca392e1adfc 100644 --- a/app/views/sent_notifications/unsubscribe.html.haml +++ b/app/views/sent_notifications/unsubscribe.html.haml @@ -1,17 +1,16 @@ - noteable = @sent_notification.noteable - noteable_type = @sent_notification.noteable_type.titleize.downcase - noteable_text = %(#{noteable.title} (#{noteable.to_reference})) -- page_title "Unsubscribe", noteable_text, noteable_type.pluralize, @sent_notification.project.full_name +- page_title _("Unsubscribe"), noteable_text, noteable_type.pluralize, @sent_notification.project.full_name %h3.page-title - Unsubscribe from #{noteable_type} + = _("Unsubscribe from %{type}") % { type: noteable_type } %p - = succeed '?' do - Are you sure you want to unsubscribe from the #{noteable_type}: - = link_to noteable_text, url_for([@sent_notification.project.namespace.becomes(Namespace), @sent_notification.project, noteable]) + - link_to_noteable_text = link_to(noteable_text, url_for([@sent_notification.project.namespace.becomes(Namespace), @sent_notification.project, noteable])) + = _("Are you sure you want to unsubscribe from the %{type}: %{link_to_noteable_text}?").html_safe % { type: noteable_type, link_to_noteable_text: link_to_noteable_text } %p - = link_to 'Unsubscribe', unsubscribe_sent_notification_path(@sent_notification, force: true), + = link_to _('Unsubscribe'), unsubscribe_sent_notification_path(@sent_notification, force: true), class: 'btn btn-primary append-right-10' - = link_to 'Cancel', new_user_session_path, class: 'btn append-right-10' + = link_to _('Cancel'), new_user_session_path, class: 'btn append-right-10' diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml index 0d0a3c1aa64..755fd3a17d3 100644 --- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml +++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml @@ -1,5 +1,5 @@ -- if show_auto_devops_implicitly_enabled_banner?(project) - .auto-devops-implicitly-enabled-banner.alert.alert-warning +- if show_auto_devops_implicitly_enabled_banner?(project, current_user) + .qa-auto-devops-banner.auto-devops-implicitly-enabled-banner.alert.alert-warning - more_information_link = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link' - auto_devops_message = s_("AutoDevOps|The Auto DevOps pipeline has been enabled and will be used if no alternative CI configuration file is found. %{more_information_link}") % { more_information_link: more_information_link } = auto_devops_message.html_safe diff --git a/app/views/shared/_flash_user_callout.html.haml b/app/views/shared/_flash_user_callout.html.haml new file mode 100644 index 00000000000..fe175195e66 --- /dev/null +++ b/app/views/shared/_flash_user_callout.html.haml @@ -0,0 +1,11 @@ +- callout_data = { uid: "callout_feature_#{feature_name}_dismissed", feature_id: feature_name, dismiss_endpoint: user_callouts_path } +- extra_flash_class = local_assigns.fetch(:extra_flash_class, nil) + +.flash-container.flash-container-page.user-callout{ data: callout_data } + -# We currently only support `alert`, `warning`, `notice`, `success` + %div{ class: "flash-#{flash_type}" } + %div{ class: "#{(container_class unless fluid_layout)} #{(extra_flash_class unless @no_container)} #{@content_class}" } + %span= message + %button.btn.btn-default.close.js-close{ type: 'button', + 'aria-label' => _('Dismiss') } + = sprite_icon('close', css_class: 'dismiss-icon') diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml index 6cc8c485666..31a5370a5f8 100644 --- a/app/views/shared/_issuable_meta_data.html.haml +++ b/app/views/shared/_issuable_meta_data.html.haml @@ -1,4 +1,4 @@ -- note_count = @issuable_meta_data[issuable.id].notes_count +- note_count = @issuable_meta_data[issuable.id].user_notes_count - issue_votes = @issuable_meta_data[issuable.id] - upvotes, downvotes = issue_votes.upvotes, issue_votes.downvotes - issuable_url = @collection_type == "Issue" ? issue_path(issuable, anchor: 'notes') : merge_request_path(issuable, anchor: 'notes') diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml index 28b34e38b15..8607f87ce0b 100644 --- a/app/views/shared/_mini_pipeline_graph.html.haml +++ b/app/views/shared/_mini_pipeline_graph.html.haml @@ -7,7 +7,6 @@ .stage-container.dropdown{ class: klass } %button.mini-pipeline-graph-dropdown-toggle.has-tooltip.js-builds-dropdown-button{ class: "ci-status-icon-#{detailed_status.group}", type: 'button', data: { toggle: 'dropdown', title: "#{stage.name}: #{detailed_status.label}", placement: 'top', "stage-endpoint" => stage_ajax_project_pipeline_path(pipeline.project, pipeline, stage: stage.name) } } = sprite_icon(icon_status) - = icon('caret-down') %ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container %li.js-builds-dropdown-list.scrollable-menu diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml index 6e2527bd1a1..1e6b6f7c79b 100644 --- a/app/views/shared/_mobile_clone_panel.html.haml +++ b/app/views/shared/_mobile_clone_panel.html.haml @@ -13,3 +13,4 @@ - if http_enabled? %li = dropdown_item_with_description(http_copy_label, project.http_url_to_repo, href: project.http_url_to_repo, data: { clone_type: 'http' }) + = render_if_exists 'shared/mobile_kerberos_clone' diff --git a/app/views/shared/_personal_access_tokens_created_container.html.haml b/app/views/shared/_personal_access_tokens_created_container.html.haml index 3150d39b84a..a8d3de66418 100644 --- a/app/views/shared/_personal_access_tokens_created_container.html.haml +++ b/app/views/shared/_personal_access_tokens_created_container.html.haml @@ -6,7 +6,7 @@ = container_title .form-group .input-group - = text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: "form-control js-select-on-focus", 'aria-describedby' => "created-token-help-block" + = text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: "qa-created-personal-access-token form-control js-select-on-focus", 'aria-describedby' => "created-token-help-block" %span.input-group-append = clipboard_button(text: new_token_value, title: clipboard_button_title, placement: "left", class: "input-group-text btn-default btn-clipboard") %span#created-token-help-block.form-text.text-muted.text-danger Make sure you save it - you won't be able to access it again. diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml index f4df7bdcd83..0891b3459ec 100644 --- a/app/views/shared/_personal_access_tokens_form.html.haml +++ b/app/views/shared/_personal_access_tokens_form.html.haml @@ -12,7 +12,7 @@ .row .form-group.col-md-6 = f.label :name, class: 'label-bold' - = f.text_field :name, class: "form-control", required: true + = f.text_field :name, class: "form-control qa-personal-access-token-name-field", required: true .row .form-group.col-md-6 @@ -26,4 +26,4 @@ = render 'shared/tokens/scopes_form', prefix: 'personal_access_token', token: token, scopes: scopes .prepend-top-default - = f.submit "Create #{type} token", class: "btn btn-success" + = f.submit "Create #{type} token", class: "btn btn-success qa-create-token-button" diff --git a/app/views/shared/_personal_access_tokens_table.html.haml b/app/views/shared/_personal_access_tokens_table.html.haml index 2efd03d4867..49f3aae0f98 100644 --- a/app/views/shared/_personal_access_tokens_table.html.haml +++ b/app/views/shared/_personal_access_tokens_table.html.haml @@ -29,7 +29,7 @@ %span.token-never-expires-label Never %td= token.scopes.present? ? token.scopes.join(", ") : "<no scopes selected>" - path = impersonation ? revoke_admin_user_impersonation_token_path(token.user, token) : revoke_profile_personal_access_token_path(token) - %td= link_to "Revoke", path, method: :put, class: "btn btn-danger float-right", data: { confirm: "Are you sure you want to revoke this #{type} Token? This action cannot be undone." } + %td= link_to "Revoke", path, method: :put, class: "btn btn-danger float-right qa-revoke-button", data: { confirm: "Are you sure you want to revoke this #{type} Token? This action cannot be undone." } - else .settings-message.text-center This user has no active #{type} Tokens. diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index c6c5cadc3f5..307a0919a4c 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -1,6 +1,6 @@ .board{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }', ":data-id" => "list.id" } - .board-inner + .board-inner.d-flex.flex-column %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" } %h3.board-title.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset) }' } %i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable", diff --git a/app/views/shared/boards/components/_sidebar.html.haml b/app/views/shared/boards/components/_sidebar.html.haml index 1ff956649ed..c9ff63f8c45 100644 --- a/app/views/shared/boards/components/_sidebar.html.haml +++ b/app/views/shared/boards/components/_sidebar.html.haml @@ -1,4 +1,4 @@ -%board-sidebar{ "inline-template" => true, ":current-user" => (UserSerializer.new.represent(current_user) || {}).to_json } +%board-sidebar{ "inline-template" => true, ":current-user" => (UserSerializer.new.represent(current_user) || {}).to_json } %transition{ name: "boards-sidebar-slide" } %aside.right-sidebar.right-sidebar-expanded.issue-boards-sidebar{ "v-show" => "showSidebar" } .issuable-sidebar diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml index 913c065e188..eb7808573b9 100644 --- a/app/views/shared/deploy_keys/_form.html.haml +++ b/app/views/shared/deploy_keys/_form.html.haml @@ -15,7 +15,7 @@ %p.light Paste a machine public key here. Read more about how to generate it = link_to 'here', help_page_path('ssh/README') - = form.text_area :key, class: 'form-control thin_area', rows: 5 + = form.text_area :key, class: 'form-control thin-area', rows: 5 - else = form.label :fingerprint, class: 'col-form-label col-sm-2' .col-sm-10 diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index 0ddc56dc6c3..9173b802dd4 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -1,6 +1,11 @@ - button_path = local_assigns.fetch(:button_path, false) - project_select_button = local_assigns.fetch(:project_select_button, false) +- show_import_button = local_assigns.fetch(:show_import_button, false) && can?(current_user, :import_issues, @project) - has_button = button_path || project_select_button +- closed_issues_count = issuables_count_for_state(:issues, :closed) +- opened_issues_count = issuables_count_for_state(:issues, :opened) +- is_opened_state = params[:state] == 'opened' +- is_closed_state = params[:state] == 'closed' .row.empty-state .col-12 @@ -13,6 +18,20 @@ = _("Sorry, your filter produced no results") %p.text-center = _("To widen your search, change or remove filters above") + - if show_new_issue_link?(@project) + .text-center + = link_to _("New issue"), new_project_issue_path(@project), class: "btn btn-success", title: _("New issue"), id: "new_issue_body_link" + - elsif is_opened_state && opened_issues_count == 0 && closed_issues_count > 0 + %h4.text-center + = _("There are no open issues") + %p.text-center + = _("To keep this project going, create a new issue") + - if show_new_issue_link?(@project) + .text-center + = link_to _("New issue"), new_project_issue_path(@project), class: "btn btn-success", title: _("New issue"), id: "new_issue_body_link" + - elsif is_closed_state && opened_issues_count > 0 && closed_issues_count == 0 + %h4.text-center + = _("There are no closed issues") - elsif current_user %h4 = _("The Issue Tracker is the place to add things that need to be improved or solved in a project") @@ -21,12 +40,20 @@ - if has_button .text-center - if project_select_button - = render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue', type: :issues, with_feature_enabled: 'issues' + = render 'shared/new_project_item_select', path: 'issues/new', label: _('New issue'), type: :issues, with_feature_enabled: 'issues' - else - = link_to 'New issue', button_path, class: 'btn btn-success', title: 'New issue', id: 'new_issue_link' + = link_to _('New issue'), button_path, class: 'btn btn-success', title: _('New issue'), id: 'new_issue_link' + + - if show_import_button + = render 'projects/issues/import_csv/button', type: :text + - else %h4.text-center= _("There are no issues to show") %p = _("The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.") .text-center = link_to _('Register / Sign In'), new_user_session_path, class: 'btn btn-success' + +- if show_import_button + = render 'projects/issues/import_csv/modal' + diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml index bee26cd8312..a739103641e 100644 --- a/app/views/shared/empty_states/_labels.html.haml +++ b/app/views/shared/empty_states/_labels.html.haml @@ -1,6 +1,6 @@ .row.empty-state.labels .col-12 - .svg-content + .svg-content.qa-label-svg = image_tag 'illustrations/labels.svg' .col-12 .text-content diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml index 06ceb9738bc..be5b1c6b6ce 100644 --- a/app/views/shared/empty_states/_merge_requests.html.haml +++ b/app/views/shared/empty_states/_merge_requests.html.haml @@ -1,6 +1,11 @@ - button_path = local_assigns.fetch(:button_path, false) - project_select_button = local_assigns.fetch(:project_select_button, false) - has_button = button_path || project_select_button +- closed_merged_count = issuables_count_for_state(:merged, :closed) +- opened_merged_count = issuables_count_for_state(:merged, :opened) +- is_opened_state = params[:state] == 'opened' +- is_closed_state = params[:state] == 'closed' +- can_create_merge_request = merge_request_source_project_for_project(@project) .row.empty-state.merge-requests .col-12 @@ -13,6 +18,20 @@ = _("Sorry, your filter produced no results") %p.text-center = _("To widen your search, change or remove filters above") + .text-center + - if can_create_merge_request + = link_to _("New merge request"), project_new_merge_request_path(@project), class: "btn btn-success", title: _("New merge request"), id: "new_merge_request_body_link" + - elsif is_opened_state && opened_merged_count == 0 && closed_merged_count > 0 + %h4.text-center + = _("There are no open merge requests") + %p.text-center + = _("To keep this project going, create a new merge request") + .text-center + - if can_create_merge_request + = link_to _("New merge request"), project_new_merge_request_path(@project), class: "btn btn-success", title: _("New merge request"), id: "new_merge_request_body_link" + - elsif is_closed_state && opened_merged_count > 0 && closed_merged_count == 0 + %h4.text-center + = _("There are no closed merge requests") - else %h4 = _("Merge requests are a place to propose changes you've made to a project and discuss those changes with others") diff --git a/app/views/shared/empty_states/_priority_labels.html.haml b/app/views/shared/empty_states/_priority_labels.html.haml index 555cb4f4af9..bba3475d244 100644 --- a/app/views/shared/empty_states/_priority_labels.html.haml +++ b/app/views/shared/empty_states/_priority_labels.html.haml @@ -1,4 +1,4 @@ .text-center - .svg-content + .svg-content.qa-label-svg = image_tag 'illustrations/priority_labels.svg' %p Star labels to start sorting by priority diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml new file mode 100644 index 00000000000..6da40e1b059 --- /dev/null +++ b/app/views/shared/empty_states/_profile_tabs.html.haml @@ -0,0 +1,19 @@ +- current_user_empty_message_description = local_assigns.fetch(:current_user_empty_message_description, nil) +- secondary_button_link = local_assigns.fetch(:secondary_button_link, nil) + +.nothing-here-block + .svg-content + = image_tag illustration_path, size: '75' + .text-content + - if user_profile? and current_user.present? and current_user.username == params[:username] + %h5= current_user_empty_message_header + + - if current_user_empty_message_description.present? + %p= current_user_empty_message_description + + - if secondary_button_link.present? + = link_to secondary_button_label, secondary_button_link, class: 'btn btn-create btn-inverted' + + = link_to primary_button_label, primary_button_link, class: 'btn btn-success' + - else + %h5= visitor_empty_message diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml new file mode 100644 index 00000000000..a1a16b9d067 --- /dev/null +++ b/app/views/shared/empty_states/_snippets.html.haml @@ -0,0 +1,20 @@ +- button_path = local_assigns.fetch(:button_path, false) + +.row.empty-state + .col-12 + .svg-content + = image_tag 'illustrations/snippets_empty.svg' + .text-content + - if current_user + %h4 + = s_('SnippetsEmptyState|Snippets are small pieces of code or notes that you want to keep.') + %p + = s_('SnippetsEmptyState|They can be either public or private.') + .text-center + = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn btn-success', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link' + - unless current_page?(dashboard_snippets_path) + = link_to s_('SnippetsEmptyState|Explore public snippets'), explore_snippets_path, class: 'btn btn-default', title: s_('SnippetsEmptyState|Explore public snippets') + - else + %h4.text-center= s_('SnippetsEmptyState|There are no snippets to show.') + + diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml index df3308abe0d..73eedcc1dc9 100644 --- a/app/views/shared/empty_states/_wikis.html.haml +++ b/app/views/shared/empty_states/_wikis.html.haml @@ -2,7 +2,7 @@ - if can?(current_user, :create_wiki, @project) - create_path = project_wiki_path(@project, params[:id], { view: 'create' }) - - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn btn-success', title: s_('WikiEmpty|Create your first page') + - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn btn-success qa-create-first-page-link', title: s_('WikiEmpty|Create your first page') = render layout: layout_path, locals: { image_path: 'illustrations/wiki_login_empty.svg' } do %h4.text-left diff --git a/app/views/shared/empty_states/_wikis_layout.html.haml b/app/views/shared/empty_states/_wikis_layout.html.haml index a5f100e3469..d44017299b8 100644 --- a/app/views/shared/empty_states/_wikis_layout.html.haml +++ b/app/views/shared/empty_states/_wikis_layout.html.haml @@ -1,6 +1,6 @@ .row.empty-state .col-12 - .svg-content + .svg-content.qa-svg-content = image_tag image_path .col-12 .text-content.text-center diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index a1b901aaffa..609b8dce21a 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -14,7 +14,7 @@ %span.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(group) } = visibility_level_icon(group.visibility_level, fw: false) - .avatar-container.s40 + .avatar-container.rect-avatar.s40 = link_to group do = group_icon(group, class: "avatar s40 d-none d-sm-block") .title diff --git a/app/views/shared/groups/_list.html.haml b/app/views/shared/groups/_list.html.haml index f50a6bd4d6a..c5b39c7db08 100644 --- a/app/views/shared/groups/_list.html.haml +++ b/app/views/shared/groups/_list.html.haml @@ -1,3 +1,10 @@ +- illustration_path = 'illustrations/profile-page/groups.svg' +- current_user_empty_message_header = s_('UserProfile|You can create a group for several dependent projects.') +- current_user_empty_message_description = s_('UserProfile|Groups are the best way to manage projects and members.') +- primary_button_label = _('New group') +- primary_button_link = new_group_path +- visitor_empty_message = s_('GroupsEmptyState|No groups found') + - if groups.any? - user = local_assigns[:user] @@ -5,4 +12,9 @@ - groups.each_with_index do |group, i| = render "shared/groups/group", group: group, user: user - else - .nothing-here-block= s_("GroupsEmptyState|No groups found") + = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path, + current_user_empty_message_header: current_user_empty_message_header, + current_user_empty_message_description: current_user_empty_message_description, + primary_button_label: primary_button_label, + primary_button_link: primary_button_link, + visitor_empty_message: visitor_empty_message } diff --git a/app/views/shared/icons/_express.svg b/app/views/shared/icons/_express.svg deleted file mode 100644 index a51e81e5568..00000000000 --- a/app/views/shared/icons/_express.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="27" height="32" viewBox="0 0 27 32" class="btn-template-icon icon-node-express"><g fill="none" fill-rule="evenodd"><path d="M-3 0h32v32H-3z"/><path fill="#353535" d="M1.192 16.267c.04 2.065.288 3.982.745 5.75.456 1.767 1.16 3.307 2.115 4.618.953 1.31 2.185 2.343 3.694 3.098 1.51.755 3.357 1.132 5.54 1.132 3.22 0 5.89-.844 8.016-2.532 2.125-1.69 3.446-4.22 3.962-7.597h1.192c-.437 3.575-1.847 6.345-4.23 8.312-2.384 1.966-5.324 2.95-8.82 2.95-2.383.04-4.42-.338-6.107-1.133-1.69-.794-3.07-1.917-4.142-3.367-1.073-1.45-1.867-3.158-2.383-5.124C.258 20.408 0 18.294 0 16.028c0-2.542.377-4.806 1.132-6.792C1.887 7.25 2.88 5.57 4.112 4.2 5.34 2.83 6.77 1.79 8.4 1.074 10.03.358 11.698 0 13.406 0c2.383 0 4.44.457 6.167 1.37 1.728.914 3.138 2.126 4.23 3.635 1.093 1.51 1.887 3.238 2.384 5.184.496 1.945.705 3.97.625 6.077H1.193zm24.43-1.192c0-1.867-.26-3.645-.775-5.333-.516-1.688-1.28-3.168-2.294-4.44-1.013-1.27-2.274-2.273-3.784-3.008-1.51-.735-3.258-1.102-5.244-1.102-1.67 0-3.228.317-4.678.953-1.45.636-2.72 1.56-3.813 2.77-1.092 1.212-1.976 2.672-2.652 4.38-.675 1.708-1.072 3.635-1.19 5.78h24.43z"/></g></svg> diff --git a/app/views/shared/icons/_rails.svg b/app/views/shared/icons/_rails.svg deleted file mode 100644 index 852bd183cc7..00000000000 --- a/app/views/shared/icons/_rails.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="32" height="20" viewBox="0 0 32 20" class="btn-template-icon icon-rails"><g fill="none" fill-rule="evenodd"><path d="M0-6h32v32H0z"/><path fill="#c00" fill-rule="nonzero" d="M.985 19.636s.422-4.163 3.375-9.087c2.954-4.924 7.99-8.65 12.083-9.017 8.144-.816 15.46 6.485 15.46 6.485s-.24.168-.494.38C23.42 2.49 18.54 5.274 17.005 6.02c-7.033 3.925-4.91 13.616-4.91 13.616H.987zM24.137 2.32c-.45-.182-.9-.35-1.364-.505l.056-.93c.885.254 1.237.423 1.363.493l-.056.943zM22.8 5.304c.45.028.915.084 1.393.183l-.056.872c-.464-.1-.928-.155-1.392-.17l.056-.885zM17.597.913c-.407 0-.815.015-1.223.058l-.268-.83c.465-.056.915-.084 1.35-.084l.282.858h-.14zm.676 5.178c.35-.154.76-.31 1.237-.45l.31.93c-.41.125-.817.294-1.225.49l-.323-.97zm-6.386-3.7c-.366.184-.718.395-1.083.62l-.647-.985c.38-.225.745-.42 1.097-.604l.633.97zm2.883 6.33c.252-.323.548-.646.87-.942l.634.957c-.31.323-.59.647-.83 1L14.77 8.72zm-2.04 4.53c.112-.506.24-1.027.422-1.547l1.012.802c-.14.548-.24 1.097-.295 1.645l-1.14-.9zM6.57 6.57c-.34.35-.662.73-.958 1.11L4.53 6.752c.323-.352.674-.704 1.04-1.055l1 .872zm-4.25 6.286c-.224.52-.52 1.21-.702 1.688L0 13.954c.14-.38.436-1.084.703-1.69l1.618.592zm10.2 3.967l1.518.548c.084.663.21 1.28.337 1.83l-1.688-.605c-.07-.422-.14-1.027-.168-1.772z"/></g></svg> diff --git a/app/views/shared/icons/_spring.svg b/app/views/shared/icons/_spring.svg deleted file mode 100644 index ccf18749029..00000000000 --- a/app/views/shared/icons/_spring.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" class="btn-template-icon icon-java-spring"><g fill="none" fill-rule="evenodd"><path d="M0 0h32v32H0z"/><path fill="#70AD51" d="M5.466 27.993c.586.473 1.446.385 1.918-.202.475-.585.386-1.445-.2-1.92-.585-.474-1.444-.383-1.92.202-.45.555-.392 1.356.115 1.844l-.266-.234C1.972 24.762 0 20.597 0 15.978 0 7.168 7.168 0 15.98 0c4.48 0 8.53 1.857 11.435 4.836.66-.898 1.232-1.902 1.7-3.015 2.036 6.118 3.233 11.26 2.795 15.31-.592 8.274-7.508 14.83-15.93 14.83-3.912 0-7.496-1.416-10.276-3.757l-.238-.21zm23.58-4.982c4.01-5.336 1.775-13.965-.085-19.48-1.657 3.453-5.738 6.094-9.262 6.93-3.303.788-6.226.142-9.283 1.318-6.97 2.68-6.86 10.992-3.02 12.86.002 0 .23.124.227.12 0-.002 5.644-1.122 8.764-2.274 4.56-1.684 9.566-5.835 11.213-10.657-.877 5.015-5.182 9.84-9.507 12.056-2.302 1.182-4.092 1.445-7.88 2.756-.464.158-.828.314-.828.314.96-.16 1.917-.212 1.917-.212 5.393-.255 13.807 1.516 17.745-3.73z"/></g></svg> diff --git a/app/views/shared/issuable/_board_create_list_dropdown.html.haml b/app/views/shared/issuable/_board_create_list_dropdown.html.haml index 4597d9439fa..fd413bd68c8 100644 --- a/app/views/shared/issuable/_board_create_list_dropdown.html.haml +++ b/app/views/shared/issuable/_board_create_list_dropdown.html.haml @@ -1,7 +1,7 @@ .dropdown.prepend-left-10#js-add-list %button.btn.btn-success.btn-inverted.js-new-board-list{ type: "button", data: board_list_data } Add list - .dropdown-menu.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels + .dropdown-menu.dropdown-extended-height.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" } - if can?(current_user, :admin_label, board.parent) = render partial: "shared/issuable/label_page_create" diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml index ca02424215c..909eb738f95 100644 --- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml +++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml @@ -2,7 +2,7 @@ %aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? } } .issuable-sidebar.hidden - = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: "bulk-update" do + = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: "bulk-update" do .block.issuable-sidebar-header .filter-item.inline.update-issues-btn.float-left = button_tag "Update all", class: "btn update-selected-issues btn-info", disabled: true @@ -50,4 +50,3 @@ = hidden_field_tag "update[issuable_ids]", [] = hidden_field_tag :state_event, params[:state_event] - diff --git a/app/views/shared/issuable/_feed_buttons.html.haml b/app/views/shared/issuable/_feed_buttons.html.haml index d4834090413..83f60fa6fe2 100644 --- a/app/views/shared/issuable/_feed_buttons.html.haml +++ b/app/views/shared/issuable/_feed_buttons.html.haml @@ -1,4 +1,4 @@ -= link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe to RSS feed' do += link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: _('Subscribe to RSS feed') do = icon('rss') -= link_to safe_params.merge(calendar_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe to calendar' do += link_to safe_params.merge(calendar_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar') do = custom_icon('icon_calendar') diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml deleted file mode 100644 index 2ca4657851c..00000000000 --- a/app/views/shared/issuable/_filter.html.haml +++ /dev/null @@ -1,32 +0,0 @@ -.issues-filters - .issues-details-filters.row-content-block.second-block - = form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do - - if params[:search].present? - = hidden_field_tag :search, params[:search] - .issues-other-filters - .filter-item.inline - - if params[:author_id].present? - = hidden_field_tag(:author_id, params[:author_id]) - = dropdown_tag(user_dropdown_label(params[:author_id], "Author"), options: { toggle_class: "js-user-search js-filter-submit js-author-search", title: "Filter by author", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit", - placeholder: "Search authors", data: { any_user: "Any Author", first_user: current_user&.username, current_user: true, project_id: @project&.id, group_id: @group&.id, selected: params[:author_id], field_name: "author_id", default_label: "Author" } }) - - .filter-item.inline - - if params[:assignee_id].present? - = hidden_field_tag(:assignee_id, params[:assignee_id]) - = dropdown_tag(user_dropdown_label(params[:assignee_id], "Assignee"), options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", - placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: current_user&.username, null_user: true, current_user: true, project_id: @project&.id, group_id: @group&.id, selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } }) - - .filter-item.inline.milestone-filter - = render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true, show_started: true - - .filter-item.inline.labels-filter - = render "shared/issuable/label_dropdown", selected: selected_labels, use_id: false, selected_toggle: params[:label_name], data_options: { field_name: "label_name[]" } - - - unless @no_filters_set - .float-right - = render 'shared/issuable/sort_dropdown' - - - has_labels = @labels && @labels.any? - .row-content-block.second-block.filtered-labels{ class: ("hidden" unless has_labels) } - - if has_labels - = render 'shared/labels_row', labels: @labels diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 46634693067..f43be304e6b 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -11,7 +11,7 @@ - if params[:search].present? = hidden_field_tag :search, params[:search] - if @can_bulk_update - .check-all-holder.hidden + .check-all-holder.d-none.d-sm-block.hidden = check_box_tag "check-all-issues", nil, false, class: "check-all-issues left" .issues-other-filters.filtered-search-wrapper .filtered-search-box @@ -57,10 +57,10 @@ avatar: { lazy: true, url: '{{avatar_url}}' } #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'none' } } + %li.filter-dropdown-item{ data: { value: 'None' } } %button.btn.btn-link{ type: 'button' } = _('None') - %li.filter-dropdown-item{ data: { value: 'any' } } + %li.filter-dropdown-item{ data: { value: 'Any' } } %button.btn.btn-link{ type: 'button' } = _('Any') %li.divider.droplab-item-ignore @@ -71,18 +71,19 @@ = render 'shared/issuable/user_dropdown_item', user: User.new(username: '{{username}}', name: '{{name}}'), avatar: { lazy: true, url: '{{avatar_url}}' } + = render_if_exists 'shared/issuable/approver_dropdown' #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'none' } } + %li.filter-dropdown-item{ data: { value: 'None' } } %button.btn.btn-link{ type: 'button' } = _('None') - %li.filter-dropdown-item{ data: { value: 'any' } } + %li.filter-dropdown-item{ data: { value: 'Any' } } %button.btn.btn-link{ type: 'button' } = _('Any') - %li.filter-dropdown-item{ data: { value: 'upcoming' } } + %li.filter-dropdown-item{ data: { value: 'Upcoming' } } %button.btn.btn-link{ type: 'button' } = _('Upcoming') - %li.filter-dropdown-item{ data: { value: 'started' } } + %li.filter-dropdown-item{ data: { value: 'Started' } } %button.btn.btn-link{ type: 'button' } = _('Started') %li.divider.droplab-item-ignore @@ -92,10 +93,10 @@ {{title}} #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'none' } } + %li.filter-dropdown-item{ data: { value: 'None' } } %button.btn.btn-link{ type: 'button' } = _('None') - %li.filter-dropdown-item{ data: { value: 'any' } } + %li.filter-dropdown-item{ data: { value: 'Any' } } %button.btn.btn-link{ type: 'button' } = _('Any') %li.divider.droplab-item-ignore @@ -107,10 +108,10 @@ {{title}} #js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'none' } } + %li.filter-dropdown-item{ data: { value: 'None' } } %button.btn.btn-link{ type: 'button' } = _('None') - %li.filter-dropdown-item{ data: { value: 'any' } } + %li.filter-dropdown-item{ data: { value: 'Any' } } %button.btn.btn-link{ type: 'button' } = _('Any') %li.divider.droplab-item-ignore @@ -128,6 +129,14 @@ %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } %button.btn.btn-link{ type: 'button' } = _('No') + #js-dropdown-confidential.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } } + %button.btn.btn-link{ type: 'button' } + = _('Yes') + %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } + %button.btn.btn-link{ type: 'button' } + = _('No') = render_if_exists 'shared/issuable/filter_weight', type: type diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 0520eda37a4..9596c1df20e 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -133,7 +133,7 @@ #js-confidential-entry-point -# haml-lint:disable InlineJavaScript - %script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable_sidebar[:discussion_locked], is_editable: can_edit_issuable }.to_json.html_safe + %script#js-lock-issue-data{ type: "application/json" }= { is_locked: !!issuable_sidebar[:discussion_locked], is_editable: can_edit_issuable }.to_json.html_safe #js-lock-entry-point .js-sidebar-participants-entry-point diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index c5cce1823f0..1a59055f652 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -25,7 +25,7 @@ .value.hide-collapsed - if issuable_sidebar[:assignee] = link_to_member(@project, assignee, size: 32, extra_class: 'bold') do - - if issuable_sidebar[:assignee][:can_merge] + - unless issuable_sidebar[:assignee][:can_merge] %span.float-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: _('Not allowed to merge') } = icon('exclamation-triangle', 'aria-hidden': 'true') %span.username diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml index b6ea9185b10..967f31c8325 100644 --- a/app/views/shared/issuable/_sort_dropdown.html.haml +++ b/app/views/shared/issuable/_sort_dropdown.html.haml @@ -5,7 +5,7 @@ .dropdown.inline.prepend-left-10.issue-sort-dropdown .btn-group{ role: 'group' } .btn-group{ role: 'group' } - %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } + %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } = sort_title = icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index 1881875b7c0..f0c4acdd07f 100644 --- a/app/views/shared/issuable/form/_merge_params.html.haml +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -10,7 +10,7 @@ = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch?, class: 'form-check-input' = label_tag 'merge_request[force_remove_source_branch]', class: 'form-check-label' do - Remove source branch when merge request is accepted. + Delete source branch when merge request is accepted. .form-group.row .col-sm-10.offset-sm-2 @@ -19,4 +19,4 @@ = check_box_tag 'merge_request[squash]', '1', issuable.squash, class: 'form-check-input' = label_tag 'merge_request[squash]', class: 'form-check-label' do Squash commits when merge request is accepted. - = link_to 'About this feature', help_page_path('user/project/merge_requests/squash_and_merge') + = link_to icon('question-circle'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank' diff --git a/app/views/shared/labels/_nav.html.haml b/app/views/shared/labels/_nav.html.haml index 98572db738b..e69246dd0eb 100644 --- a/app/views/shared/labels/_nav.html.haml +++ b/app/views/shared/labels/_nav.html.haml @@ -18,3 +18,7 @@ %button.btn.btn-default{ type: "submit", "aria-label" => _('Submit search') } = icon("search") = render 'shared/labels/sort_dropdown' + - if labels_or_filters && can_admin_label && @project + = link_to _('New label'), new_project_label_path(@project), class: "btn btn-success qa-label-create-new" + - if labels_or_filters && can_admin_label && @group + = link_to _('New label'), new_group_label_path(@group), class: "btn btn-success qa-label-create-new" diff --git a/app/views/shared/members/_access_request_buttons.html.haml b/app/views/shared/members/_access_request_buttons.html.haml deleted file mode 100644 index ebae58f28ba..00000000000 --- a/app/views/shared/members/_access_request_buttons.html.haml +++ /dev/null @@ -1,20 +0,0 @@ -- model_name = source.model_name.to_s.downcase - -- if can?(current_user, :"destroy_#{model_name}_member", source.members.find_by(user_id: current_user.id)) # rubocop: disable CodeReuse/ActiveRecord - .project-action-button.inline - - link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project') - = link_to link_text, polymorphic_path([:leave, source, :members]), - method: :delete, - data: { confirm: leave_confirmation_message(source) }, - class: 'btn' -- elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord - .project-action-button.inline - = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]), - method: :delete, - data: { confirm: remove_member_message(requester) }, - class: 'btn' -- elsif source.request_access_enabled && can?(current_user, :request_access, source) - .project-action-button.inline - = link_to _('Request Access'), polymorphic_path([:request_access, source, :members]), - method: :post, - class: 'btn' diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 6b3841ebbc4..2db1f67a793 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -10,7 +10,7 @@ - if user = image_tag avatar_icon_for_user(user, 40), class: "avatar s40", alt: '' .user-info - = link_to user.name, user_path(user), class: 'member' + = link_to user.name, user_path(user), class: 'member js-user-link', data: { user_id: user.id } = user_status(user) %span.cgray= user.to_reference diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml index 922805958a5..4de89d7c7a0 100644 --- a/app/views/shared/milestones/_form_dates.html.haml +++ b/app/views/shared/milestones/_form_dates.html.haml @@ -1,11 +1,13 @@ .col-md-6 .form-group.row - = f.label :start_date, "Start Date", class: "col-form-label col-sm-2" + .col-form-label.col-sm-2 + = f.label :start_date, "Start Date" .col-sm-10 = f.text_field :start_date, class: "datepicker form-control", placeholder: "Select start date", autocomplete: 'off' %a.inline.float-right.prepend-top-5.js-clear-start-date{ href: "#" } Clear start date .form-group.row - = f.label :due_date, "Due Date", class: "col-form-label col-sm-2" + .col-form-label.col-sm-2 + = f.label :due_date, "Due Date" .col-sm-10 = f.text_field :due_date, class: "datepicker form-control", placeholder: "Select due date", autocomplete: 'off' %a.inline.float-right.prepend-top-5.js-clear-due-date{ href: "#" } Clear due date diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index ed7fefba56d..e75f0a184ea 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -1,5 +1,5 @@ - dashboard = local_assigns[:dashboard] -- custom_dom_id = dom_id(milestone.try(:milestones) ? milestone.milestones.first : milestone) +- custom_dom_id = dom_id(milestone.try(:milestone) ? milestone.milestone : milestone) - milestone_type = milestone.group_milestone? ? 'Group Milestone' : 'Project Milestone' %li{ class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: custom_dom_id } @@ -21,16 +21,18 @@ = milestone.group.full_name - if milestone.legacy_group_milestone? .projects - - milestone.milestones.each do |milestone| - = link_to milestone_path(milestone) do - %span.label-badge.label-badge-blue.d-inline-block.append-bottom-5 - = dashboard ? milestone.project.full_name : milestone.project.name + - link_to milestone_path(milestone.milestone) do + %span.label-badge.label-badge-blue.d-inline-block.append-bottom-5 + = dashboard ? milestone.project.full_name : milestone.project.name + - if milestone.project + .label-badge.label-badge-gray.d-inline-block + = milestone.project.full_name .col-sm-4.milestone-progress = milestone_progress_bar(milestone) = link_to pluralize(milestone.total_issues_count(current_user), 'Issue'), issues_path · - = link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path + = link_to pluralize(milestone.merge_requests_visible_to_user(current_user).size, 'Merge Request'), merge_requests_path .float-lg-right.light #{milestone.percent_complete(current_user)}% complete .col-sm-2 .milestone-actions.d-flex.justify-content-sm-start.justify-content-md-end @@ -58,5 +60,5 @@ - else = link_to 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-sm btn-grouped btn-close" - if dashboard - .status-box.status-box-milestone + .label-badge.label-badge-gray = milestone_type diff --git a/app/views/shared/milestones/_search_form.html.haml b/app/views/shared/milestones/_search_form.html.haml new file mode 100644 index 00000000000..403a0224a85 --- /dev/null +++ b/app/views/shared/milestones/_search_form.html.haml @@ -0,0 +1,8 @@ += form_tag request.path, method: :get do |f| + = search_field_tag :search_title, params[:search_title], + placeholder: _('Filter by milestone name'), + class: 'form-control input-short', + spellcheck: false + = hidden_field_tag :state, params[:state] + = hidden_field_tag :sort, params[:sort] + diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml index 55460acab8f..b877f66c71e 100644 --- a/app/views/shared/milestones/_tabs.html.haml +++ b/app/views/shared/milestones/_tabs.html.haml @@ -12,7 +12,7 @@ %li.nav-item = link_to '#tab-merge-requests', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do Merge Requests - %span.badge.badge-pill= milestone.merge_requests.size + %span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size - else %li.nav-item = link_to '#tab-merge-requests', class: 'nav-link active', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do @@ -21,11 +21,11 @@ %li.nav-item = link_to '#tab-participants', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_participants_tab_path(milestone) do Participants - %span.badge.badge-pill= milestone.participants.count + %span.badge.badge-pill= milestone.issue_participants_visible_by_user(current_user).count %li.nav-item = link_to '#tab-labels', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_labels_tab_path(milestone) do Labels - %span.badge.badge-pill= milestone.labels.count + %span.badge.badge-pill= milestone.issue_labels_visible_by_user(current_user).count - issues = milestone.sorted_issues(current_user) - show_project_name = local_assigns.fetch(:show_project_name, false) diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index 0499b04a482..55b1c14022f 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -62,20 +62,19 @@ %th Open issues %th State %th Due date - - milestone.milestones.each do |ms| %tr %td - - project_name = group ? ms.project.name : ms.project.full_name - = link_to project_name, project_milestone_path(ms.project, ms) + - project_name = group ? milestone.project.name : milestone.project.full_name + = link_to project_name, milestone_path(milestone.milestone) %td - = ms.issues_visible_to_user(current_user).opened.count + = milestone.milestone.issues_visible_to_user(current_user).opened.count %td - - if ms.closed? + - if milestone.closed? Closed - else Open %td - = ms.expires_at + = milestone.expires_at - elsif milestone.group_milestone? %br View diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml index 0674c822d63..c3f5eeb0da6 100644 --- a/app/views/shared/notes/_comment_button.html.haml +++ b/app/views/shared/notes/_comment_button.html.haml @@ -1,30 +1,31 @@ - noteable_name = @note.noteable.human_class_name .float-left.btn-group.append-right-10.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown - %input.btn.btn-nr.btn-success.comment-btn.js-comment-button.js-comment-submit-button{ type: 'submit', value: 'Comment' } + %input.btn.btn-nr.btn-success.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment') } - if @note.can_be_discussion_note? - = button_tag type: 'button', class: 'btn btn-nr dropdown-toggle comment-btn js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => 'Open comment type dropdown' do + = button_tag type: 'button', class: 'btn btn-nr dropdown-toggle btn-success js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => _('Open comment type dropdown') do = icon('caret-down', class: 'toggle-icon') %ul#resolvable-comment-menu.dropdown-menu.dropdown-open-top{ data: { dropdown: true } } - %li#comment.droplab-item-selected{ data: { value: '', 'submit-text' => 'Comment', 'close-text' => "Comment & close #{noteable_name}", 'reopen-text' => "Comment & reopen #{noteable_name}" } } + %li#comment.droplab-item-selected{ data: { value: '', 'submit-text' => _('Comment'), 'close-text' => _("Comment & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Comment & reopen %{noteable_name}") % { noteable_name: noteable_name } } } %button.btn.btn-transparent = icon('check', class: 'icon') .description - %strong Comment + %strong= _("Comment") %p - Add a general comment to this #{noteable_name}. + = _("Add a general comment to this %{noteable_name}.") % { noteable_name: noteable_name } %li.divider.droplab-item-ignore - %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => 'Start discussion', 'close-text' => "Start discussion & close #{noteable_name}", 'reopen-text' => "Start discussion & reopen #{noteable_name}" } } + %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => _('Start discussion'), 'close-text' => _("Start discussion & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Start discussion & reopen %{noteable_name}") % { noteable_name: noteable_name } } } %button.btn.btn-transparent = icon('check', class: 'icon') .description - %strong Start discussion + %strong= _("Start discussion") %p = succeed '.' do - Discuss a specific suggestion or question - if @note.noteable.supports_resolvable_notes? - that needs to be resolved + = _('Discuss a specific suggestion or question that needs to be resolved') + - else + = _('Discuss a specific suggestion or question') diff --git a/app/views/shared/notes/_edit.html.haml b/app/views/shared/notes/_edit.html.haml index f4b3aac29b4..84a3ef9d8fe 100644 --- a/app/views/shared/notes/_edit.html.haml +++ b/app/views/shared/notes/_edit.html.haml @@ -1 +1 @@ -%textarea.hidden.js-task-list-field.original-task-list{ data: {update_url: note_url(note) } }= note.note +%textarea.hidden.js-task-list-field.original-task-list{ data: { update_url: note_url(note) } }= note.note diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml index fec966069b9..6fe511c2999 100644 --- a/app/views/shared/notes/_edit_form.html.haml +++ b/app/views/shared/notes/_edit_form.html.haml @@ -3,12 +3,12 @@ = hidden_field_tag :target_id, '', class: 'js-form-target-id' = hidden_field_tag :target_type, '', class: 'js-form-target-type' = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(project), referenced_users: true } do - = render 'projects/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: "Write a comment or drag your files here…" + = render 'projects/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: _("Write a comment or drag your files here…") = render 'shared/notes/hints' .note-form-actions.clearfix .settings-message.note-edit-warning.js-finish-edit-warning - Finish editing this message first! - = submit_tag 'Save comment', class: 'btn btn-nr btn-success js-comment-save-button' + = _("Finish editing this message first!") + = submit_tag _('Save comment'), class: 'btn btn-nr btn-success js-comment-save-button' %button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' } - Cancel + = _("Cancel") diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml index c360f1ffe2a..6a1eea85fde 100644 --- a/app/views/shared/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -29,7 +29,7 @@ = render 'projects/zen', f: f, attr: :note, classes: 'note-textarea js-note-text', - placeholder: "Write a comment or drag your files here…", + placeholder: _("Write a comment or drag your files here…"), supports_quick_actions: supports_quick_actions, supports_autocomplete: supports_autocomplete = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions @@ -40,5 +40,5 @@ = yield(:note_actions) - %a.btn.btn-cancel.js-note-discard{ role: "button", data: {cancel_text: "Cancel" } } - Discard draft + %a.btn.btn-cancel.js-close-discussion-note-form.hide{ role: "button", data: { cancel_text: _("Cancel") } } + = _('Cancel') diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index 00eae553279..46f3f8428f1 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -1,10 +1,10 @@ - supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false) .comment-toolbar.clearfix .toolbar-text - = link_to 'Markdown', help_page_path('user/markdown'), target: '_blank', tabindex: -1 + = link_to _('Markdown'), help_page_path('user/markdown'), target: '_blank', tabindex: -1 - if supports_quick_actions and - = link_to 'quick actions', help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1 + = link_to _('quick actions'), help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1 are - else is @@ -24,12 +24,12 @@ = icon('file-image-o', class: 'toolbar-button-icon') %span.uploading-error-message -# Populated by app/assets/javascripts/dropzone_input.js - %button.retry-uploading-link{ type: 'button' } Try again + %button.retry-uploading-link{ type: 'button' }= _("Try again") or - %button.attach-new-file.markdown-selector{ type: 'button' } attach a new file + %button.attach-new-file.markdown-selector{ type: 'button' }= _("attach a new file") %button.markdown-selector.button-attach-file{ type: 'button', tabindex: '-1' } = icon('file-image-o', class: 'toolbar-button-icon') - Attach a file + = _("Attach a file") - %button.btn.btn-default.btn-sm.hide.button-cancel-uploading-files{ type: 'button' } Cancel + %button.btn.btn-default.btn-sm.hide.button-cancel-uploading-files{ type: 'button' }= _("Cancel") diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index e125d7f108a..41d6ae79c81 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -54,7 +54,7 @@ .note-text.md = markdown_field(note, :note) = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago') - .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore, markdown_version: note.cached_markdown_version } } + .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } } #{note.note} - if note_editable = render 'shared/notes/edit', note: note @@ -62,7 +62,7 @@ = render 'award_emoji/awards_block', awardable: note, inline: false - if note.system .system-note-commit-list-toggler.hide - Toggle commit list + = _("Toggle commit list") %i.fa.fa-angle-down - if note.attachment.url .note-attachment @@ -74,5 +74,5 @@ = icon('paperclip') = note.attachment_identifier = link_to delete_attachment_project_note_path(note.project, note), - title: 'Delete this attachment', method: :delete, remote: true, data: { confirm: 'Are you sure you want to remove the attachment?' }, class: 'danger js-note-attachment-delete' do + title: _('Delete this attachment'), method: :delete, remote: true, data: { confirm: _('Are you sure you want to remove the attachment?') }, class: 'danger js-note-attachment-delete' do = icon('trash-o', class: 'cred') diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index 4c4050c6054..002189e6ecd 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -19,20 +19,14 @@ = render "shared/notes/form", view: diff_view, supports_autocomplete: autocomplete - elsif !current_user .disabled-comment.text-center.prepend-top-default - Please - = link_to "register", new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), class: 'js-register-link' - or - = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link' - to comment + - link_to_register = link_to(_("register"), new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), class: 'js-register-link') + - link_to_sign_in = link_to(_("sign in"), new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link') + = _("Please %{link_to_register} or %{link_to_sign_in} to comment").html_safe % { link_to_register: link_to_register, link_to_sign_in: link_to_sign_in } - elsif discussion_locked .disabled-comment.text-center.prepend-top-default %span.issuable-note-warning = sprite_icon('lock', size: 16, css_class: 'icon') %span - This - = issuable.class.to_s.titleize.downcase - is locked. Only - %b project members - can comment. + = _("This %{issuable} is locked. Only <strong>project members</strong> can comment.").html_safe % { issuable: issuable.class.to_s.titleize.downcase } -# haml-lint:disable InlineJavaScript %script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml index 30860988bbb..2ece7b7f701 100644 --- a/app/views/shared/notifications/_button.html.haml +++ b/app/views/shared/notifications/_button.html.haml @@ -1,7 +1,7 @@ - btn_class = local_assigns.fetch(:btn_class, nil) - if notification_setting - .js-notification-dropdown.notification-dropdown.project-action-button.dropdown.inline + .js-notification-dropdown.notification-dropdown.home-panel-action-button.dropdown.inline = form_for notification_setting, remote: true, html: { class: "inline notification-form" } do |f| = hidden_setting_source_input(notification_setting) = f.hidden_field :level, class: "notification_setting_level" diff --git a/app/views/shared/notifications/_new_button.html.haml b/app/views/shared/notifications/_new_button.html.haml new file mode 100644 index 00000000000..6d26dbebbc8 --- /dev/null +++ b/app/views/shared/notifications/_new_button.html.haml @@ -0,0 +1,27 @@ +- btn_class = local_assigns.fetch(:btn_class, nil) + +- if notification_setting + .js-notification-dropdown.notification-dropdown.home-panel-action-button.prepend-top-default.append-right-8.dropdown.inline + = form_for notification_setting, remote: true, html: { class: "inline notification-form no-label" } do |f| + = hidden_setting_source_input(notification_setting) + = hidden_field_tag "hide_label", true + = f.hidden_field :level, class: "notification_setting_level" + .js-notification-toggle-btns + %div{ class: ("btn-group" if notification_setting.custom?) } + - if notification_setting.custom? + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", placement: 'top', toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } + = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon") + %span.js-notification-loading.fa.hidden + %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" }, class: "#{btn_class}" } + = sprite_icon("arrow-down", css_class: "icon mr-0") + .sr-only Toggle dropdown + - else + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting - #{notification_title(notification_setting.level)}", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", placement: 'top', toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon") + %span.js-notification-loading.fa.hidden + = sprite_icon("arrow-down", css_class: "icon") + + = render "shared/notifications/notification_dropdown", notification_setting: notification_setting + + = content_for :scripts_body do + = render "shared/notifications/custom_notifications", notification_setting: notification_setting diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index 15c29e14cc0..13847cd9be1 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -14,6 +14,17 @@ - skip_pagination = false unless local_assigns[:skip_pagination] == true - compact_mode = false unless local_assigns[:compact_mode] == true - css_classes = "#{'compact' if compact_mode} #{'explore' if explore_projects_tab?}" +- contributed_projects_illustration_path = 'illustrations/profile-page/contributed-projects.svg' +- contributed_projects_current_user_empty_message_header = s_('UserProfile|Explore public groups to find projects to contribute to.') +- contributed_projects_visitor_empty_message = s_('UserProfile|This user hasn\'t contributed to any projects') +- own_projects_illustration_path = 'illustrations/profile-page/personal-project.svg' +- own_projects_current_user_empty_message_header = s_('UserProfile|You haven\'t created any personal projects.') +- own_projects_current_user_empty_message_description = s_('UserProfile|Your projects can be available publicly, internally, or privately, at your choice.') +- own_projects_visitor_empty_message = s_('UserProfile|This user doesn\'t have any personal projects') +- primary_button_label = _('New project') +- primary_button_link = new_project_path +- secondary_button_label = _('Explore groups') +- secondary_button_link = explore_groups_path .js-projects-list-holder - if any_projects?(projects) @@ -33,4 +44,18 @@ %span you have no access to. = paginate_collection(projects, remote: remote) unless skip_pagination - else - .nothing-here-block No projects found + - if @contributed_projects + = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: contributed_projects_illustration_path, + current_user_empty_message_header: contributed_projects_current_user_empty_message_header, + primary_button_label: primary_button_label, + primary_button_link: primary_button_link, + secondary_button_label: secondary_button_label, + secondary_button_link: secondary_button_link, + visitor_empty_message: contributed_projects_visitor_empty_message } + - else + = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: own_projects_illustration_path, + current_user_empty_message_header: own_projects_current_user_empty_message_header, + current_user_empty_message_description: own_projects_current_user_empty_message_description, + primary_button_label: primary_button_label, + primary_button_link: primary_button_link, + visitor_empty_message: own_projects_visitor_empty_message } diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index fea7e17be3d..d2b1be29eb9 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -12,21 +12,21 @@ - css_class += " no-description" if project.description.blank? && !show_last_commit_as_description - cache_key = project_list_cache_key(project) - updated_tooltip = time_ago_with_tooltip(project.last_activity_date) -- css_details_class = compact_mode ? "d-flex flex-column flex-sm-row flex-md-row align-items-sm-center" : "align-items-center flex-md-fill flex-lg-column d-sm-flex d-lg-block" -- css_controls_class = compact_mode ? "" : "align-items-md-end align-items-lg-center flex-lg-row" +- css_controls_class = compact_mode ? "" : "flex-lg-row justify-content-lg-between" +- avatar_container_class = project.creator && use_creator_avatar ? '' : 'rect-avatar' %li.project-row.d-flex{ class: css_class } = cache(cache_key) do - if avatar - .avatar-container.s64.flex-grow-0.flex-shrink-0 + .avatar-container.s48.flex-grow-0.flex-shrink-0{ class: avatar_container_class } = link_to project_path(project), class: dom_class(project) do - if project.creator && use_creator_avatar - = image_tag avatar_icon_for_user(project.creator, 64), class: "avatar s65", alt:'' + = image_tag avatar_icon_for_user(project.creator, 48), class: "avatar s48", alt:'' - else - = project_icon(project, alt: '', class: 'avatar project-avatar s64', width: 64, height: 64) - .project-details.flex-sm-fill{ class: css_details_class } - .flex-wrapper.flex-fill - .d-flex.align-items-center.flex-wrap + = project_icon(project, alt: '', class: 'avatar project-avatar s48', width: 48, height: 48) + .project-details.d-sm-flex.flex-sm-fill.align-items-center + .flex-wrapper + .d-flex.align-items-center.flex-wrap.project-title %h2.d-flex.prepend-top-8 = link_to project_path(project), class: 'text-plain' do %span.project-full-name.append-right-8>< @@ -52,13 +52,13 @@ %span.user-access-role.d-block= Gitlab::Access.human_access(access) - if show_last_commit_as_description - .description.d-none.d-sm-block.prepend-top-8.append-right-default + .description.d-none.d-sm-block.append-right-default = link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message") - elsif project.description.present? - .description.d-none.d-sm-block.prepend-top-8.append-right-default + .description.d-none.d-sm-block.append-right-default = markdown_field(project, :description) - .controls.d-flex.flex-row.flex-sm-column.flex-md-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0{ class: css_controls_class } + .controls.d-flex.flex-sm-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0{ class: css_controls_class } .icon-container.d-flex.align-items-center - if project.archived %span.d-flex.icon-wrapper.badge.badge-warning archived @@ -74,34 +74,19 @@ = number_with_delimiter(project.forks_count) - if show_merge_request_count?(disabled: !merge_requests, compact_mode: compact_mode) = link_to project_merge_requests_path(project), - class: "d-none d-lg-flex align-items-center icon-wrapper merge-requests has-tooltip", + class: "d-none d-xl-flex align-items-center icon-wrapper merge-requests has-tooltip", title: _('Merge Requests'), data: { container: 'body', placement: 'top' } do = sprite_icon('git-merge', size: 14, css_class: 'append-right-4') = number_with_delimiter(project.open_merge_requests_count) - if show_issue_count?(disabled: !issues, compact_mode: compact_mode) = link_to project_issues_path(project), - class: "d-none d-lg-flex align-items-center icon-wrapper issues has-tooltip", + class: "d-none d-xl-flex align-items-center icon-wrapper issues has-tooltip", title: _('Issues'), data: { container: 'body', placement: 'top' } do = sprite_icon('issues', size: 14, css_class: 'append-right-4') = number_with_delimiter(project.open_issues_count) - - if pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? + - if pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project) + - pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref) %span.icon-wrapper.pipeline-status - = render_project_pipeline_status(project.pipeline_status, tooltip_placement: 'top') + = render 'ci/status/icon', status: project.commit.last_pipeline.detailed_status(current_user), type: 'commit', tooltip_placement: 'top', path: pipeline_path .updated-note %span Updated #{updated_tooltip} - - .d-none.d-lg-flex.align-item-stretch - - unless compact_mode - - if current_user - %button.star-button.btn.btn-default.d-flex.align-items-center.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(project, :json) } } - - if current_user.starred?(project) - = sprite_icon('star', { css_class: 'icon' }) - %span.starred= s_('ProjectOverview|Unstar') - - else - = sprite_icon('star-o', { css_class: 'icon' }) - %span= s_('ProjectOverview|Star') - - - else - = link_to new_user_session_path, class: 'btn btn-default has-tooltip count-badge-button d-flex align-items-center star-btn', title: s_('ProjectOverview|You must sign in to star a project') do - = sprite_icon('star-o', { css_class: 'icon' }) - %span= s_('ProjectOverview|Star') diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index cf9c3055499..3007da0c189 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -3,8 +3,7 @@ .snippet-form-holder = form_for @snippet, url: url, - html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" }, - data: { markdown_version: @snippet.cached_markdown_version } do |f| + html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" } do |f| = form_errors(@snippet) .form-group.row diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index 10bfc30492a..a43296aa806 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -30,7 +30,7 @@ - if @snippet.updated_at != @snippet.created_at = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true) - - if public_snippet? + - if @snippet.embeddable? .embed-snippet .input-group .input-group-prepend diff --git a/app/views/shared/snippets/_list.html.haml b/app/views/shared/snippets/_list.html.haml new file mode 100644 index 00000000000..5d2152eb411 --- /dev/null +++ b/app/views/shared/snippets/_list.html.haml @@ -0,0 +1,12 @@ +- remote = local_assigns.fetch(:remote, false) +- link_project = local_assigns.fetch(:link_project, false) + +- if @snippets.exists? + .snippets-list-holder + %ul.content-list + = render partial: 'shared/snippets/snippet', collection: @snippets, locals: { link_project: link_project } + + = paginate @snippets, theme: 'gitlab', remote: remote + +- else + .nothing-here-block= s_("SnippetsEmptyState|No snippets found") diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index 5069e2e4ca6..42af97bc6af 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -25,7 +25,7 @@ #{snippet.to_reference} · authored #{time_ago_with_tooltip(snippet.created_at, placement: 'bottom', html_class: 'snippet-created-ago')} by - = link_to user_snippets_path(snippet.author) do + = link_to user_snippets_path(snippet.author), class: "js-user-link", data: { user_id: snippet.author.id } do = snippet.author_name - if link_project && snippet.project_id? %span.d-none.d-sm-inline-block diff --git a/app/views/shared/tokens/_scopes_form.html.haml b/app/views/shared/tokens/_scopes_form.html.haml index af9db5f59a8..a5d3e1c8de0 100644 --- a/app/views/shared/tokens/_scopes_form.html.haml +++ b/app/views/shared/tokens/_scopes_form.html.haml @@ -4,6 +4,6 @@ - scopes.each do |scope| %fieldset.form-group.form-check - = check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}", class: 'form-check-input' + = check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}", class: "form-check-input qa-#{scope}-radio" = label_tag ("#{prefix}_scopes_#{scope}"), scope, class: 'label-bold form-check-label' .text-secondary= t scope, scope: [:doorkeeper, :scope_desc] diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml index 69d41f8fe5e..dab247da251 100644 --- a/app/views/snippets/_snippets.html.haml +++ b/app/views/snippets/_snippets.html.haml @@ -1,10 +1,21 @@ - link_project = local_assigns.fetch(:link_project, false) +- illustration_path = 'illustrations/profile-page/activity.svg' +- current_user_empty_message_header = s_('UserProfile|You haven\'t created any snippets.') +- current_user_empty_message_description = s_('UserProfile|Snippets in GitLab can either be private, internal, or public.') +- primary_button_label = _('New snippet') +- primary_button_link = new_snippet_path +- visitor_empty_message = s_('UserProfile|No snippets found.') .snippets-list-holder %ul.content-list = render partial: 'shared/snippets/snippet', collection: @snippets, locals: { link_project: link_project } - if @snippets.empty? %li - .nothing-here-block= _("Nothing here.") + = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path, + current_user_empty_message_header: current_user_empty_message_header, + current_user_empty_message_description: current_user_empty_message_description, + primary_button_label: primary_button_label, + primary_button_link: primary_button_link, + visitor_empty_message: visitor_empty_message } = paginate @snippets, theme: 'gitlab' diff --git a/app/views/snippets/index.html.haml b/app/views/snippets/index.html.haml index 4f418e2381f..f5fe75bed5a 100644 --- a/app/views/snippets/index.html.haml +++ b/app/views/snippets/index.html.haml @@ -10,4 +10,4 @@ = link_to user_path(@user) do = _("%{user_name} profile page") % { user_name: @user.name } -= render 'snippets' += render 'shared/snippets/list' diff --git a/app/views/users/_groups.html.haml b/app/views/users/_groups.html.haml index 55799e10a46..6d7a52c7688 100644 --- a/app/views/users/_groups.html.haml +++ b/app/views/users/_groups.html.haml @@ -1,5 +1,5 @@ .clearfix - groups.each do |group| = link_to group, class: 'profile-groups-avatars inline', title: group.name do - .avatar-container.s40 + .avatar-container.rect-avatar.s40 = group_icon(group, class: 'avatar group-avatar s40') diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml index d22905ecc93..b5bc1180290 100644 --- a/app/views/users/_overview.html.haml +++ b/app/views/users/_overview.html.haml @@ -9,24 +9,22 @@ .col-md-12.col-lg-6 - if can?(current_user, :read_cross_project) .activities-block - .append-right-5 - .prepend-top-16 - .d-flex.align-items-center.border-bottom - %h4.flex-grow - = s_('UserProfile|Activity') - = link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all" - .overview-content-list{ data: { href: user_path } } - .center.light.loading - = spinner nil, true - - .col-md-12.col-lg-6 - .projects-block - .prepend-left-5 .prepend-top-16 .d-flex.align-items-center.border-bottom %h4.flex-grow - = s_('UserProfile|Personal projects') - = link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all" - .overview-content-list{ data: { href: user_projects_path } } + = s_('UserProfile|Activity') + = link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all" + .overview-content-list{ data: { href: user_path } } .center.light.loading = spinner nil, true + + .col-md-12.col-lg-6 + .projects-block + .prepend-top-16 + .d-flex.align-items-center.border-bottom + %h4.flex-grow + = s_('UserProfile|Personal projects') + = link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all" + .overview-content-list{ data: { href: user_projects_path } } + .center.light.loading + = spinner nil, true diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index dd2cd36eac2..211e3eafac6 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -71,14 +71,14 @@ = icon('twitter-square') - unless @user.website_url.blank? .profile-link-holder.middle-dot-divider - = link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'noopener noreferrer nofollow' + = link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'me noopener noreferrer nofollow' - unless @user.location.blank? .profile-link-holder.middle-dot-divider - = icon('map-marker') + = sprite_icon('location', size: 16, css_class: 'vertical-align-sub') = @user.location - unless @user.organization.blank? .profile-link-holder.middle-dot-divider - = icon('briefcase') + = sprite_icon('work', size: 16, css_class: 'vertical-align-sub') = @user.organization - if @user.bio.present? diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index d3cf21db335..d86f654dd44 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -23,6 +23,7 @@ - cronjob:prune_web_hook_logs - gcp_cluster:cluster_install_app +- gcp_cluster:cluster_upgrade_app - gcp_cluster:cluster_provision - gcp_cluster:cluster_wait_for_app_installation - gcp_cluster:wait_for_cluster_creation @@ -45,6 +46,11 @@ - github_importer:github_import_stage_import_pull_requests - github_importer:github_import_stage_import_repository +- hashed_storage:hashed_storage_migrator +- hashed_storage:hashed_storage_rollbacker +- hashed_storage:hashed_storage_project_migrate +- hashed_storage:hashed_storage_project_rollback + - mail_scheduler:mail_scheduler_issue_due - mail_scheduler:mail_scheduler_notification_service @@ -90,13 +96,16 @@ - object_pool:object_pool_join - object_pool:object_pool_destroy +- container_repository:delete_container_repository +- container_repository:cleanup_container_repository + - default - mailers # ActionMailer::DeliveryJob.queue_name - authorized_projects - background_migration +- chat_notification - create_gpg_signature -- delete_container_repository - delete_merged_branches - delete_user - email_receiver @@ -120,7 +129,6 @@ - project_cache - project_destroy - project_export -- project_migrate_hashed_storage - project_service - propagate_service_template - reactive_caching @@ -129,7 +137,6 @@ - repository_fork - repository_import - repository_remove_remote -- storage_migrator - system_hook_push - update_merge_requests - upload_checksum @@ -140,3 +147,5 @@ - detect_repository_languages - repository_cleanup - delete_stored_files +- import_issues_csv +- project_daily_statistics diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb index 61d866b1f02..adc38226405 100644 --- a/app/workers/build_finished_worker.rb +++ b/app/workers/build_finished_worker.rb @@ -9,14 +9,27 @@ class BuildFinishedWorker # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) Ci::Build.find_by(id: build_id).try do |build| - # We execute that in sync as this access the files in order to access local file, and reduce IO - BuildTraceSectionsWorker.new.perform(build.id) - BuildCoverageWorker.new.perform(build.id) - - # We execute that async as this are two independent operations that can be executed after TraceSections and Coverage - BuildHooksWorker.perform_async(build.id) - ArchiveTraceWorker.perform_async(build.id) + process_build(build) end end # rubocop: enable CodeReuse/ActiveRecord + + private + + # Processes a single CI build that has finished. + # + # This logic resides in a separate method so that EE can extend it more + # easily. + # + # @param [Ci::Build] build The build to process. + def process_build(build) + # We execute these in sync to reduce IO. + BuildTraceSectionsWorker.new.perform(build.id) + BuildCoverageWorker.new.perform(build.id) + + # We execute these async as these are independent operations. + BuildHooksWorker.perform_async(build.id) + ArchiveTraceWorker.perform_async(build.id) + ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat? + end end diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb new file mode 100644 index 00000000000..25a306e94d8 --- /dev/null +++ b/app/workers/chat_notification_worker.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class ChatNotificationWorker + include ApplicationWorker + + RESCHEDULE_INTERVAL = 2.seconds + + # rubocop: disable CodeReuse/ActiveRecord + def perform(build_id) + Ci::Build.find_by(id: build_id).try do |build| + send_response(build) + end + rescue Gitlab::Chat::Output::MissingBuildSectionError + # The creation of traces and sections appears to be eventually consistent. + # As a result it's possible for us to run the above code before the trace + # sections are present. To better handle such cases we'll just reschedule + # the job instead of producing an error. + self.class.perform_in(RESCHEDULE_INTERVAL, build_id) + end + # rubocop: enable CodeReuse/ActiveRecord + + def send_response(build) + Gitlab::Chat::Responder.responder_for(build).try do |responder| + if build.success? + output = Gitlab::Chat::Output.new(build) + + responder.success(output.to_s) + else + responder.failure + end + end + end +end diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb new file mode 100644 index 00000000000..974ee8c8146 --- /dev/null +++ b/app/workers/cleanup_container_repository_worker.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class CleanupContainerRepositoryWorker + include ApplicationWorker + include ExclusiveLeaseGuard + + queue_namespace :container_repository + + LEASE_TIMEOUT = 1.hour + + attr_reader :container_repository, :current_user + + def perform(current_user_id, container_repository_id, params) + @current_user = User.find_by_id(current_user_id) + @container_repository = ContainerRepository.find_by_id(container_repository_id) + + return unless valid? + + try_obtain_lease do + Projects::ContainerRepository::CleanupTagsService + .new(project, current_user, params) + .execute(container_repository) + end + end + + private + + def valid? + current_user && container_repository && project + end + + def project + container_repository&.project + end + + # For ExclusiveLeaseGuard concern + def lease_key + @lease_key ||= "container_repository:cleanup_tags:#{container_repository.id}" + end + + # For ExclusiveLeaseGuard concern + def lease_timeout + LEASE_TIMEOUT + end + + # For ExclusiveLeaseGuard concern + def lease_release? + # we don't allow to execute this worker + # more often than LEASE_TIMEOUT + # for given container repository + false + end +end diff --git a/app/workers/cluster_upgrade_app_worker.rb b/app/workers/cluster_upgrade_app_worker.rb new file mode 100644 index 00000000000..d1a538859b4 --- /dev/null +++ b/app/workers/cluster_upgrade_app_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ClusterUpgradeAppWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + def perform(app_name, app_id) + find_application(app_name, app_id) do |app| + Clusters::Applications::UpgradeService.new(app).execute + end + end +end diff --git a/app/workers/delete_container_repository_worker.rb b/app/workers/delete_container_repository_worker.rb index e8fe9d82797..42e66513ff1 100644 --- a/app/workers/delete_container_repository_worker.rb +++ b/app/workers/delete_container_repository_worker.rb @@ -4,6 +4,8 @@ class DeleteContainerRepositoryWorker include ApplicationWorker include ExclusiveLeaseGuard + queue_namespace :container_repository + LEASE_TIMEOUT = 1.hour attr_reader :container_repository diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb index dce812d1ae2..251e95c68d5 100644 --- a/app/workers/expire_build_artifacts_worker.rb +++ b/app/workers/expire_build_artifacts_worker.rb @@ -4,8 +4,20 @@ class ExpireBuildArtifactsWorker include ApplicationWorker include CronjobQueue - # rubocop: disable CodeReuse/ActiveRecord def perform + if Feature.enabled?(:ci_new_expire_job_artifacts_service, default_enabled: true) + perform_efficient_artifacts_removal + else + perform_legacy_artifacts_removal + end + end + + def perform_efficient_artifacts_removal + Ci::DestroyExpiredJobArtifactsService.new.execute + end + + # rubocop: disable CodeReuse/ActiveRecord + def perform_legacy_artifacts_removal Rails.logger.info 'Scheduling removal of build artifacts' build_ids = Ci::Build.with_expired_artifacts.pluck(:id) diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb index c96e8a0379b..2c070d482a1 100644 --- a/app/workers/expire_pipeline_cache_worker.rb +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -11,16 +11,9 @@ class ExpirePipelineCacheWorker pipeline = Ci::Pipeline.find_by(id: pipeline_id) return unless pipeline - project = pipeline.project store = Gitlab::EtagCaching::Store.new - store.touch(project_pipelines_path(project)) - store.touch(project_pipeline_path(project, pipeline)) - store.touch(commit_pipelines_path(project, pipeline.commit)) unless pipeline.commit.nil? - store.touch(new_merge_request_pipelines_path(project)) - each_pipelines_merge_request_path(project, pipeline) do |path| - store.touch(path) - end + update_etag_cache(pipeline, store) Gitlab::Cache::Ci::ProjectPipelineStatus.update_for_pipeline(pipeline) end @@ -44,11 +37,30 @@ class ExpirePipelineCacheWorker Gitlab::Routing.url_helpers.project_new_merge_request_path(project, format: :json) end - def each_pipelines_merge_request_path(project, pipeline) + def each_pipelines_merge_request_path(pipeline) pipeline.all_merge_requests.each do |merge_request| - path = Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(project, merge_request, format: :json) + path = Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(merge_request.target_project, merge_request, format: :json) yield(path) end end + + # Updates ETag caches of a pipeline. + # + # This logic resides in a separate method so that EE can more easily extend + # it. + # + # @param [Ci::Pipeline] pipeline + # @param [Gitlab::EtagCaching::Store] store + def update_etag_cache(pipeline, store) + project = pipeline.project + + store.touch(project_pipelines_path(project)) + store.touch(project_pipeline_path(project, pipeline)) + store.touch(commit_pipelines_path(project, pipeline.commit)) unless pipeline.commit.nil? + store.touch(new_merge_request_pipelines_path(project)) + each_pipelines_merge_request_path(pipeline) do |path| + store.touch(path) + end + end end diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb index d3628b23189..b33e9b1f718 100644 --- a/app/workers/git_garbage_collect_worker.rb +++ b/app/workers/git_garbage_collect_worker.rb @@ -23,6 +23,7 @@ class GitGarbageCollectWorker end task = task.to_sym + project.link_pool_repository gitaly_call(task, project.repository.raw_repository) # Refresh the branch cache in case garbage collection caused a ref lookup to fail diff --git a/app/workers/hashed_storage/base_worker.rb b/app/workers/hashed_storage/base_worker.rb new file mode 100644 index 00000000000..816e0504db6 --- /dev/null +++ b/app/workers/hashed_storage/base_worker.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module HashedStorage + class BaseWorker + include ExclusiveLeaseGuard + + LEASE_TIMEOUT = 30.seconds.to_i + LEASE_KEY_SEGMENT = 'project_migrate_hashed_storage_worker'.freeze + + protected + + def lease_key + # we share the same lease key for both migration and rollback so they don't run simultaneously + "#{LEASE_KEY_SEGMENT}:#{project_id}" + end + + def lease_timeout + LEASE_TIMEOUT + end + end +end diff --git a/app/workers/hashed_storage/migrator_worker.rb b/app/workers/hashed_storage/migrator_worker.rb new file mode 100644 index 00000000000..49e347d4060 --- /dev/null +++ b/app/workers/hashed_storage/migrator_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module HashedStorage + class MigratorWorker + include ApplicationWorker + + queue_namespace :hashed_storage + + # @param [Integer] start initial ID of the batch + # @param [Integer] finish last ID of the batch + def perform(start, finish) + migrator = Gitlab::HashedStorage::Migrator.new + migrator.bulk_migrate(start: start, finish: finish) + end + end +end diff --git a/app/workers/hashed_storage/project_migrate_worker.rb b/app/workers/hashed_storage/project_migrate_worker.rb new file mode 100644 index 00000000000..f00a459a097 --- /dev/null +++ b/app/workers/hashed_storage/project_migrate_worker.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module HashedStorage + class ProjectMigrateWorker < BaseWorker + include ApplicationWorker + + queue_namespace :hashed_storage + + 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) + break unless project + + old_disk_path ||= project.disk_path + + ::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 new file mode 100644 index 00000000000..55e1d7ab23e --- /dev/null +++ b/app/workers/hashed_storage/project_rollback_worker.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module HashedStorage + class ProjectRollbackWorker < BaseWorker + include ApplicationWorker + + queue_namespace :hashed_storage + + 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) + break unless project + + old_disk_path ||= project.disk_path + + ::Projects::HashedStorage::RollbackService.new(project, old_disk_path, logger: logger).execute + end + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/workers/hashed_storage/rollbacker_worker.rb b/app/workers/hashed_storage/rollbacker_worker.rb new file mode 100644 index 00000000000..a4da8443787 --- /dev/null +++ b/app/workers/hashed_storage/rollbacker_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module HashedStorage + class RollbackerWorker + include ApplicationWorker + + queue_namespace :hashed_storage + + # @param [Integer] start initial ID of the batch + # @param [Integer] finish last ID of the batch + def perform(start, finish) + migrator = Gitlab::HashedStorage::Migrator.new + migrator.bulk_rollback(start: start, finish: finish) + end + end +end diff --git a/app/workers/import_issues_csv_worker.rb b/app/workers/import_issues_csv_worker.rb new file mode 100644 index 00000000000..d44fdfec8ae --- /dev/null +++ b/app/workers/import_issues_csv_worker.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class ImportIssuesCsvWorker + include ApplicationWorker + + sidekiq_retries_exhausted do |job| + Upload.find(job['args'][2]).destroy + end + + def perform(current_user_id, project_id, upload_id) + @user = User.find(current_user_id) + @project = Project.find(project_id) + @upload = Upload.find(upload_id) + + importer = Issues::ImportCsvService.new(@user, @project, @upload.build_uploader) + importer.execute + + @upload.destroy + end +end diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb index 4726e416182..421fbf04e28 100644 --- a/app/workers/mail_scheduler/notification_service_worker.rb +++ b/app/workers/mail_scheduler/notification_service_worker.rb @@ -8,14 +8,64 @@ module MailScheduler include MailSchedulerQueue def perform(meth, *args) - deserialized_args = ActiveJob::Arguments.deserialize(args) + check_arguments!(args) + deserialized_args = ActiveJob::Arguments.deserialize(args) notification_service.public_send(meth, *deserialized_args) # rubocop:disable GitlabSecurity/PublicSend rescue ActiveJob::DeserializationError + # No-op. + # This exception gets raised when an argument + # is correct (deserializeable), but it still cannot be deserialized. + # This can happen when an object has been deleted after + # rails passes this job to sidekiq, but before + # sidekiq gets it for execution. + # In this case just do nothing. end def self.perform_async(*args) - super(*ActiveJob::Arguments.serialize(args)) + super(*Arguments.serialize(args)) + end + + private + + # If an argument is in the ActiveJob::Arguments::TYPE_WHITELIST list, + # it means the argument cannot be deserialized. + # Which means there's something wrong with our code. + def check_arguments!(args) + args.each do |arg| + if arg.class.in?(ActiveJob::Arguments::TYPE_WHITELIST) + raise(ArgumentError, "Argument `#{arg}` cannot be deserialized because of its type") + end + end + end + + # Permit ActionController::Parameters for serializable Hash + # + # Port of + # https://github.com/rails/rails/commit/945fdd76925c9f615bf016717c4c8db2b2955357#diff-fc90ec41ef75be8b2259526fe1a8b663 + module Arguments + include ActiveJob::Arguments + extend self + + private + + def serialize_argument(argument) + case argument + when -> (arg) { arg.respond_to?(:permitted?) } + serialize_hash(argument.to_h).tap do |result| + result[WITH_INDIFFERENT_ACCESS_KEY] = serialize_argument(true) + end + else + super + end + end + end + + # Make sure we remove this patch starting with Rails 6.0. + if Rails.version.start_with?('6.0') + raise <<~MSG + Please remove the patch `Arguments` module and use `ActiveJob::Arguments` again. + MSG end end end diff --git a/app/workers/namespaceless_project_destroy_worker.rb b/app/workers/namespaceless_project_destroy_worker.rb index 2965f3b1150..f6e98746055 100644 --- a/app/workers/namespaceless_project_destroy_worker.rb +++ b/app/workers/namespaceless_project_destroy_worker.rb @@ -17,7 +17,7 @@ class NamespacelessProjectDestroyWorker return end - return if project.namespace # Reject doing anything for projects that *do* have a namespace + return if project.namespace # Reject doing anything for projects that *do* have a namespace project.team.truncate diff --git a/app/workers/object_pool/join_worker.rb b/app/workers/object_pool/join_worker.rb index 07676011b2a..9c5161fd55a 100644 --- a/app/workers/object_pool/join_worker.rb +++ b/app/workers/object_pool/join_worker.rb @@ -5,14 +5,13 @@ module ObjectPool include ApplicationWorker include ObjectPoolQueue - def perform(pool_id, project_id) - pool = PoolRepository.find_by_id(pool_id) - return unless pool&.joinable? - + # The use of pool id is deprecated. Keeping the argument allows old jobs to + # still be performed. + def perform(_pool_id, project_id) project = Project.find_by_id(project_id) - return unless project + return unless project&.pool_repository&.joinable? - pool.link_repository(project.repository) + project.link_pool_repository Projects::HousekeepingService.new(project).execute end diff --git a/app/workers/pipeline_metrics_worker.rb b/app/workers/pipeline_metrics_worker.rb index c2fbfd2b3a5..0ddad43b8d5 100644 --- a/app/workers/pipeline_metrics_worker.rb +++ b/app/workers/pipeline_metrics_worker.rb @@ -30,6 +30,6 @@ class PipelineMetricsWorker # rubocop: enable CodeReuse/ActiveRecord def merge_requests(pipeline) - pipeline.merge_requests.map(&:id) + pipeline.merge_requests_as_head_pipeline.map(&:id) end end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 72a1733a2a8..bbd4ab159e4 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -3,7 +3,7 @@ class PostReceive include ApplicationWorker - def perform(gl_repository, identifier, changes) + def perform(gl_repository, identifier, changes, push_options = []) project, is_wiki = Gitlab::GlRepository.parse(gl_repository) if project.nil? @@ -15,7 +15,7 @@ class PostReceive # Use Sidekiq.logger so arguments can be correlated with execution # time and thread ID's. Sidekiq.logger.info "changes: #{changes.inspect}" if ENV['SIDEKIQ_LOG_ARGUMENTS'] - post_received = Gitlab::GitPostReceive.new(project, identifier, changes) + post_received = Gitlab::GitPostReceive.new(project, identifier, changes, push_options) if is_wiki process_wiki_changes(post_received) @@ -38,9 +38,21 @@ class PostReceive post_received.changes_refs do |oldrev, newrev, ref| if Gitlab::Git.tag_ref?(ref) - GitTagPushService.new(post_received.project, @user, oldrev: oldrev, newrev: newrev, ref: ref).execute + GitTagPushService.new( + post_received.project, + @user, + oldrev: oldrev, + newrev: newrev, + ref: ref, + push_options: post_received.push_options).execute elsif Gitlab::Git.branch_ref?(ref) - GitPushService.new(post_received.project, @user, oldrev: oldrev, newrev: newrev, ref: ref).execute + GitPushService.new( + post_received.project, + @user, + oldrev: oldrev, + newrev: newrev, + ref: ref, + push_options: post_received.push_options).execute end changes << Gitlab::DataBuilder::Repository.single_change(oldrev, newrev, ref) diff --git a/app/workers/project_daily_statistics_worker.rb b/app/workers/project_daily_statistics_worker.rb new file mode 100644 index 00000000000..101f5c28459 --- /dev/null +++ b/app/workers/project_daily_statistics_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ProjectDailyStatisticsWorker + include ApplicationWorker + + def perform(project_id) + project = Project.find_by_id(project_id) + + return unless project&.repository&.exists? + + Projects::FetchStatisticsIncrementService.new(project).execute + end +end diff --git a/app/workers/project_migrate_hashed_storage_worker.rb b/app/workers/project_migrate_hashed_storage_worker.rb deleted file mode 100644 index 4c6339f7701..00000000000 --- a/app/workers/project_migrate_hashed_storage_worker.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -class ProjectMigrateHashedStorageWorker - include ApplicationWorker - - LEASE_TIMEOUT = 30.seconds.to_i - - # rubocop: disable CodeReuse/ActiveRecord - def perform(project_id, old_disk_path = nil) - project = Project.find_by(id: project_id) - return if project.nil? || project.pending_delete? - - uuid = lease_for(project_id).try_obtain - if uuid - ::Projects::HashedStorageMigrationService.new(project, old_disk_path || project.full_path, logger: logger).execute - else - false - end - rescue => ex - cancel_lease_for(project_id, uuid) if uuid - raise ex - end - # rubocop: enable CodeReuse/ActiveRecord - - def lease_for(project_id) - Gitlab::ExclusiveLease.new(lease_key(project_id), timeout: LEASE_TIMEOUT) - end - - private - - def lease_key(project_id) - "project_migrate_hashed_storage_worker:#{project_id}" - end - - def cancel_lease_for(project_id, uuid) - Gitlab::ExclusiveLease.cancel(lease_key(project_id), uuid) - end -end diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb index 96ff8cd6222..9ec8bcca4f3 100644 --- a/app/workers/reactive_caching_worker.rb +++ b/app/workers/reactive_caching_worker.rb @@ -6,13 +6,13 @@ class ReactiveCachingWorker # rubocop: disable CodeReuse/ActiveRecord def perform(class_name, id, *args) klass = begin - Kernel.const_get(class_name) + class_name.constantize rescue NameError nil end return unless klass - klass.find_by(id: id).try(:exclusively_update_reactive_cache!, *args) + klass.find_by(klass.primary_key => id).try(:exclusively_update_reactive_cache!, *args) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/remote_mirror_notification_worker.rb b/app/workers/remote_mirror_notification_worker.rb index 70c2e857d09..5bafe8e2046 100644 --- a/app/workers/remote_mirror_notification_worker.rb +++ b/app/workers/remote_mirror_notification_worker.rb @@ -9,7 +9,10 @@ class RemoteMirrorNotificationWorker # We check again if there's an error because a newer run since this job was # fired could've completed successfully. return unless remote_mirror && remote_mirror.last_error.present? + return if remote_mirror.error_notification_sent? NotificationService.new.remote_mirror_update_failed(remote_mirror) + + remote_mirror.after_sent_notification end end diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index 7eae07d3f6b..a9b88a133be 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -15,19 +15,19 @@ class RepositoryForkWorker return target_project.import_state.mark_as_failed(_('Source project cannot be found.')) end - fork_repository(target_project, source_project.repository_storage, source_project.disk_path) + fork_repository(target_project, source_project) end private - def fork_repository(target_project, source_repository_storage_name, source_disk_path) + def fork_repository(target_project, source_project) return unless start_fork(target_project) Gitlab::Metrics.add_event(:fork_repository) - result = gitlab_shell.fork_repository(source_repository_storage_name, source_disk_path, - target_project.repository_storage, target_project.disk_path) - raise "Unable to fork project #{target_project.id} for repository #{source_disk_path} -> #{target_project.disk_path}" unless result + result = gitlab_shell.fork_repository(source_project, target_project) + + raise "Unable to fork project #{target_project.id} for repository #{source_project.disk_path} -> #{target_project.disk_path}" unless result target_project.after_import end diff --git a/app/workers/storage_migrator_worker.rb b/app/workers/storage_migrator_worker.rb deleted file mode 100644 index fa76fbac55c..00000000000 --- a/app/workers/storage_migrator_worker.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -class StorageMigratorWorker - include ApplicationWorker - - def perform(start, finish) - migrator = Gitlab::HashedStorage::Migrator.new - migrator.bulk_migrate(start, finish) - end -end diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb index e8494ffa002..4ec2b9d8fbe 100644 --- a/app/workers/update_head_pipeline_for_merge_request_worker.rb +++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb @@ -7,25 +7,8 @@ class UpdateHeadPipelineForMergeRequestWorker queue_namespace :pipeline_processing def perform(merge_request_id) - merge_request = MergeRequest.find(merge_request_id) - - sha = merge_request.diff_head_sha - pipeline = merge_request.all_pipelines(shas: sha).first - - return unless pipeline && pipeline.latest? - - if merge_request.diff_head_sha != pipeline.sha - log_error_message_for(merge_request) - - return + MergeRequest.find_by_id(merge_request_id).try do |merge_request| + merge_request.update_head_pipeline end - - merge_request.update_attribute(:head_pipeline_id, pipeline.id) - end - - def log_error_message_for(merge_request) - Rails.logger.error( - "Outdated head pipeline for active merge request: id=#{merge_request.id}, source_branch=#{merge_request.source_branch}, diff_head_sha=#{merge_request.diff_head_sha}" - ) end end |