diff options
Diffstat (limited to 'app')
24 files changed, 321 insertions, 155 deletions
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index b5e17a0587d..fe63ebd470d 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -1,6 +1,7 @@ import flash from '~/flash'; import $ from 'jquery'; -import { sprintf, __ } from '../../locale'; +import { __, sprintf } from '~/locale'; +import { once } from 'lodash'; // Renders diagrams and flowcharts from text using Mermaid in any element with the // `js-render-mermaid` class. @@ -18,14 +19,10 @@ import { sprintf, __ } from '../../locale'; // This is an arbitrary number; Can be iterated upon when suitable. const MAX_CHAR_LIMIT = 5000; +let mermaidModule = {}; -function renderMermaids($els) { - if (!$els.length) return; - - // A diagram may have been truncated in search results which will cause errors, so abort the render. - if (document.querySelector('body').dataset.page === 'search:show') return; - - import(/* webpackChunkName: 'mermaid' */ 'mermaid') +function importMermaidModule() { + return import(/* webpackChunkName: 'mermaid' */ 'mermaid') .then(mermaid => { mermaid.initialize({ // mermaid core options @@ -41,63 +38,127 @@ function renderMermaids($els) { securityLevel: 'strict', }); + mermaidModule = mermaid; + + return mermaid; + }) + .catch(err => { + flash(sprintf(__("Can't load mermaid module: %{err}"), { err })); + // eslint-disable-next-line no-console + console.error(err); + }); +} + +function fixElementSource(el) { + // Mermaid doesn't like `<br />` tags, so collapse all like tags into `<br>`, which is parsed correctly. + const source = el.textContent.replace(/<br\s*\/>/g, '<br>'); + + // Remove any extra spans added by the backend syntax highlighting. + Object.assign(el, { textContent: source }); + + return { source }; +} + +function renderMermaidEl(el) { + mermaidModule.init(undefined, el, id => { + const source = el.textContent; + const svg = document.getElementById(id); + + // As of https://github.com/knsv/mermaid/commit/57b780a0d, + // Mermaid will make two init callbacks:one to initialize the + // flow charts, and another to initialize the Gannt charts. + // Guard against an error caused by double initialization. + if (svg.classList.contains('mermaid')) { + return; + } + + svg.classList.add('mermaid'); + + // pre > code > svg + svg.closest('pre').replaceWith(svg); + + // We need to add the original source into the DOM to allow Copy-as-GFM + // to access it. + const sourceEl = document.createElement('text'); + sourceEl.classList.add('source'); + sourceEl.setAttribute('display', 'none'); + sourceEl.textContent = source; + + svg.appendChild(sourceEl); + }); +} + +function renderMermaids($els) { + if (!$els.length) return; + + // A diagram may have been truncated in search results which will cause errors, so abort the render. + if (document.querySelector('body').dataset.page === 'search:show') return; + + importMermaidModule() + .then(() => { let renderedChars = 0; $els.each((i, el) => { - // Mermaid doesn't like `<br />` tags, so collapse all like tags into `<br>`, which is parsed correctly. - const source = el.textContent.replace(/<br\s*\/>/g, '<br>'); - + const { source } = fixElementSource(el); /** * 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) || renderedChars > MAX_CHAR_LIMIT) { - el.textContent = sprintf( - __( - 'Cannot render the image. Maximum character count (%{charLimit}) has been exceeded.', - ), - { charLimit: MAX_CHAR_LIMIT }, - ); + const html = ` + <div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-mermaid-container js-lazy-render-mermaid-container fade show" role="alert"> + <div> + <div class="display-flex"> + <div>${__( + 'Warning: Displaying this diagram might cause performance issues on this page.', + )}</div> + <div class="gl-alert-actions"> + <button class="js-lazy-render-mermaid btn gl-alert-action btn-warning btn-md new-gl-button">Display</button> + </div> + </div> + <button type="button" class="close" data-dismiss="alert" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + </div> + </div> + `; + + const $parent = $(el).parent(); + + if (!$parent.hasClass('lazy-alert-shown')) { + $parent.after(html); + $parent.addClass('lazy-alert-shown'); + } + return; } renderedChars += source.length; - // Remove any extra spans added by the backend syntax highlighting. - Object.assign(el, { textContent: source }); - - mermaid.init(undefined, el, id => { - const svg = document.getElementById(id); - - // As of https://github.com/knsv/mermaid/commit/57b780a0d, - // Mermaid will make two init callbacks:one to initialize the - // flow charts, and another to initialize the Gannt charts. - // Guard against an error caused by double initialization. - if (svg.classList.contains('mermaid')) { - return; - } - - svg.classList.add('mermaid'); - - // pre > code > svg - svg.closest('pre').replaceWith(svg); - // We need to add the original source into the DOM to allow Copy-as-GFM - // to access it. - const sourceEl = document.createElement('text'); - sourceEl.classList.add('source'); - sourceEl.setAttribute('display', 'none'); - sourceEl.textContent = source; - - svg.appendChild(sourceEl); - }); + renderMermaidEl(el); }); }) .catch(err => { - flash(`Can't load mermaid module: ${err}`); + flash(sprintf(__('Encountered an error while rendering: %{err}'), { err })); + // eslint-disable-next-line no-console + console.error(err); }); } +const hookLazyRenderMermaidEvent = once(() => { + $(document.body).on('click', '.js-lazy-render-mermaid', function eventHandler() { + const parent = $(this).closest('.js-lazy-render-mermaid-container'); + const pre = parent.prev(); + + const el = pre.find('.js-render-mermaid'); + + parent.remove(); + + renderMermaidEl(el); + }); +}); + export default function renderMermaid($els) { if (!$els.length) return; @@ -112,4 +173,6 @@ export default function renderMermaid($els) { renderMermaids($(this).find('.js-render-mermaid')); } }); + + hookLazyRenderMermaidEvent(); } diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index bfb760f3579..c93a95e490a 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -308,6 +308,7 @@ export default { 'is-added': file.tempFile, }" class="multi-file-editor-holder" + data-qa-selector="editor_container" @focusout="triggerFilesChange" ></div> <content-viewer diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index 5cc22f62262..f8c1c3634c2 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -39,6 +39,7 @@ const populateUserInfo = user => { location: userData.location, bio: userData.bio, organization: userData.organization, + jobTitle: userData.job_title, loaded: true, }); } 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 b85be8b9652..c38272ab239 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 @@ -121,6 +121,7 @@ export default { data-placement="bottom" tabindex="0" role="button" + data-qa-selector="open_in_web_ide_button" > {{ s__('mrWidget|Open in Web IDE') }} </a> 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 ca25d9ee738..602d4ab89e1 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 @@ -1,8 +1,10 @@ <script> -import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; +import { GlPopover, GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; import { glEmojiTag } from '../../../emoji'; +import { s__ } from '~/locale'; +import { isString } from 'lodash'; export default { name: 'UserPopover', @@ -10,6 +12,7 @@ export default { Icon, GlPopover, GlSkeletonLoading, + GlSprintf, UserAvatarImage, }, props: { @@ -45,8 +48,27 @@ export default { nameIsLoading() { return !this.user.name; }, - jobInfoIsLoading() { - return !this.user.loaded && this.user.organization === null; + workInformationIsLoading() { + return !this.user.loaded && this.workInformation === null; + }, + workInformation() { + const { jobTitle, organization } = this.user; + + if (organization && jobTitle) { + return { + message: s__('Profile|%{job_title} at %{organization}'), + placeholders: { job_title: jobTitle, organization }, + }; + } else if (organization) { + return organization; + } else if (jobTitle) { + return jobTitle; + } + + return null; + }, + workInformationShouldUseSprintf() { + return !isString(this.workInformation); }, locationIsLoading() { return !this.user.loaded && this.user.location === null; @@ -72,16 +94,30 @@ export default { <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" /> </div> <div class="text-secondary"> - <div v-if="user.bio" class="js-bio d-flex mb-1"> + <div v-if="user.bio" class="d-flex mb-1"> <icon name="profile" class="category-icon flex-shrink-0" /> - <span class="ml-1">{{ user.bio }}</span> + <span ref="bio" class="ml-1">{{ user.bio }}</span> </div> - <div v-if="user.organization" class="js-organization d-flex mb-1"> - <icon v-show="!jobInfoIsLoading" name="work" class="category-icon flex-shrink-0" /> - <span class="ml-1">{{ user.organization }}</span> + <div v-if="workInformation" class="d-flex mb-1"> + <icon + v-show="!workInformationIsLoading" + name="work" + class="category-icon flex-shrink-0" + /> + <span ref="workInformation" class="ml-1"> + <gl-sprintf v-if="workInformationShouldUseSprintf" :message="workInformation.message"> + <template + v-for="(placeholder, slotName) in workInformation.placeholders" + v-slot:[slotName] + > + <span :key="slotName">{{ placeholder }}</span> + </template> + </gl-sprintf> + <span v-else>{{ workInformation }}</span> + </span> </div> <gl-skeleton-loading - v-if="jobInfoIsLoading" + v-if="workInformationIsLoading" :lines="1" class="animation-container-small mb-1" /> diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 0e4080ce201..f922d8bcaab 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -161,13 +161,17 @@ } .cover-controls { - position: absolute; - top: 10px; - right: 10px; + @include media-breakpoint-up(sm) { + position: absolute; + top: 1rem; + right: 1.25rem; + } &.left { - left: 10px; - right: auto; + @include media-breakpoint-up(sm) { + left: 1.25rem; + right: auto; + } } } diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index fd448ee24ed..621a4eddc34 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -401,3 +401,21 @@ line-height: 16px; text-align: center; } + +@mixin middle-dot-divider { + &::after { + // Duplicate `content` property used as a fallback + // scss-lint:disable DuplicateProperty + content: '\00B7'; // middle dot fallback if browser does not support alternative content + content: '\00B7' / ''; // tell screen readers to ignore the content https://www.w3.org/TR/css-content-3/#accessibility + padding: 0 0.375rem; + font-weight: $gl-font-weight-bold; + } + + &:last-child { + &::after { + content: ''; + padding: 0; + } + } +} diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 43cf0d4bd70..82b3698287c 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -74,17 +74,12 @@ // Middle dot divider between each element in a list of items. .middle-dot-divider { - &::after { - content: '\00B7'; // Middle Dot - padding: 0 6px; - font-weight: $gl-font-weight-bold; - } + @include middle-dot-divider; +} - &:last-child { - &::after { - content: ''; - padding: 0; - } +.middle-dot-divider-sm { + @include media-breakpoint-up(sm) { + @include middle-dot-divider; } } @@ -202,10 +197,6 @@ } .user-profile { - .cover-controls a { - margin-left: 5px; - } - .profile-header { margin: 0 $gl-padding; diff --git a/app/controllers/projects/tags/releases_controller.rb b/app/controllers/projects/tags/releases_controller.rb index 5e4c601a693..c1f4cbce054 100644 --- a/app/controllers/projects/tags/releases_controller.rb +++ b/app/controllers/projects/tags/releases_controller.rb @@ -12,11 +12,7 @@ class Projects::Tags::ReleasesController < Projects::ApplicationController end def update - if release_params[:description].present? - release.update(release_params) - else - release.destroy - end + release.update(release_params) if release.persisted? || release_params[:description].present? redirect_to project_tag_path(@project, tag.name) end diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb index 32c613ab4ad..1149b168383 100644 --- a/app/helpers/submodule_helper.rb +++ b/app/helpers/submodule_helper.rb @@ -66,7 +66,7 @@ module SubmoduleHelper project].join('') url_with_dotgit = url_no_dotgit + '.git' - url_with_dotgit == Gitlab::Shell.new.url_to_repo([namespace, '/', project].join('')) + url_with_dotgit == Gitlab::Shell.url_to_repo([namespace, '/', project].join('')) end def relative_self_url?(url) diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index e87bb27cf62..c1bca6b4c41 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -91,6 +91,21 @@ module UsersHelper end end + def work_information(user) + return unless user + + organization = user.organization + job_title = user.job_title + + if organization.present? && job_title.present? + s_('Profile|%{job_title} at %{organization}') % { job_title: job_title, organization: organization } + elsif job_title.present? + job_title + elsif organization.present? + organization + end + end + private def get_profile_tabs diff --git a/app/models/concerns/bulk_insert_safe.rb b/app/models/concerns/bulk_insert_safe.rb index a4814fc0d48..a61db2dc148 100644 --- a/app/models/concerns/bulk_insert_safe.rb +++ b/app/models/concerns/bulk_insert_safe.rb @@ -61,12 +61,13 @@ module BulkInsertSafe super end - # Inserts the given ActiveRecord [items] to the table mapped to this class via [InsertAll]. + # Inserts the given ActiveRecord [items] to the table mapped to this class. # Items will be inserted in batches of a given size, where insertion semantics are - # "atomic across all batches", i.e. either all items will be inserted or none. + # "atomic across all batches". # # @param [Boolean] validate Whether validations should run on [items] # @param [Integer] batch_size How many items should at most be inserted at once + # @param [Boolean] skip_duplicates Marks duplicates as allowed, and skips inserting them # @param [Proc] handle_attributes Block that will receive each item attribute hash # prior to insertion for further processing # @@ -75,26 +76,65 @@ module BulkInsertSafe # - [ActiveRecord::RecordInvalid] on entity validation failures # - [ActiveRecord::RecordNotUnique] on duplicate key errors # - # @return true if all items succeeded to be inserted, throws otherwise. + # @return true if operation succeeded, throws otherwise. # - def bulk_insert!(items, validate: true, batch_size: DEFAULT_BATCH_SIZE, &handle_attributes) - return true if items.empty? - - _bulk_insert_in_batches(items, batch_size, validate, &handle_attributes) + def bulk_insert!(items, validate: true, skip_duplicates: false, batch_size: DEFAULT_BATCH_SIZE, &handle_attributes) + _bulk_insert_all!(items, + validate: validate, + on_duplicate: skip_duplicates ? :skip : :raise, + unique_by: nil, + batch_size: batch_size, + &handle_attributes) + end - true + # Upserts the given ActiveRecord [items] to the table mapped to this class. + # Items will be inserted or updated in batches of a given size, + # where insertion semantics are "atomic across all batches". + # + # @param [Boolean] validate Whether validations should run on [items] + # @param [Integer] batch_size How many items should at most be inserted at once + # @param [Symbol/Array] unique_by Defines index or columns to use to consider item duplicate + # @param [Proc] handle_attributes Block that will receive each item attribute hash + # prior to insertion for further processing + # + # Unique indexes can be identified by columns or name: + # - unique_by: :isbn + # - unique_by: %i[ author_id name ] + # - unique_by: :index_books_on_isbn + # + # Note that this method will throw on the following occasions: + # - [PrimaryKeySetError] when primary keys are set on entities prior to insertion + # - [ActiveRecord::RecordInvalid] on entity validation failures + # - [ActiveRecord::RecordNotUnique] on duplicate key errors + # + # @return true if operation succeeded, throws otherwise. + # + def bulk_upsert!(items, unique_by:, validate: true, batch_size: DEFAULT_BATCH_SIZE, &handle_attributes) + _bulk_insert_all!(items, + validate: validate, + on_duplicate: :update, + unique_by: unique_by, + batch_size: batch_size, + &handle_attributes) end private - def _bulk_insert_in_batches(items, batch_size, validate_items, &handle_attributes) + def _bulk_insert_all!(items, on_duplicate:, unique_by:, validate:, batch_size:, &handle_attributes) + return true if items.empty? + transaction do items.each_slice(batch_size) do |item_batch| - attributes = _bulk_insert_item_attributes(item_batch, validate_items, &handle_attributes) + attributes = _bulk_insert_item_attributes( + item_batch, validate, &handle_attributes) - insert_all!(attributes) + ActiveRecord::InsertAll + .new(self, attributes, on_duplicate: on_duplicate, unique_by: unique_by) + .execute end end + + true end def _bulk_insert_item_attributes(items, validate_items) diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb index 0887236e65e..cc792eab2e0 100644 --- a/app/models/concerns/has_repository.rb +++ b/app/models/concerns/has_repository.rb @@ -9,7 +9,6 @@ # needs any special behavior. module HasRepository extend ActiveSupport::Concern - include Gitlab::ShellAdapter include AfterCommitQueue include Referable include Gitlab::Utils::StrongMemoize @@ -78,7 +77,7 @@ module HasRepository end def url_to_repo - gitlab_shell.url_to_repo(full_path) + Gitlab::Shell.url_to_repo(full_path) end def ssh_url_to_repo diff --git a/app/models/project.rb b/app/models/project.rb index 6d9a0e5e813..4017fe31b84 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1460,13 +1460,14 @@ class Project < ApplicationRecord # Forked import is handled asynchronously return if forked? && !force - if gitlab_shell.create_project_repository(self) - repository.after_create - true - else - errors.add(:base, _('Failed to create repository via gitlab-shell')) - false - end + repository.create_repository + repository.after_create + + true + rescue => err + Gitlab::ErrorTracking.track_exception(err, project: { id: id, full_path: full_path, disk_path: disk_path }) + errors.add(:base, _('Failed to create repository')) + false end def hook_attrs(backward: true) diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 8ed7811b468..f8528a41634 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class ProjectWiki - include Gitlab::ShellAdapter include Storage::LegacyProjectWiki + include Gitlab::Utils::StrongMemoize MARKUPS = { 'Markdown' => :markdown, @@ -47,7 +47,7 @@ class ProjectWiki end def url_to_repo - gitlab_shell.url_to_repo(full_path) + Gitlab::Shell.url_to_repo(full_path) end def ssh_url_to_repo @@ -64,14 +64,15 @@ class ProjectWiki # Returns the Gitlab::Git::Wiki object. def wiki - @wiki ||= begin - gl_repository = Gitlab::GlRepository::WIKI.identifier_for_container(project) - raw_repository = Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', gl_repository, full_path) + strong_memoize(:wiki) do + repository.create_if_not_exists + raise CouldNotCreateWikiError unless repository_exists? - create_repo!(raw_repository) unless raw_repository.exists? - - Gitlab::Git::Wiki.new(raw_repository) + Gitlab::Git::Wiki.new(repository.raw) end + rescue => err + Gitlab::ErrorTracking.track_exception(err, project_wiki: { project_id: project.id, full_path: full_path, disk_path: disk_path }) + raise CouldNotCreateWikiError end def repository_exists? @@ -193,14 +194,6 @@ class ProjectWiki private - def create_repo!(raw_repository) - gitlab_shell.create_wiki_repository(project) - - raise CouldNotCreateWikiError unless raw_repository.exists? - - repository.after_create - end - def commit_details(action, message = nil, title = nil) commit_message = message.presence || default_message(action, title) git_user = Gitlab::Git::User.from_gitlab(@user) diff --git a/app/models/release.rb b/app/models/release.rb index 2543717895f..45c2a56d764 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -24,7 +24,7 @@ class Release < ApplicationRecord accepts_nested_attributes_for :links, allow_destroy: true - validates :description, :project, :tag, presence: true + validates :project, :tag, presence: true validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") } scope :sorted, -> { order(released_at: :desc) } diff --git a/app/services/ci/update_ci_ref_status_service.rb b/app/services/ci/update_ci_ref_status_service.rb index e5e5b94b629..4f7ac4d11b0 100644 --- a/app/services/ci/update_ci_ref_status_service.rb +++ b/app/services/ci/update_ci_ref_status_service.rb @@ -22,7 +22,7 @@ module Ci begin retry_optimistic_lock(ref) do next false if ref.persisted? && - (ref.last_updated_by_pipeline_id || 0) >= pipeline.id + (ref.last_updated_by_pipeline_id || 0) > pipeline.id ref.update(status: next_status(ref.status, pipeline.status), last_updated_by_pipeline: pipeline) diff --git a/app/services/projects/update_repository_storage_service.rb b/app/services/projects/update_repository_storage_service.rb index 0adfd4f8fd7..0602089a3ab 100644 --- a/app/services/projects/update_repository_storage_service.rb +++ b/app/services/projects/update_repository_storage_service.rb @@ -52,11 +52,14 @@ module Projects checksum = repository.checksum # Initialize a git repository on the target path - gitlab_shell.create_repository(new_storage_key, raw_repository.relative_path, full_path) - new_repository = Gitlab::Git::Repository.new(new_storage_key, - raw_repository.relative_path, - raw_repository.gl_repository, - full_path) + new_repository = Gitlab::Git::Repository.new( + new_storage_key, + raw_repository.relative_path, + raw_repository.gl_repository, + full_path + ) + + new_repository.create_repository new_repository.replicate(raw_repository) new_checksum = new_repository.checksum diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index f5f6175d3d8..68f761c75d8 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -99,7 +99,7 @@ %p GitLab Shell %span.float-right - = Gitlab::Shell.new.version + = Gitlab::Shell.version %p GitLab Workhorse %span.float-right diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index 038befac420..d71650ae50c 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -1,5 +1,6 @@ - page_title "UI Development Kit", "Help" - lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed fermentum nisi sapien, non consequat lectus aliquam ultrices. Suspendisse sodales est euismod nunc condimentum, a consectetur diam ornare." +- link_classes = "flex-grow-1 mx-1 " .gitlab-ui-dev-kit %h1 GitLab UI development kit @@ -64,7 +65,12 @@ Cover block for profile page with avatar, name and description %code .cover-block .example - .cover-block + .cover-block.user-cover-block + = render layout: 'users/cover_controls' do + = link_to '#', class: link_classes + 'btn btn-default' do + = icon('pencil') + = link_to '#', class: link_classes + 'btn btn-default' do + = icon('rss') .avatar-holder = image_tag avatar_icon_for_email('admin@example.com', 90), class: "avatar s90", alt: '' .cover-title @@ -73,13 +79,6 @@ .cover-desc.cgray = lorem - .cover-controls - = link_to '#', class: 'btn btn-default' do - = icon('pencil') - - = link_to '#', class: 'btn btn-default' do - = icon('rss') - %h2#lists Lists .lead diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 49533c18c8f..86e157ee042 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -90,7 +90,6 @@ .row = render 'profiles/name', form: f, user: @user = f.text_field :id, readonly: true, label: s_('Profiles|User ID'), wrapper: { class: 'col-md-3' } - = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, { prompt: _('Select your role') }, required: true, class: 'input-md' = render_if_exists 'profiles/email_settings', form: f = f.text_field :skype, class: 'input-md', placeholder: s_("Profiles|username") @@ -101,6 +100,7 @@ = f.text_field :location, readonly: true, help: s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) } - else = f.text_field :location, label: s_('Profiles|Location'), class: 'input-lg', placeholder: s_("Profiles|City, country") + = f.text_field :job_title, class: 'input-md' = f.text_field :organization, label: s_('Profiles|Organization'), class: 'input-md', help: s_("Profiles|Who you represent or work for") = f.text_area :bio, label: s_('Profiles|Bio'), rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters") %hr diff --git a/app/views/users/_cover_controls.html.haml b/app/views/users/_cover_controls.html.haml new file mode 100644 index 00000000000..43278e9d232 --- /dev/null +++ b/app/views/users/_cover_controls.html.haml @@ -0,0 +1,2 @@ +.cover-controls.d-flex.px-2.pb-4.d-sm-block.p-sm-0 + = yield diff --git a/app/views/users/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml index af0a766bab0..c431a72d0e7 100644 --- a/app/views/users/_profile_basic_info.html.haml +++ b/app/views/users/_profile_basic_info.html.haml @@ -1,4 +1,4 @@ -%p +%p.mb-1.mb-sm-2.mt-2.mt-sm-3 %span.middle-dot-divider @#{@user.username} - if can?(current_user, :read_user_profile, @user) diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 3c164588b13..9f5124afc16 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -4,30 +4,31 @@ - page_title @user.blocked? ? s_('UserProfile|Blocked user') : @user.name - page_description @user.bio - header_title @user.name, user_path(@user) +- link_classes = "flex-grow-1 mx-1 " = content_for :meta_tags do = auto_discovery_link_tag(:atom, user_url(@user, format: :atom), title: "#{@user.name} activity") .user-profile .cover-block.user-cover-block{ class: [('border-bottom' if profile_tabs.empty?)] } - .cover-controls + = render layout: 'users/cover_controls' do - if @user == current_user - = link_to profile_path, class: 'btn btn-default has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile' do + = link_to profile_path, class: link_classes + 'btn btn-default has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile' do = icon('pencil') - elsif current_user - if @user.abuse_report - %button.btn.btn-danger{ title: s_('UserProfile|Already reported for abuse'), + %button{ class: link_classes + 'btn btn-danger mr-1', title: s_('UserProfile|Already reported for abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } } = icon('exclamation-circle') - else - = link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: 'btn', + = link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: link_classes + 'btn', title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do = icon('exclamation-circle') - if can?(current_user, :read_user_profile, @user) - = link_to user_path(@user, rss_url_options), class: 'btn btn-default has-tooltip', title: s_('UserProfile|Subscribe'), 'aria-label': 'Subscribe' do + = link_to user_path(@user, rss_url_options), class: link_classes + 'btn btn-default has-tooltip', title: s_('UserProfile|Subscribe'), 'aria-label': 'Subscribe' do = icon('rss') - if current_user && current_user.admin? - = link_to [:admin, @user], class: 'btn btn-default', title: s_('UserProfile|View user in admin area'), + = link_to [:admin, @user], class: link_classes + 'btn btn-default', title: s_('UserProfile|View user in admin area'), data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('users') @@ -51,10 +52,18 @@ = emoji_icon(@user.status.emoji) = markdown_field(@user.status, :message) = render "users/profile_basic_info" - .cover-desc.cgray - - unless @user.public_email.blank? - .profile-link-holder.middle-dot-divider - = link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link' + .cover-desc.cgray.mb-1.mb-sm-2 + - unless @user.location.blank? + .profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mb-1.mb-sm-0 + = sprite_icon('location', size: 16, css_class: 'vertical-align-sub fgray') + %span.vertical-align-middle + = @user.location + - unless work_information(@user).blank? + .profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline + = sprite_icon('work', size: 16, css_class: 'vertical-align-middle fgray') + %span.vertical-align-middle + = work_information(@user) + .cover-desc.cgray.mb-1.mb-sm-2 - unless @user.skype.blank? .profile-link-holder.middle-dot-divider = link_to "skype:#{@user.skype}", title: "Skype" do @@ -64,24 +73,18 @@ = link_to linkedin_url(@user), title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do = icon('linkedin-square') - unless @user.twitter.blank? - .profile-link-holder.middle-dot-divider + .profile-link-holder.middle-dot-divider-sm = link_to twitter_url(@user), title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do = icon('twitter-square') - unless @user.website_url.blank? - .profile-link-holder.middle-dot-divider + .profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0 = 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 - = sprite_icon('location', size: 16, css_class: 'vertical-align-sub') - = @user.location - - unless @user.organization.blank? - .profile-link-holder.middle-dot-divider - = sprite_icon('work', size: 16, css_class: 'vertical-align-sub') - = @user.organization - + - unless @user.public_email.blank? + .profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0 + = link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link' - if @user.bio.present? .cover-desc.cgray - %p.profile-user-bio + %p.profile-user-bio.font-italic = @user.bio - unless profile_tabs.empty? |