Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-06-15 09:10:21 +00:00
parent d35de87f96
commit a149886179
63 changed files with 1973 additions and 1535 deletions

View File

@ -1 +1 @@
56c571a6bf20894de96f4739cfe17ede1befc8a2
c50b0080e9996e5db5eb4d75dfc3811618812798

View File

@ -0,0 +1,703 @@
import { isNumber } from 'lodash';
import { __, n__ } from '../../../locale';
import { getDayName, parseSeconds } from './date_format_utility';
const DAYS_IN_WEEK = 7;
export const SECONDS_IN_DAY = 86400;
export const MILLISECONDS_IN_DAY = 24 * 60 * 60 * 1000;
/**
* 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 number of days in a month for provided date.
* courtesy: https://stacko(verflow.com/a/1185804/414749
*
* @param {Date} date
*/
export const totalDaysInMonth = (date) => {
if (!date) {
return 0;
}
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
};
/**
* Returns number of days in a quarter from provided
* months array.
*
* @param {Array} quarter
*/
export const totalDaysInQuarter = (quarter) =>
quarter.reduce((acc, month) => acc + totalDaysInMonth(month), 0);
/**
* Returns list of Dates referring to Sundays of the month
* based on provided date
*
* @param {Date} date
*/
export const getSundays = (date) => {
if (!date) {
return [];
}
const daysToSunday = [
__('Saturday'),
__('Friday'),
__('Thursday'),
__('Wednesday'),
__('Tuesday'),
__('Monday'),
__('Sunday'),
];
const month = date.getMonth();
const year = date.getFullYear();
const sundays = [];
const dateOfMonth = new Date(year, month, 1);
while (dateOfMonth.getMonth() === month) {
const dayName = getDayName(dateOfMonth);
if (dayName === __('Sunday')) {
sundays.push(new Date(dateOfMonth.getTime()));
}
const daysUntilNextSunday = daysToSunday.indexOf(dayName) + 1;
dateOfMonth.setDate(dateOfMonth.getDate() + daysUntilNextSunday);
}
return sundays;
};
/**
* 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} initialStartDate
* @param {Number} 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(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
// when length is positive
if (length > 0) {
timeframe[timeframe.length - 1].setDate(totalDaysInMonth(timeframe[timeframe.length - 1]));
}
return timeframe;
};
/**
* Returns count of day within current quarter from provided date
* and array of months for the quarter
*
* Eg;
* If date is 15 Feb 2018
* and quarter is [Jan, Feb, Mar]
*
* Then 15th Feb is 46th day of the quarter
* Where 31 (days in Jan) + 15 (date of Feb).
*
* @param {Date} date
* @param {Array} quarter
*/
export const dayInQuarter = (date, quarter) => {
const dateValues = {
date: date.getDate(),
month: date.getMonth(),
};
return quarter.reduce((acc, month) => {
if (dateValues.month > month.getMonth()) {
return acc + totalDaysInMonth(month);
} else if (dateValues.month === month.getMonth()) {
return acc + dateValues.date;
}
return acc + 0;
}, 0);
};
export const millisecondsPerDay = 1000 * 60 * 60 * 24;
export const getDayDifference = (a, b) => {
const date1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
const date2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
return Math.floor((date2 - date1) / millisecondsPerDay);
};
/**
* Calculates the milliseconds between now and a given date string.
* The result cannot become negative.
*
* @param endDate date string that the time difference is calculated for
* @return {Number} number of milliseconds remaining until the given date
*/
export const calculateRemainingMilliseconds = (endDate) => {
const remainingMilliseconds = new Date(endDate).getTime() - Date.now();
return Math.max(remainingMilliseconds, 0);
};
/**
* Subtracts a given number of days from a given date and returns the new date.
*
* @param {Date} date the date that we will substract days from
* @param {Number} daysInPast number of days that are subtracted from a given date
* @returns {Date} Date in past as Date object
*/
export const getDateInPast = (date, daysInPast) =>
new Date(newDate(date).setDate(date.getDate() - daysInPast));
/**
* Adds a given number of days to a given date and returns the new date.
*
* @param {Date} date the date that we will add days to
* @param {Number} daysInFuture number of days that are added to a given date
* @returns {Date} Date in future as Date object
*/
export const getDateInFuture = (date, daysInFuture) =>
new Date(newDate(date).setDate(date.getDate() + daysInFuture));
/**
* Checks if a given date-instance was created with a valid date
*
* @param {Date} date
* @returns boolean
*/
export const isValidDate = (date) => date instanceof Date && !Number.isNaN(date.getTime());
/*
* Appending T00:00:00 makes JS assume local time and prevents it from shifting the date
* to match the user's time zone. We want to display the date in server time for now, to
* be consistent with the "edit issue -> due date" UI.
*/
export const newDateAsLocaleTime = (date) => {
const suffix = 'T00:00:00';
return new Date(`${date}${suffix}`);
};
export const beginOfDayTime = 'T00:00:00Z';
export const endOfDayTime = 'T23:59:59Z';
/**
* @param {Date} d1
* @param {Date} d2
* @param {Function} formatter
* @return {Any[]} an array of formatted dates between 2 given dates (including start&end date)
*/
export const getDatesInRange = (d1, d2, formatter = (x) => x) => {
if (!(d1 instanceof Date) || !(d2 instanceof Date)) {
return [];
}
let startDate = d1.getTime();
const endDate = d2.getTime();
const oneDay = 24 * 3600 * 1000;
const range = [d1];
while (startDate < endDate) {
startDate += oneDay;
range.push(new Date(startDate));
}
return range.map(formatter);
};
/**
* Converts the supplied number of seconds to milliseconds.
*
* @param {Number} seconds
* @return {Number} number of milliseconds
*/
export const secondsToMilliseconds = (seconds) => seconds * 1000;
/**
* Converts the supplied number of seconds to days.
*
* @param {Number} seconds
* @return {Number} number of days
*/
export const secondsToDays = (seconds) => Math.round(seconds / 86400);
/**
* Converts a numeric utc offset in seconds to +/- hours
* ie -32400 => -9 hours
* ie -12600 => -3.5 hours
*
* @param {Number} offset UTC offset in seconds as a integer
*
* @return {String} the + or - offset in hours
*/
export const secondsToHours = (offset) => {
const parsed = parseInt(offset, 10);
if (Number.isNaN(parsed) || parsed === 0) {
return `0`;
}
const num = offset / 3600;
return parseInt(num, 10) !== num ? num.toFixed(1) : num;
};
/**
* Returns the date `n` days after the date provided
*
* @param {Date} date the initial date
* @param {Number} numberOfDays number of days after
* @param {Object} [options={}] Additional options for this calculation
* @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
* This will cause Daylight Saving Time to be ignored. Defaults to `false`
* if not provided, which causes the calculation to be performed in the
* user's timezone.
*
* @return {Date} A `Date` object `n` days after the provided `Date`
*/
export const nDaysAfter = (date, numberOfDays, { utc = false } = {}) => {
const clone = newDate(date);
const cloneValue = utc
? clone.setUTCDate(date.getUTCDate() + numberOfDays)
: clone.setDate(date.getDate() + numberOfDays);
return new Date(cloneValue);
};
/**
* Returns the date `n` days before the date provided
*
* @param {Date} date the initial date
* @param {Number} numberOfDays number of days before
* @param {Object} [options={}] Additional options for this calculation
* @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
* This will cause Daylight Saving Time to be ignored. Defaults to `false`
* if not provided, which causes the calculation to be performed in the
* user's timezone.
* @return {Date} A `Date` object `n` days before the provided `Date`
*/
export const nDaysBefore = (date, numberOfDays, options) =>
nDaysAfter(date, -numberOfDays, options);
/**
* Returns the date `n` months after the date provided
*
* @param {Date} date the initial date
* @param {Number} numberOfMonths number of months after
* @param {Object} [options={}] Additional options for this calculation
* @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
* This will cause Daylight Saving Time to be ignored. Defaults to `false`
* if not provided, which causes the calculation to be performed in the
* user's timezone.
*
* @return {Date} A `Date` object `n` months after the provided `Date`
*/
export const nMonthsAfter = (date, numberOfMonths, { utc = false } = {}) => {
const clone = newDate(date);
const cloneValue = utc
? clone.setUTCMonth(date.getUTCMonth() + numberOfMonths)
: clone.setMonth(date.getMonth() + numberOfMonths);
return new Date(cloneValue);
};
/**
* Returns the date `n` months before the date provided
*
* @param {Date} date the initial date
* @param {Number} numberOfMonths number of months before
* @param {Object} [options={}] Additional options for this calculation
* @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
* This will cause Daylight Saving Time to be ignored. Defaults to `false`
* if not provided, which causes the calculation to be performed in the
* user's timezone.
*
* @return {Date} A `Date` object `n` months before the provided `Date`
*/
export const nMonthsBefore = (date, numberOfMonths, options) =>
nMonthsAfter(date, -numberOfMonths, options);
/**
* Returns the date `n` weeks after the date provided
*
* @param {Date} date the initial date
* @param {Number} numberOfWeeks number of weeks after
* @param {Object} [options={}] Additional options for this calculation
* @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
* This will cause Daylight Saving Time to be ignored. Defaults to `false`
* if not provided, which causes the calculation to be performed in the
* user's timezone.
*
* @return {Date} A `Date` object `n` weeks after the provided `Date`
*/
export const nWeeksAfter = (date, numberOfWeeks, options) =>
nDaysAfter(date, DAYS_IN_WEEK * numberOfWeeks, options);
/**
* Returns the date `n` weeks before the date provided
*
* @param {Date} date the initial date
* @param {Number} numberOfWeeks number of weeks before
* @param {Object} [options={}] Additional options for this calculation
* @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
* This will cause Daylight Saving Time to be ignored. Defaults to `false`
* if not provided, which causes the calculation to be performed in the
* user's timezone.
*
* @return {Date} A `Date` object `n` weeks before the provided `Date`
*/
export const nWeeksBefore = (date, numberOfWeeks, options) =>
nWeeksAfter(date, -numberOfWeeks, options);
/**
* Returns the date `n` years after the date provided.
*
* @param {Date} date the initial date
* @param {Number} numberOfYears number of years after
* @return {Date} A `Date` object `n` years after the provided `Date`
*/
export const nYearsAfter = (date, numberOfYears) => {
const clone = newDate(date);
clone.setFullYear(clone.getFullYear() + numberOfYears);
return clone;
};
/**
* Returns the date after the date provided
*
* @param {Date} date the initial date
* @param {Object} [options={}] Additional options for this calculation
* @param {boolean} [options.utc=false] Performs the calculation using UTC dates.
* This will cause Daylight Saving Time to be ignored. Defaults to `false`
* if not provided, which causes the calculation to be performed in the
* user's timezone.
*
* @return {Date} the date following the date provided
*/
export const dayAfter = (date, options) => nDaysAfter(date, 1, options);
/**
* A utility function which computes the difference in seconds
* between 2 dates.
*
* @param {Date} startDate the start date
* @param {Date} endDate the end date
*
* @return {Int} the difference in seconds
*/
export const differenceInSeconds = (startDate, endDate) => {
return (endDate.getTime() - startDate.getTime()) / 1000;
};
/**
* A utility function which computes the difference in months
* between 2 dates.
*
* @param {Date} startDate the start date
* @param {Date} endDate the end date
*
* @return {Int} the difference in months
*/
export const differenceInMonths = (startDate, endDate) => {
const yearDiff = endDate.getYear() - startDate.getYear();
const monthDiff = endDate.getMonth() - startDate.getMonth();
return monthDiff + 12 * yearDiff;
};
/**
* A utility function which computes the difference in milliseconds
* between 2 dates.
*
* @param {Date|Int} startDate the start date. Can be either a date object or a unix timestamp.
* @param {Date|Int} endDate the end date. Can be either a date object or a unix timestamp. Defaults to now.
*
* @return {Int} the difference in milliseconds
*/
export const differenceInMilliseconds = (startDate, endDate = Date.now()) => {
const startDateInMS = startDate instanceof Date ? startDate.getTime() : startDate;
const endDateInMS = endDate instanceof Date ? endDate.getTime() : endDate;
return endDateInMS - startDateInMS;
};
/**
* A utility which returns a new date at the first day of the month for any given date.
*
* @param {Date} date
*
* @return {Date} the date at the first day of the month
*/
export const dateAtFirstDayOfMonth = (date) => new Date(newDate(date).setDate(1));
/**
* A utility function which checks if two dates match.
*
* @param {Date|Int} date1 Can be either a date object or a unix timestamp.
* @param {Date|Int} date2 Can be either a date object or a unix timestamp.
*
* @return {Boolean} true if the dates match
*/
export const datesMatch = (date1, date2) => differenceInMilliseconds(date1, date2) === 0;
/**
* A utility function which checks if two date ranges overlap.
*
* @param {Object} givenPeriodLeft - the first period to compare.
* @param {Object} givenPeriodRight - the second period to compare.
* @returns {Object} { overlap: number of days the overlap is present, overlapStartDate: the start date of the overlap in time format, overlapEndDate: the end date of the overlap in time format }
* @throws {Error} Uncaught Error: Invalid period
*
* @example
* getOverlappingDaysInPeriods(
* { start: new Date(2021, 0, 11), end: new Date(2021, 0, 13) },
* { start: new Date(2021, 0, 11), end: new Date(2021, 0, 14) }
* ) => { daysOverlap: 2, overlapStartDate: 1610323200000, overlapEndDate: 1610496000000 }
*
*/
export const getOverlappingDaysInPeriods = (givenPeriodLeft = {}, givenPeriodRight = {}) => {
const leftStartTime = new Date(givenPeriodLeft.start).getTime();
const leftEndTime = new Date(givenPeriodLeft.end).getTime();
const rightStartTime = new Date(givenPeriodRight.start).getTime();
const rightEndTime = new Date(givenPeriodRight.end).getTime();
if (!(leftStartTime <= leftEndTime && rightStartTime <= rightEndTime)) {
throw new Error(__('Invalid period'));
}
const isOverlapping = leftStartTime < rightEndTime && rightStartTime < leftEndTime;
if (!isOverlapping) {
return { daysOverlap: 0 };
}
const overlapStartDate = Math.max(leftStartTime, rightStartTime);
const overlapEndDate = rightEndTime > leftEndTime ? leftEndTime : rightEndTime;
const differenceInMs = overlapEndDate - overlapStartDate;
return {
daysOverlap: Math.ceil(differenceInMs / MILLISECONDS_IN_DAY),
overlapStartDate,
overlapEndDate,
};
};
/**
* Mimics the behaviour of the rails distance_of_time_in_words function
* https://api.rubyonrails.org/v6.0.1/classes/ActionView/Helpers/DateHelper.html#method-i-distance_of_time_in_words
* 0 < -> 29 secs => less than a minute
* 30 secs < -> 1 min, 29 secs => 1 minute
* 1 min, 30 secs < -> 44 mins, 29 secs => [2..44] minutes
* 44 mins, 30 secs < -> 89 mins, 29 secs => about 1 hour
* 89 mins, 30 secs < -> 23 hrs, 59 mins, 29 secs => about[2..24]hours
* 23 hrs, 59 mins, 30 secs < -> 41 hrs, 59 mins, 29 secs => 1 day
* 41 hrs, 59 mins, 30 secs => x days
*
* @param {Number} seconds
* @return {String} approximated time
*/
export const approximateDuration = (seconds = 0) => {
if (!isNumber(seconds) || seconds < 0) {
return '';
}
const ONE_MINUTE_LIMIT = 90; // 1 minute 30s
const MINUTES_LIMIT = 2670; // 44 minutes 30s
const ONE_HOUR_LIMIT = 5370; // 89 minutes 30s
const HOURS_LIMIT = 86370; // 23 hours 59 minutes 30s
const ONE_DAY_LIMIT = 151170; // 41 hours 59 minutes 30s
const { days = 0, hours = 0, minutes = 0 } = parseSeconds(seconds, {
daysPerWeek: 7,
hoursPerDay: 24,
limitToDays: true,
});
if (seconds < 30) {
return __('less than a minute');
} else if (seconds < MINUTES_LIMIT) {
return n__('1 minute', '%d minutes', seconds < ONE_MINUTE_LIMIT ? 1 : minutes);
} else if (seconds < HOURS_LIMIT) {
return n__('about 1 hour', 'about %d hours', seconds < ONE_HOUR_LIMIT ? 1 : hours);
}
return n__('1 day', '%d days', seconds < ONE_DAY_LIMIT ? 1 : days);
};
/**
* A utility function which helps creating a date object
* for a specific date. Accepts the year, month and day
* returning a date object for the given params.
*
* @param {Int} year the full year as a number i.e. 2020
* @param {Int} month the month index i.e. January => 0
* @param {Int} day the day as a number i.e. 23
*
* @return {Date} the date object from the params
*/
export const dateFromParams = (year, month, day) => {
return new Date(year, month, day);
};
/**
* A utility function which computes a formatted 24 hour
* time string from a positive int in the range 0 - 24.
*
* @param {Int} time a positive Int between 0 and 24
*
* @returns {String} formatted 24 hour time String
*/
export const format24HourTimeStringFromInt = (time) => {
if (!Number.isInteger(time) || time < 0 || time > 24) {
return '';
}
const formatted24HourString = time > 9 ? `${time}:00` : `0${time}:00`;
return formatted24HourString;
};
/**
* A utility function that checks that the date is today
*
* @param {Date} date
*
* @return {Boolean} true if provided date is today
*/
export const isToday = (date) => {
const today = new Date();
return (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
);
};
/**
* Checks whether the date is in the past.
*
* @param {Date} date
* @return {Boolean} Returns true if the date falls before today, otherwise false.
*/
export const isInPast = (date) => !isToday(date) && differenceInMilliseconds(date, Date.now()) > 0;
/**
* Checks whether the date is in the future.
* .
* @param {Date} date
* @return {Boolean} Returns true if the date falls after today, otherwise false.
*/
export const isInFuture = (date) =>
!isToday(date) && differenceInMilliseconds(Date.now(), date) > 0;
/**
* Checks whether dateA falls before dateB.
*
* @param {Date} dateA
* @param {Date} dateB
* @return {Boolean} Returns true if dateA falls before dateB, otherwise false
*/
export const fallsBefore = (dateA, dateB) => differenceInMilliseconds(dateA, dateB) > 0;
/**
* Removes the time component of the date.
*
* @param {Date} date
* @return {Date} Returns a clone of the date with the time set to midnight
*/
export const removeTime = (date) => {
const clone = newDate(date);
clone.setHours(0, 0, 0, 0);
return clone;
};
/**
* Returns the start of the provided day
*
* @param {Object} [options={}] Additional options for this calculation
* @param {boolean} [options.utc=false] Performs the calculation using UTC time.
* If `true`, the time returned will be midnight UTC. If `false` (the default)
* the time returned will be midnight in the user's local time.
*
* @returns {Date} A new `Date` object that represents the start of the day
* of the provided date
*/
export const getStartOfDay = (date, { utc = false } = {}) => {
const clone = newDate(date);
const cloneValue = utc ? clone.setUTCHours(0, 0, 0, 0) : clone.setHours(0, 0, 0, 0);
return new Date(cloneValue);
};
/**
* Returns the start of the current week against the provide date
*
* @param {Date} date The current date instance to calculate against
* @param {Object} [options={}] Additional options for this calculation
* @param {boolean} [options.utc=false] Performs the calculation using UTC time.
* If `true`, the time returned will be midnight UTC. If `false` (the default)
* the time returned will be midnight in the user's local time.
*
* @returns {Date} A new `Date` object that represents the start of the current week
* of the provided date
*/
export const getStartOfWeek = (date, { utc = false } = {}) => {
const cloneValue = utc
? new Date(date.setUTCHours(0, 0, 0, 0))
: new Date(date.setHours(0, 0, 0, 0));
const diff = cloneValue.getDate() - cloneValue.getDay() + (cloneValue.getDay() === 0 ? -6 : 1);
return new Date(date.setDate(diff));
};
/**
* Calculates the time remaining from today in words in the format
* `n days/weeks/months/years remaining`.
*
* @param {Date} date A date in future
* @return {String} The time remaining in the format `n days/weeks/months/years remaining`
*/
export const getTimeRemainingInWords = (date) => {
const today = removeTime(new Date());
const dateInFuture = removeTime(date);
const oneWeekFromNow = nWeeksAfter(today, 1);
const oneMonthFromNow = nMonthsAfter(today, 1);
const oneYearFromNow = nYearsAfter(today, 1);
if (fallsBefore(dateInFuture, oneWeekFromNow)) {
const days = getDayDifference(today, dateInFuture);
return n__('1 day remaining', '%d days remaining', days);
}
if (fallsBefore(dateInFuture, oneMonthFromNow)) {
const weeks = Math.floor(getDayDifference(today, dateInFuture) / 7);
return n__('1 week remaining', '%d weeks remaining', weeks);
}
if (fallsBefore(dateInFuture, oneYearFromNow)) {
const months = differenceInMonths(today, dateInFuture);
return n__('1 month remaining', '%d months remaining', months);
}
const years = dateInFuture.getFullYear() - today.getFullYear();
return n__('1 year remaining', '%d years remaining', years);
};

View File

@ -0,0 +1,260 @@
import dateFormat from 'dateformat';
import { isString, mapValues, reduce } from 'lodash';
import { s__, n__, __ } from '../../../locale';
/**
* Returns i18n month names array.
* If `abbreviated` is provided, returns abbreviated
* name.
*
* @param {Boolean} abbreviated
*/
export const getMonthNames = (abbreviated) => {
if (abbreviated) {
return [
s__('Jan'),
s__('Feb'),
s__('Mar'),
s__('Apr'),
s__('May'),
s__('Jun'),
s__('Jul'),
s__('Aug'),
s__('Sep'),
s__('Oct'),
s__('Nov'),
s__('Dec'),
];
}
return [
s__('January'),
s__('February'),
s__('March'),
s__('April'),
s__('May'),
s__('June'),
s__('July'),
s__('August'),
s__('September'),
s__('October'),
s__('November'),
s__('December'),
];
};
/**
* Returns month name based on provided date.
*
* @param {Date} date
* @param {Boolean} abbreviated
*/
export const monthInWords = (date, abbreviated = false) => {
if (!date) {
return '';
}
return getMonthNames(abbreviated)[date.getMonth()];
};
export const dateInWords = (date, abbreviated = false, hideYear = false) => {
if (!date) return date;
const month = date.getMonth();
const year = date.getFullYear();
const monthName = getMonthNames(abbreviated)[month];
if (hideYear) {
return `${monthName} ${date.getDate()}`;
}
return `${monthName} ${date.getDate()}, ${year}`;
};
/**
* Similar to `timeIntervalInWords`, but rounds the return value
* to 1/10th of the largest time unit. For example:
*
* 30 => 30 seconds
* 90 => 1.5 minutes
* 7200 => 2 hours
* 86400 => 1 day
* ... etc.
*
* The largest supported unit is "days".
*
* @param {Number} intervalInSeconds The time interval in seconds
* @returns {String} A humanized description of the time interval
*/
export const humanizeTimeInterval = (intervalInSeconds) => {
if (intervalInSeconds < 60 /* = 1 minute */) {
const seconds = Math.round(intervalInSeconds * 10) / 10;
return n__('%d second', '%d seconds', seconds);
} else if (intervalInSeconds < 3600 /* = 1 hour */) {
const minutes = Math.round(intervalInSeconds / 6) / 10;
return n__('%d minute', '%d minutes', minutes);
} else if (intervalInSeconds < 86400 /* = 1 day */) {
const hours = Math.round(intervalInSeconds / 360) / 10;
return n__('%d hour', '%d hours', hours);
}
const days = Math.round(intervalInSeconds / 8640) / 10;
return n__('%d day', '%d days', days);
};
/**
* Returns i18n weekday names array.
*/
export const getWeekdayNames = () => [
__('Sunday'),
__('Monday'),
__('Tuesday'),
__('Wednesday'),
__('Thursday'),
__('Friday'),
__('Saturday'),
];
/**
* Given a date object returns the day of the week in English
* @param {date} date
* @returns {String}
*/
export const getDayName = (date) => getWeekdayNames()[date.getDay()];
/**
* Returns the i18n month name from a given date
* @example
* formatDateAsMonth(new Date('2020-06-28')) -> 'Jun'
* @param {String} datetime where month is extracted from
* @param {Object} options
* @param {Boolean} options.abbreviated whether to use the abbreviated month string, or not
* @return {String} the i18n month name
*/
export function formatDateAsMonth(datetime, options = {}) {
const { abbreviated = true } = options;
const month = new Date(datetime).getMonth();
return getMonthNames(abbreviated)[month];
}
/**
* @example
* dateFormat('2017-12-05','mmm d, yyyy h:MMtt Z' ) -> "Dec 5, 2017 12:00am UTC"
* @param {date} datetime
* @param {String} format
* @param {Boolean} UTC convert local time to UTC
* @returns {String}
*/
export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z', utc = false) => {
if (isString(datetime) && datetime.match(/\d+-\d+\d+ /)) {
throw new Error(__('Invalid date'));
}
return dateFormat(datetime, format, utc);
};
/**
* Formats milliseconds as timestamp (e.g. 01:02:03).
* This takes durations longer than a day into account (e.g. two days would be 48:00:00).
*
* @param milliseconds
* @returns {string}
*/
export const formatTime = (milliseconds) => {
const remainingSeconds = Math.floor(milliseconds / 1000) % 60;
const remainingMinutes = Math.floor(milliseconds / 1000 / 60) % 60;
const remainingHours = Math.floor(milliseconds / 1000 / 60 / 60);
let formattedTime = '';
if (remainingHours < 10) formattedTime += '0';
formattedTime += `${remainingHours}:`;
if (remainingMinutes < 10) formattedTime += '0';
formattedTime += `${remainingMinutes}:`;
if (remainingSeconds < 10) formattedTime += '0';
formattedTime += remainingSeconds;
return formattedTime;
};
/**
* Port of ruby helper time_interval_in_words.
*
* @param {Number} seconds
* @return {String}
*/
export const timeIntervalInWords = (intervalInSeconds) => {
const secondsInteger = parseInt(intervalInSeconds, 10);
const minutes = Math.floor(secondsInteger / 60);
const seconds = secondsInteger - minutes * 60;
const secondsText = n__('%d second', '%d seconds', seconds);
return minutes >= 1
? [n__('%d minute', '%d minutes', minutes), secondsText].join(' ')
: secondsText;
};
/**
* Accepts a timeObject (see parseSeconds) and returns a condensed string representation of it
* (e.g. '1w 2d 3h 1m' or '1h 30m'). Zero value units are not included.
* If the 'fullNameFormat' param is passed it returns a non condensed string eg '1 week 3 days'
*/
export const stringifyTime = (timeObject, fullNameFormat = false) => {
const reducedTime = reduce(
timeObject,
(memo, unitValue, unitName) => {
const isNonZero = Boolean(unitValue);
if (fullNameFormat && isNonZero) {
// Remove traling 's' if unit value is singular
const formattedUnitName = unitValue > 1 ? unitName : unitName.replace(/s$/, '');
return `${memo} ${unitValue} ${formattedUnitName}`;
}
return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo;
},
'',
).trim();
return reducedTime.length ? reducedTime : '0m';
};
/**
* Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # }
* Seconds can be negative or positive, zero or non-zero. Can be configured for any day
* or week length.
*/
export const parseSeconds = (
seconds,
{ daysPerWeek = 5, hoursPerDay = 8, limitToHours = false, limitToDays = false } = {},
) => {
const DAYS_PER_WEEK = daysPerWeek;
const HOURS_PER_DAY = hoursPerDay;
const SECONDS_PER_MINUTE = 60;
const MINUTES_PER_HOUR = 60;
const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR;
const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR;
const timePeriodConstraints = {
weeks: MINUTES_PER_WEEK,
days: MINUTES_PER_DAY,
hours: MINUTES_PER_HOUR,
minutes: 1,
};
if (limitToDays || limitToHours) {
timePeriodConstraints.weeks = 0;
}
if (limitToHours) {
timePeriodConstraints.days = 0;
}
let unorderedMinutes = Math.abs(seconds / SECONDS_PER_MINUTE);
return mapValues(timePeriodConstraints, (minutesPerPeriod) => {
if (minutesPerPeriod === 0) {
return 0;
}
const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod);
unorderedMinutes -= periodCount * minutesPerPeriod;
return periodCount;
});
};

View File

@ -0,0 +1,28 @@
export const pad = (val, len = 2) => `0${val}`.slice(-len);
/**
* Formats dates in Pickaday
* @param {String} dateString Date in yyyy-mm-dd format
* @return {Date} UTC format
*/
export const parsePikadayDate = (dateString) => {
const parts = dateString.split('-');
const year = parseInt(parts[0], 10);
const month = parseInt(parts[1] - 1, 10);
const day = parseInt(parts[2], 10);
return new Date(year, month, day);
};
/**
* Used `onSelect` method in pickaday
* @param {Date} date UTC format
* @return {String} Date formatted in yyyy-mm-dd
*/
export const pikadayToString = (date) => {
const day = pad(date.getDate());
const month = pad(date.getMonth() + 1);
const year = date.getFullYear();
return `${year}-${month}-${day}`;
};

View File

@ -0,0 +1,124 @@
import $ from 'jquery';
import * as timeago from 'timeago.js';
import { languageCode, s__ } from '../../../locale';
import { formatDate } from './date_format_utility';
window.timeago = timeago;
/**
* Timeago uses underscores instead of dashes to separate language from country code.
*
* see https://github.com/hustcc/timeago.js/tree/v3.0.0/locales
*/
const timeagoLanguageCode = languageCode().replace(/-/g, '_');
/**
* Registers timeago locales
*/
const memoizedLocaleRemaining = () => {
const cache = [];
const timeAgoLocaleRemaining = [
() => [s__('Timeago|just now'), s__('Timeago|right now')],
() => [s__('Timeago|just now'), 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|just now'), 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());
export const getTimeago = () => timeago;
/**
* For the given elements, sets a tooltip with a formatted date.
* @param {JQuery} $timeagoEls
* @param {Boolean} setTimeago
*/
export const localTimeAgo = ($timeagoEls, setTimeago = true) => {
$timeagoEls.each((i, el) => {
$(el).text(timeago.format($(el).attr('datetime'), timeagoLanguageCode));
});
if (!setTimeago) {
return;
}
function addTimeAgoTooltip() {
$timeagoEls.each((i, el) => {
// Recreate with custom template
el.setAttribute('title', formatDate(el.dateTime));
});
}
requestIdleCallback(addTimeAgoTooltip);
};
/**
* Returns remaining or passed time over the given time.
* @param {*} time
* @param {*} expiredLabel
*/
export const timeFor = (time, expiredLabel) => {
if (!time) {
return '';
}
if (new Date(time) < new Date()) {
return expiredLabel || s__('Timeago|Past due');
}
return timeago.format(time, `${timeagoLanguageCode}-remaining`).trim();
};
window.gl = window.gl || {};
window.gl.utils = {
...(window.gl.utils || {}),
localTimeAgo,
};

File diff suppressed because it is too large Load Diff

View File

@ -20,7 +20,7 @@ import { removeFlashClickListener } from './flash';
import initTodoToggle from './header';
import initLayoutNav from './layout_nav';
import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils';
import { localTimeAgo } from './lib/utils/datetime_utility';
import { localTimeAgo } from './lib/utils/datetime/timeago_utility';
import { getLocationHash, visitUrl } from './lib/utils/url_utility';
// everything else

View File

@ -20,12 +20,16 @@ const apolloProvider = new VueApollo({
const viewBlobEl = document.querySelector('#js-view-blob-app');
if (viewBlobEl) {
const { blobPath, projectPath } = viewBlobEl.dataset;
const { blobPath, projectPath, targetBranch, originalBranch } = viewBlobEl.dataset;
// eslint-disable-next-line no-new
new Vue({
el: viewBlobEl,
apolloProvider,
provide: {
targetBranch,
originalBranch,
},
render(createElement) {
return createElement(BlobContentViewer, {
props: {

View File

@ -33,7 +33,34 @@ export default {
},
},
apollo: {
graphqlResponse: {
/**
* The same query as `fullGraphqlResponse`, except that it limits its
* results to a single item. This causes this request to complete much more
* quickly than `fullGraphqlResponse`, which allows the page to show
* meaningful content to the user much earlier.
*/
singleGraphqlResponse: {
query: allReleasesQuery,
// This trick only works when paginating _forward_.
// When paginating backwards, limiting the query to a single item loads
// the _last_ item in the page, which is not useful for our purposes.
skip() {
return !this.includeSingleQuery;
},
variables() {
return {
...this.queryVariables,
first: 1,
};
},
update(data) {
return { data };
},
error() {
this.singleRequestError = true;
},
},
fullGraphqlResponse: {
query: allReleasesQuery,
variables() {
return this.queryVariables;
@ -42,7 +69,7 @@ export default {
return { data };
},
error(error) {
this.hasError = true;
this.fullRequestError = true;
createFlash({
message: this.$options.i18n.errorMessage,
@ -54,7 +81,8 @@ export default {
},
data() {
return {
hasError: false,
singleRequestError: false,
fullRequestError: false,
cursors: {
before: getParameterByName('before'),
after: getParameterByName('after'),
@ -83,41 +111,65 @@ export default {
sort: this.sort,
};
},
isLoading() {
return this.$apollo.queries.graphqlResponse.loading;
/**
* @returns {Boolean} Whether or not to request/include
* the results of the single-item query
*/
includeSingleQuery() {
return Boolean(!this.cursors.before || this.cursors.after);
},
isSingleRequestLoading() {
return this.$apollo.queries.singleGraphqlResponse.loading;
},
isFullRequestLoading() {
return this.$apollo.queries.fullGraphqlResponse.loading;
},
/**
* @returns {Boolean} `true` if the `singleGraphqlResponse`
* query has finished loading without errors
*/
isSingleRequestLoaded() {
return Boolean(!this.isSingleRequestLoading && this.singleGraphqlResponse?.data.project);
},
/**
* @returns {Boolean} `true` if the `fullGraphqlResponse`
* query has finished loading without errors
*/
isFullRequestLoaded() {
return Boolean(!this.isFullRequestLoading && this.fullGraphqlResponse?.data.project);
},
releases() {
if (!this.graphqlResponse || this.hasError) {
return [];
if (this.isFullRequestLoaded) {
return convertAllReleasesGraphQLResponse(this.fullGraphqlResponse).data;
}
return convertAllReleasesGraphQLResponse(this.graphqlResponse).data;
if (this.isSingleRequestLoaded && this.includeSingleQuery) {
return convertAllReleasesGraphQLResponse(this.singleGraphqlResponse).data;
}
return [];
},
pageInfo() {
if (!this.graphqlResponse || this.hasError) {
if (!this.isFullRequestLoaded) {
return {
hasPreviousPage: false,
hasNextPage: false,
};
}
return this.graphqlResponse.data.project.releases.pageInfo;
return this.fullGraphqlResponse.data.project.releases.pageInfo;
},
shouldRenderEmptyState() {
return !this.releases.length && !this.hasError && !this.isLoading;
},
shouldRenderSuccessState() {
return this.releases.length && !this.isLoading && !this.hasError;
return this.isFullRequestLoaded && this.releases.length === 0;
},
shouldRenderLoadingIndicator() {
return this.isLoading && !this.hasError;
return (
(this.isSingleRequestLoading && !this.singleRequestError && !this.isFullRequestLoaded) ||
(this.isFullRequestLoading && !this.fullRequestError)
);
},
shouldRenderPagination() {
return (
!this.isLoading &&
!this.hasError &&
(this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage)
);
return this.isFullRequestLoaded && !this.shouldRenderEmptyState;
},
},
created() {
@ -130,7 +182,7 @@ export default {
},
methods: {
getReleaseKey(release, index) {
return [release.tagNamerstrs, release.name, index].join('|');
return [release.tagName, release.name, index].join('|');
},
updateQueryParamsFromUrl() {
this.cursors.before = getParameterByName('before');
@ -191,19 +243,17 @@ export default {
>
</div>
<releases-empty-state v-if="shouldRenderEmptyState" />
<release-block
v-for="(release, index) in releases"
:key="getReleaseKey(release, index)"
:release="release"
:class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
/>
<release-skeleton-loader v-if="shouldRenderLoadingIndicator" />
<releases-empty-state v-else-if="shouldRenderEmptyState" />
<div v-else-if="shouldRenderSuccessState">
<release-block
v-for="(release, index) in releases"
:key="getReleaseKey(release, index)"
:release="release"
:class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
/>
</div>
<releases-pagination-apollo-client
v-if="shouldRenderPagination"
:page-info="pageInfo"

View File

@ -14,7 +14,19 @@ export default () => {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
defaultClient: createDefaultClient(
{},
{
// This page attempts to decrease the perceived loading time
// by sending two requests: one request for the first item only (which
// completes relatively quickly), and one for all the items (which is slower).
// By default, Apollo Client batches these requests together, which defeats
// the purpose of making separate requests. So we explicitly
// disable batching on this page.
batchMax: 1,
assumeImmutableResults: true,
},
),
});
return new Vue({

View File

@ -8,11 +8,13 @@ import createFlash from '~/flash';
import { __ } from '~/locale';
import blobInfoQuery from '../queries/blob_info.query.graphql';
import BlobHeaderEdit from './blob_header_edit.vue';
import BlobReplace from './blob_replace.vue';
export default {
components: {
BlobHeader,
BlobHeaderEdit,
BlobReplace,
BlobContent,
GlLoadingIcon,
},
@ -87,6 +89,9 @@ export default {
};
},
computed: {
isLoggedIn() {
return Boolean(gon.current_user_id);
},
isLoading() {
return this.$apollo.queries.project.loading;
},
@ -130,6 +135,13 @@ export default {
:edit-path="blobInfo.editBlobPath"
:web-ide-path="blobInfo.ideEditPath"
/>
<blob-replace
v-if="isLoggedIn"
:path="path"
:name="blobInfo.name"
:replace-path="blobInfo.replacePath"
:can-push-code="blobInfo.canModifyBlob"
/>
</template>
</blob-header>
<blob-content

View File

@ -0,0 +1,75 @@
<script>
import { GlButton, GlModalDirective } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { sprintf, __ } from '~/locale';
import getRefMixin from '../mixins/get_ref';
import UploadBlobModal from './upload_blob_modal.vue';
export default {
i18n: {
replace: __('Replace'),
replacePrimaryBtnText: __('Replace file'),
},
components: {
GlButton,
UploadBlobModal,
},
directives: {
GlModal: GlModalDirective,
},
mixins: [getRefMixin],
inject: {
targetBranch: {
default: '',
},
originalBranch: {
default: '',
},
},
props: {
name: {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
replacePath: {
type: String,
required: true,
},
canPushCode: {
type: Boolean,
required: true,
},
},
computed: {
replaceModalId() {
return uniqueId('replace-modal');
},
title() {
return sprintf(__('Replace %{name}'), { name: this.name });
},
},
};
</script>
<template>
<div class="gl-mr-3">
<gl-button v-gl-modal="replaceModalId">
{{ $options.i18n.replace }}
</gl-button>
<upload-blob-modal
:modal-id="replaceModalId"
:modal-title="title"
:commit-message="title"
:target-branch="targetBranch || ref"
:original-branch="originalBranch || ref"
:can-push-code="canPushCode"
:path="path"
:replace-path="replacePath"
:primary-btn-text="$options.i18n.replacePrimaryBtnText"
/>
</div>
</template>

View File

@ -43,7 +43,6 @@ export default {
GlAlert,
},
i18n: {
MODAL_TITLE,
COMMIT_LABEL,
TARGET_BRANCH_LABEL,
TOGGLE_CREATE_MR_LABEL,
@ -51,6 +50,16 @@ export default {
NEW_BRANCH_IN_FORK,
},
props: {
modalTitle: {
type: String,
default: MODAL_TITLE,
required: false,
},
primaryBtnText: {
type: String,
default: PRIMARY_OPTIONS_TEXT,
required: false,
},
modalId: {
type: String,
required: true,
@ -75,6 +84,11 @@ export default {
type: String,
required: true,
},
replacePath: {
type: String,
default: null,
required: false,
},
},
data() {
return {
@ -90,7 +104,7 @@ export default {
computed: {
primaryOptions() {
return {
text: PRIMARY_OPTIONS_TEXT,
text: this.primaryBtnText,
attributes: [
{
variant: 'confirm',
@ -136,6 +150,45 @@ export default {
this.file = null;
this.filePreviewURL = null;
},
submitForm() {
return this.replacePath ? this.replaceFile() : this.uploadFile();
},
submitRequest(method, url) {
return axios({
method,
url,
data: this.formData(),
headers: {
...ContentTypeMultipartFormData,
},
})
.then((response) => {
if (!this.replacePath) {
trackFileUploadEvent('click_upload_modal_form_submit');
}
visitUrl(response.data.filePath);
})
.catch(() => {
this.loading = false;
createFlash(ERROR_MESSAGE);
});
},
formData() {
const formData = new FormData();
formData.append('branch_name', this.target);
formData.append('create_merge_request', this.createNewMr);
formData.append('commit_message', this.commit);
formData.append('file', this.file);
return formData;
},
replaceFile() {
this.loading = true;
// The PUT path can be geneated from $route (similar to "uploadFile") once router is connected
// Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/332736
return this.submitRequest('put', this.replacePath);
},
uploadFile() {
this.loading = true;
@ -146,26 +199,7 @@ export default {
} = this;
const uploadPath = joinPaths(this.path, path);
const formData = new FormData();
formData.append('branch_name', this.target);
formData.append('create_merge_request', this.createNewMr);
formData.append('commit_message', this.commit);
formData.append('file', this.file);
return axios
.post(uploadPath, formData, {
headers: {
...ContentTypeMultipartFormData,
},
})
.then((response) => {
trackFileUploadEvent('click_upload_modal_form_submit');
visitUrl(response.data.filePath);
})
.catch(() => {
this.loading = false;
createFlash(ERROR_MESSAGE);
});
return this.submitRequest('post', uploadPath);
},
},
validFileMimetypes: [],
@ -175,10 +209,10 @@ export default {
<gl-form>
<gl-modal
:modal-id="modalId"
:title="$options.i18n.MODAL_TITLE"
:title="modalTitle"
:action-primary="primaryOptions"
:action-cancel="cancelOptions"
@primary.prevent="uploadFile"
@primary.prevent="submitForm"
>
<upload-dropzone
class="gl-h-200! gl-mb-4"

View File

@ -6,7 +6,7 @@ export default {
query: refQuery,
manual: true,
result({ data, loading }) {
if (!loading) {
if (data && !loading) {
this.ref = data.ref;
this.escapedRef = data.escapedRef;
}

View File

@ -15,6 +15,7 @@ query getBlobInfo($projectPath: ID!, $filePath: String!) {
storedExternally
rawPath
replacePath
canModifyBlob
simpleViewer {
fileType
tooLarge

View File

@ -76,7 +76,7 @@ class Packages::Package < ApplicationRecord
validates :version, format: { with: Gitlab::Regex.maven_version_regex }, if: -> { version? && maven? }
validates :version, format: { with: Gitlab::Regex.pypi_version_regex }, if: :pypi?
validates :version, format: { with: Gitlab::Regex.prefixed_semver_regex }, if: :golang?
validates :version, format: { with: Gitlab::Regex.prefixed_semver_regex }, if: :helm?
validates :version, format: { with: Gitlab::Regex.helm_version_regex }, if: :helm?
validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { composer_tag_version? || npm? || terraform_module? }
validates :version,
@ -269,6 +269,10 @@ class Packages::Package < ApplicationRecord
tags.pluck(:name)
end
def infrastructure_package?
terraform_module?
end
def debian_incoming?
debian? && version.nil?
end

View File

@ -2031,7 +2031,6 @@ class Project < ApplicationRecord
.append(key: 'CI_PROJECT_VISIBILITY', value: Gitlab::VisibilityLevel.string_level(visibility_level))
.append(key: 'CI_PROJECT_REPOSITORY_LANGUAGES', value: repository_languages.map(&:name).join(',').downcase)
.append(key: 'CI_DEFAULT_BRANCH', value: default_branch)
.append(key: 'CI_PROJECT_CONFIG_PATH', value: ci_config_path_or_default)
.append(key: 'CI_CONFIG_PATH', value: ci_config_path_or_default)
end

View File

@ -100,25 +100,6 @@ module NotificationRecipients
# Get project/group users with CUSTOM notification level
# rubocop: disable CodeReuse/ActiveRecord
def add_custom_notifications
return new_add_custom_notifications if Feature.enabled?(:notification_setting_recipient_refactor, project, default_enabled: :yaml)
user_ids = []
# Users with a notification setting on group or project
user_ids += user_ids_notifiable_on(project, :custom)
user_ids += user_ids_notifiable_on(group, :custom)
# Users with global level custom
user_ids_with_project_level_global = user_ids_notifiable_on(project, :global)
user_ids_with_group_level_global = user_ids_notifiable_on(group, :global)
global_users_ids = user_ids_with_project_level_global.concat(user_ids_with_group_level_global)
user_ids += user_ids_with_global_level_custom(global_users_ids, custom_action)
add_recipients(user_scope.where(id: user_ids), :custom, nil)
end
def new_add_custom_notifications
notification_by_sources = related_notification_settings_sources(:custom)
return if notification_by_sources.blank?
@ -172,22 +153,6 @@ module NotificationRecipients
# Get project users with WATCH notification level
# rubocop: disable CodeReuse/ActiveRecord
def project_watchers
return new_project_watchers if Feature.enabled?(:notification_setting_recipient_refactor, project, default_enabled: :yaml)
project_members_ids = user_ids_notifiable_on(project)
user_ids_with_project_global = user_ids_notifiable_on(project, :global)
user_ids_with_group_global = user_ids_notifiable_on(project.group, :global)
user_ids = user_ids_with_global_level_watch((user_ids_with_project_global + user_ids_with_group_global).uniq)
user_ids_with_project_setting = select_project_members_ids(user_ids_with_project_global, user_ids)
user_ids_with_group_setting = select_group_members_ids(project.group, project_members_ids, user_ids_with_group_global, user_ids)
user_scope.where(id: user_ids_with_project_setting.concat(user_ids_with_group_setting).uniq)
end
def new_project_watchers
notification_by_sources = related_notification_settings_sources(:watch)
return if notification_by_sources.blank?
@ -200,16 +165,6 @@ module NotificationRecipients
# rubocop: disable CodeReuse/ActiveRecord
def group_watchers
return new_group_watchers if Feature.enabled?(:notification_setting_recipient_refactor, project, default_enabled: :yaml)
user_ids_with_group_global = user_ids_notifiable_on(group, :global)
user_ids = user_ids_with_global_level_watch(user_ids_with_group_global)
user_ids_with_group_setting = select_group_members_ids(group, [], user_ids_with_group_global, user_ids)
user_scope.where(id: user_ids_with_group_setting)
end
def new_group_watchers
return [] unless group
user_ids = group

View File

@ -1,4 +1,6 @@
= render "projects/blob/breadcrumb", blob: blob
- project = @project.present(current_user: current_user)
- ref = local_assigns[:ref] || @ref
.info-well.d-none.d-sm-block
.well-segment
@ -12,7 +14,12 @@
- if @code_navigation_path
#js-code-navigation{ data: { code_navigation_path: @code_navigation_path, blob_path: blob.path, definition_path_prefix: project_blob_path(@project, @ref) } }
- if Feature.enabled?(:refactor_blob_viewer, @project, default_enabled: :yaml)
#js-view-blob-app{ data: { blob_path: blob.path, project_path: @project.full_path } }
-# Data info will be removed once we migrate this to use GraphQL
-# Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/330406
#js-view-blob-app{ data: { blob_path: blob.path,
project_path: @project.full_path,
target_branch: project.empty_repo? ? ref : @ref,
original_branch: @ref } }
.gl-spinner-container
= loading_icon(size: 'md')
- else

View File

@ -1,8 +0,0 @@
---
name: notification_setting_recipient_refactor
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57688
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/327303
milestone: '13.11'
type: development
group: group::code review
default_enabled: true

View File

@ -122,7 +122,7 @@ The following are required to run Geo:
The following operating systems are known to ship with a current version of OpenSSH:
- [CentOS](https://www.centos.org) 7.4+
- [Ubuntu](https://ubuntu.com) 16.04+
- PostgreSQL 11+ with [Streaming Replication](https://wiki.postgresql.org/wiki/Streaming_Replication)
- PostgreSQL 12+ with [Streaming Replication](https://wiki.postgresql.org/wiki/Streaming_Replication)
- Git 2.9+
- Git-lfs 2.4.2+ on the user side when using LFS
- All sites must run the same GitLab version.

View File

@ -219,7 +219,7 @@ the **primary** database. Use the following as a guide.
prometheus['enable'] = false
redis['enable'] = false
redis_exporter['enable'] = false
repmgr['enable'] = false
patroni['enable'] = false
sidekiq['enable'] = false
sidekiq_cluster['enable'] = false
puma['enable'] = false
@ -290,7 +290,7 @@ Configure the tracking database.
prometheus['enable'] = false
redis['enable'] = false
redis_exporter['enable'] = false
repmgr['enable'] = false
patroni['enable'] = false
sidekiq['enable'] = false
sidekiq_cluster['enable'] = false
puma['enable'] = false
@ -437,7 +437,7 @@ application servers above, with some changes to run only the `sidekiq` service:
prometheus['enable'] = false
redis['enable'] = false
redis_exporter['enable'] = false
repmgr['enable'] = false
patroni['enable'] = false
puma['enable'] = false
##

View File

@ -184,7 +184,7 @@ from [owasp.org](https://owasp.org/).
### What databases and application servers support the application?
- PostgreSQL >= 11, Redis, Sidekiq, Puma.
- PostgreSQL >= 12, Redis, Sidekiq, Puma.
### How will database connection strings, encryption keys, and other sensitive components be stored, accessed, and protected from unauthorized detection?

View File

@ -583,64 +583,6 @@ to start again from scratch, there are a few steps that can help you:
gitlab-ctl start
```
## Fixing errors during a PostgreSQL upgrade or downgrade
### Message: `ERROR: psql: FATAL: role "gitlab-consul" does not exist`
When
[upgrading PostgreSQL on a Geo instance](https://docs.gitlab.com/omnibus/settings/database.html#upgrading-a-geo-instance), you might encounter the
following error:
```plaintext
$ sudo gitlab-ctl pg-upgrade --target-version=11
Checking for an omnibus managed postgresql: OK
Checking if postgresql['version'] is set: OK
Checking if we already upgraded: NOT OK
Checking for a newer version of PostgreSQL to install
Upgrading PostgreSQL to 11.7
Checking if PostgreSQL bin files are symlinked to the expected location: OK
Waiting 30 seconds to ensure tasks complete before PostgreSQL upgrade.
See https://docs.gitlab.com/omnibus/settings/database.html#upgrade-packaged-postgresql-server for details
If you do not want to upgrade the PostgreSQL server at this time, enter Ctrl-C and see the documentation for details
Please hit Ctrl-C now if you want to cancel the operation.
..............................Detected an HA cluster.
Error running command: /opt/gitlab/embedded/bin/psql -qt -d gitlab_repmgr -h /var/opt/gitlab/postgresql -p 5432 -c "SELECT name FROM repmgr_gitlab_cluster.repl_nodes WHERE type='master' AND active != 'f'" -U gitlab-consul
ERROR: psql: FATAL: role "gitlab-consul" does not exist
Traceback (most recent call last):
10: from /opt/gitlab/embedded/bin/omnibus-ctl:23:in `<main>'
9: from /opt/gitlab/embedded/bin/omnibus-ctl:23:in `load'
8: from /opt/gitlab/embedded/lib/ruby/gems/2.6.0/gems/omnibus-ctl-0.6.0/bin/omnibus-ctl:31:in `<top (required)>'
7: from /opt/gitlab/embedded/lib/ruby/gems/2.6.0/gems/omnibus-ctl-0.6.0/lib/omnibus-ctl.rb:746:in `run'
6: from /opt/gitlab/embedded/lib/ruby/gems/2.6.0/gems/omnibus-ctl-0.6.0/lib/omnibus-ctl.rb:204:in `block in add_command_under_category'
5: from /opt/gitlab/embedded/service/omnibus-ctl/pg-upgrade.rb:171:in `block in load_file'
4: from /opt/gitlab/embedded/service/omnibus-ctl-ee/lib/repmgr.rb:248:in `is_master?'
3: from /opt/gitlab/embedded/service/omnibus-ctl-ee/lib/repmgr.rb:100:in `execute_psql'
2: from /opt/gitlab/embedded/service/omnibus-ctl-ee/lib/repmgr.rb:113:in `cmd'
1: from /opt/gitlab/embedded/lib/ruby/gems/2.6.0/gems/mixlib-shellout-3.0.9/lib/mixlib/shellout.rb:287:in `error!'
/opt/gitlab/embedded/lib/ruby/gems/2.6.0/gems/mixlib-shellout-3.0.9/lib/mixlib/shellout.rb:300:in `invalid!': Expected process to exit with [0], but received '2' (Mixlib::ShellOut::ShellCommandFailed)
---- Begin output of /opt/gitlab/embedded/bin/psql -qt -d gitlab_repmgr -h /var/opt/gitlab/postgresql -p 5432 -c "SELECT name FROM repmgr_gitlab_cluster.repl_nodes WHERE type='master' AND active != 'f'" -U gitlab-consul ----
STDOUT:
STDERR: psql: FATAL: role "gitlab-consul" does not exist
---- End output of /opt/gitlab/embedded/bin/psql -qt -d gitlab_repmgr -h /var/opt/gitlab/postgresql -p 5432 -c "SELECT name FROM repmgr_gitlab_cluster.repl_nodes WHERE type='master' AND active != 'f'" -U gitlab-consul ----
Ran /opt/gitlab/embedded/bin/psql -qt -d gitlab_repmgr -h /var/opt/gitlab/postgresql -p 5432 -c "SELECT name FROM repmgr_gitlab_cluster.repl_nodes WHERE type='master' AND active != 'f'" -U gitlab-consul returned 2
```
If you are upgrading the PostgreSQL read-replica of a Geo secondary node, and
you are not using `consul` or `repmgr`, you may need to disable `consul` and/or
`repmgr` services in `gitlab.rb`:
```ruby
consul['enable'] = false
repmgr['enable'] = false
```
Then reconfigure GitLab:
```shell
sudo gitlab-ctl reconfigure
```
## Fixing errors during a failover or when promoting a secondary to a primary node
The following are possible errors that might be encountered during failover or

View File

@ -50,8 +50,8 @@ recover. See below for more details.
The following guide assumes that:
- You are using Omnibus and therefore you are using PostgreSQL 11 or later
which includes the [`pg_basebackup` tool](https://www.postgresql.org/docs/11/app-pgbasebackup.html).
- You are using Omnibus and therefore you are using PostgreSQL 12 or later
which includes the [`pg_basebackup` tool](https://www.postgresql.org/docs/12/app-pgbasebackup.html).
- You have a **primary** node already set up (the GitLab server you are
replicating from), running Omnibus' PostgreSQL (or equivalent version), and
you have a new **secondary** server set up with the same versions of the OS,
@ -187,7 +187,7 @@ There is an [issue where support is being discussed](https://gitlab.com/gitlab-o
`postgresql['md5_auth_cidr_addresses']` and `postgresql['listen_address']`.
The `listen_address` option opens PostgreSQL up to network connections with the interface
corresponding to the given address. See [the PostgreSQL documentation](https://www.postgresql.org/docs/11/runtime-config-connection.html)
corresponding to the given address. See [the PostgreSQL documentation](https://www.postgresql.org/docs/12/runtime-config-connection.html)
for more details.
NOTE:
@ -245,7 +245,7 @@ There is an [issue where support is being discussed](https://gitlab.com/gitlab-o
```
You may also want to edit the `wal_keep_segments` and `max_wal_senders` to match your
database replication requirements. Consult the [PostgreSQL - Replication documentation](https://www.postgresql.org/docs/11/runtime-config-replication.html)
database replication requirements. Consult the [PostgreSQL - Replication documentation](https://www.postgresql.org/docs/12/runtime-config-replication.html)
for more information.
1. Save the file and reconfigure GitLab for the database listen changes and
@ -468,7 +468,7 @@ data before running `pg_basebackup`.
(e.g., you know the network path is secure, or you are using a site-to-site
VPN). This is **not** safe over the public Internet!
- You can read more details about each `sslmode` in the
[PostgreSQL documentation](https://www.postgresql.org/docs/11/libpq-ssl.html#LIBPQ-SSL-PROTECTION);
[PostgreSQL documentation](https://www.postgresql.org/docs/12/libpq-ssl.html#LIBPQ-SSL-PROTECTION);
the instructions above are carefully written to ensure protection against
both passive eavesdroppers and active "man-in-the-middle" attackers.
- Change the `--slot-name` to the name of the replication slot

View File

@ -57,7 +57,7 @@ developed and tested. We aim to be compatible with most external
To set up an external database, you can either:
- Set up [streaming replication](https://www.postgresql.org/docs/11/warm-standby.html#STREAMING-REPLICATION-SLOTS) yourself (for example AWS RDS, bare metal not managed by Omnibus, etc.).
- Set up [streaming replication](https://www.postgresql.org/docs/12/warm-standby.html#STREAMING-REPLICATION-SLOTS) yourself (for example AWS RDS, bare metal not managed by Omnibus, etc.).
- Perform the Omnibus configuration manually as follows.
#### Leverage your cloud provider's tools to replicate the primary database

View File

@ -147,7 +147,7 @@ and they will assist you with any issues you are having.
You can also use `gitlab-rake`, instead of `/usr/local/bin/gitlab-rake`.
- Troubleshooting **Operations > Kubernetes** integration:
- Troubleshooting **Infrastructure > Kubernetes** integration:
- Check the output of `kubectl get events -w --all-namespaces`.
- Check the logs of pods within `gitlab-managed-apps` namespace.

View File

@ -2154,6 +2154,28 @@ Input type: `EscalationPolicyDestroyInput`
| <a id="mutationescalationpolicydestroyerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationescalationpolicydestroyescalationpolicy"></a>`escalationPolicy` | [`EscalationPolicyType`](#escalationpolicytype) | The escalation policy. |
### `Mutation.escalationPolicyUpdate`
Input type: `EscalationPolicyUpdateInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationescalationpolicyupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationescalationpolicyupdatedescription"></a>`description` | [`String`](#string) | The description of the escalation policy. |
| <a id="mutationescalationpolicyupdateid"></a>`id` | [`IncidentManagementEscalationPolicyID!`](#incidentmanagementescalationpolicyid) | The ID of the on-call schedule to create the on-call rotation in. |
| <a id="mutationescalationpolicyupdatename"></a>`name` | [`String`](#string) | The name of the escalation policy. |
| <a id="mutationescalationpolicyupdaterules"></a>`rules` | [`[EscalationRuleInput!]`](#escalationruleinput) | The steps of the escalation policy. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationescalationpolicyupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationescalationpolicyupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationescalationpolicyupdateescalationpolicy"></a>`escalationPolicy` | [`EscalationPolicyType`](#escalationpolicytype) | The escalation policy. |
### `Mutation.exportRequirements`
Input type: `ExportRequirementsInput`

View File

@ -31,7 +31,7 @@ Prerequisites:
To view a list of environments and deployments:
1. Go to the project's **Operations > Environments** page.
1. Go to the project's **Deployments > Environments** page.
The environments are displayed.
![Environments list](img/environments_list.png)
@ -57,7 +57,7 @@ You can create an environment and deployment in the UI or in your `.gitlab-ci.ym
In the UI:
1. Go to the project's **Operations > Environments** page.
1. Go to the project's **Deployments > Environments** page.
1. Select **New environment**.
1. Enter a name and external URL.
1. Select **Save**.
@ -326,7 +326,7 @@ If there is a problem with a deployment, you can retry it or roll it back.
To retry or rollback a deployment:
1. Go to the project's **Operations > Environments**.
1. Go to the project's **Deployments > Environments**.
1. Select the environment.
1. To the right of the deployment name:
- To retry a deployment, select **Re-deploy to environment**.
@ -465,7 +465,7 @@ GitLab automatically triggers the `stop_review_app` job to stop the environment.
You can view a deployment's expiration date in the GitLab UI.
1. Go to the project's **Operations > Environments** page.
1. Go to the project's **Deployments > Environments** page.
1. Select the name of the deployment.
In the top left, next to the environment name, the expiration date is displayed.
@ -474,7 +474,7 @@ In the top left, next to the environment name, the expiration date is displayed.
You can manually override a deployment's expiration date.
1. Go to the project's **Operations > Environments** page.
1. Go to the project's **Deployments > Environments** page.
1. Select the deployment name.
1. On the top right, select the thumbtack (**{thumbtack}**).
@ -491,7 +491,7 @@ You can delete [stopped environments](#stopping-an-environment) in the GitLab UI
To delete a stopped environment in the GitLab UI:
1. Go to the project's **Operations > Environments** page.
1. Go to the project's **Deployments > Environments** page.
1. Select the **Stopped** tab.
1. Next to the environment you want to delete, select **Delete environment**.
1. On the confirmation dialog box, select **Delete environment**.

View File

@ -77,7 +77,7 @@ the **Enable Review Apps** button and GitLab prompts you with a template code bl
you can copy and paste into `.gitlab-ci.yml` as a starting point. To do so:
1. Go to the project your want to create a Review App job for.
1. From the left nav, go to **Operations** > **Environments**.
1. From the left nav, go to **Deployments > Environments**.
1. Click on the **Enable Review Apps** button. It is available to you
if you have Developer or higher [permissions](../../user/permissions.md) to that project.
1. Copy the provided code snippet and paste it into your

View File

@ -77,7 +77,7 @@ There are also [Kubernetes-specific deployment variables](../../user/project/clu
| `CI_PIPELINE_TRIGGERED` | all | all | `true` if the job was [triggered](../triggers/README.md). |
| `CI_PIPELINE_URL` | 11.1 | 0.5 | The URL for the pipeline details. |
| `CI_PIPELINE_CREATED_AT` | 13.10 | all | The UTC datetime when the pipeline was created, in [ISO 8601](https://tools.ietf.org/html/rfc3339#appendix-A) format. |
| `CI_PROJECT_CONFIG_PATH` | 13.8 | all | (Deprecated) The CI configuration path for the project. [Deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/321334) in GitLab 13.10. [Removal planned](https://gitlab.com/gitlab-org/gitlab/-/issues/322807) for GitLab 14.0. |
| `CI_PROJECT_CONFIG_PATH` | 13.8 to 13.12 | all | [Removed](https://gitlab.com/gitlab-org/gitlab/-/issues/322807) in GitLab 14.0. Use `CI_CONFIG_PATH`. |
| `CI_PROJECT_DIR` | all | all | The full path the repository is cloned to, and where the job runs from. If the GitLab Runner `builds_dir` parameter is set, this variable is set relative to the value of `builds_dir`. For more information, see the [Advanced GitLab Runner configuration](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-section). |
| `CI_PROJECT_ID` | all | all | The ID of the current project. This ID is unique across all projects on the GitLab instance. |
| `CI_PROJECT_NAME` | 8.10 | 0.5 | The name of the directory for the project. For example if the project URL is `gitlab.example.com/group-name/project-1`, `CI_PROJECT_NAME` is `project-1`. |

View File

@ -24,7 +24,7 @@ More useful links:
Snowplow is an enterprise-grade marketing and Product Intelligence platform which helps track the way users engage with our website and application.
[Snowplow](https://github.com/snowplow/snowplow) consists of the following loosely-coupled sub-systems:
[Snowplow](https://snowplowanalytics.com) consists of the following loosely-coupled sub-systems:
- **Trackers** fire Snowplow events. Snowplow has 12 trackers, covering web, mobile, desktop, server, and IoT.
- **Collectors** receive Snowplow events from trackers. We have three different event collectors, synchronizing events either to Amazon S3, Apache Kafka, or Amazon Kinesis.
@ -35,15 +35,11 @@ Snowplow is an enterprise-grade marketing and Product Intelligence platform whic
![snowplow_flow](../img/snowplow_flow.png)
## Snowplow schema
### Useful links
We have many definitions of Snowplow's schema. We have an active issue to [standardize this schema](https://gitlab.com/gitlab-org/gitlab/-/issues/207930) including the following definitions:
- Frontend and backend taxonomy as listed below
- [Structured event taxonomy](#structured-event-taxonomy)
- [Self describing events](https://github.com/snowplow/snowplow/wiki/Custom-events#self-describing-events)
- [Iglu schema](https://gitlab.com/gitlab-org/iglu/)
- [Snowplow authored events](https://github.com/snowplow/snowplow/wiki/Snowplow-authored-events)
- [Understanding the structure of Snowplow data](https://docs.snowplowanalytics.com/docs/understanding-your-pipeline/canonical-event/)
- [Our Iglu schema registry](https://gitlab.com/gitlab-org/iglu)
- [List of events used in our codebase (Event Dictionary)](dictionary.md)
## Enable Snowplow tracking
@ -57,7 +53,7 @@ Snowplow tracking is enabled on GitLab.com, and we use it for most of our tracki
To enable Snowplow tracking on a self-managed instance:
1. Go to the Admin Area (**{admin}**) and select **Settings > General**.
1. Go to the Admin Area (**{admin}**) and select **Settings > General**.
Alternatively, go to `admin/application_settings/general` in your browser.
1. Expand **Snowplow**.
@ -162,7 +158,7 @@ Snowplow JS adds many [web-specific parameters](https://docs.snowplowanalytics.c
## Implementing Snowplow JS (Frontend) tracking
GitLab provides `Tracking`, an interface that wraps the [Snowplow JavaScript Tracker](https://github.com/snowplow/snowplow/wiki/javascript-tracker) for tracking custom events. The simplest way to use it is to add `data-` attributes to clickable elements and dropdowns. There is also a Vue mixin (exposing a `track` method), and the static method `Tracking.event`. Each of these requires at minimum a `category` and an `action`. You can provide additional [Structured event taxonomy](#structured-event-taxonomy) properties along with an `extra` object that accepts key-value pairs.
GitLab provides `Tracking`, an interface that wraps the [Snowplow JavaScript Tracker](https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/javascript-trackers) for tracking custom events. The simplest way to use it is to add `data-` attributes to clickable elements and dropdowns. There is also a Vue mixin (exposing a `track` method), and the static method `Tracking.event`. Each of these requires at minimum a `category` and an `action`. You can provide additional [Structured event taxonomy](#structured-event-taxonomy) properties along with an `extra` object that accepts key-value pairs.
| field | type | default value | description |
|:-----------|:-------|:---------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
@ -406,7 +402,7 @@ describe('MyTracking', () => {
## Implementing Snowplow Ruby (Backend) tracking
GitLab provides `Gitlab::Tracking`, an interface that wraps the [Snowplow Ruby Tracker](https://github.com/snowplow/snowplow/wiki/ruby-tracker) for tracking custom events.
GitLab provides `Gitlab::Tracking`, an interface that wraps the [Snowplow Ruby Tracker](https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/ruby-tracker) for tracking custom events.
Custom event tracking and instrumentation can be added by directly calling the `GitLab::Tracking.event` class method, which accepts the following arguments:

View File

@ -38,7 +38,7 @@ with GitLab, so it's up to developers to use a compatible client library and
To create and enable a feature flag:
1. Navigate to your project's **Operations > Feature Flags**.
1. Navigate to your project's **Deployments > Feature Flags**.
1. Click the **New feature flag** button.
1. Enter a name that starts with a letter and contains only lowercase letters, digits, underscores (`_`),
or dashes (`-`), and does not end with a dash (`-`) or underscore (`_`).
@ -90,7 +90,7 @@ and the supported strategies are:
- [User List](#user-list)
Strategies can be added to feature flags when [creating a feature flag](#create-a-feature-flag),
or by editing an existing feature flag after creation by navigating to **Operations > Feature Flags**
or by editing an existing feature flag after creation by navigating to **Deployments > Feature Flags**
and clicking **{pencil}** (edit).
### All users
@ -189,7 +189,7 @@ For example:
To create a user list:
1. In your project, navigate to **Operations > Feature Flags**.
1. In your project, navigate to **Deployments > Feature Flags**.
1. Select **View user lists**
1. Select **New user list**.
1. Enter a name for the list.
@ -204,7 +204,7 @@ When viewing a list, you can rename it by clicking the **Edit** button.
To add users to a user list:
1. In your project, navigate to **Operations > Feature Flags**.
1. In your project, navigate to **Deployments > Feature Flags**.
1. Click on the **{pencil}** (edit) button next to the list you want to add users to.
1. Click on **Add Users**.
1. Enter the user IDs as a comma-separated list of values. For example,
@ -217,7 +217,7 @@ To add users to a user list:
To remove users from a user list:
1. In your project, navigate to **Operations > Feature Flags**.
1. In your project, navigate to **Deployments > Feature Flags**.
1. Click on the **{pencil}** (edit) button next to the list you want to change.
1. Click on the **{remove}** (remove) button next to the ID you want to remove.
@ -255,7 +255,7 @@ See [this video tutorial](https://www.youtube.com/watch?v=CAJY2IGep7Y) for help
In [GitLab 13.0 and earlier](https://gitlab.com/gitlab-org/gitlab/-/issues/8621),
to disable a feature flag for a specific environment:
1. Navigate to your project's **Operations > Feature Flags**.
1. Navigate to your project's **Deployments > Feature Flags**.
1. For the feature flag you want to disable, click the Pencil icon.
1. To disable the flag:
@ -269,7 +269,7 @@ to disable a feature flag for a specific environment:
To disable a feature flag for all environments:
1. Navigate to your project's **Operations > Feature Flags**.
1. Navigate to your project's **Deployments > Feature Flags**.
1. For the feature flag you want to disable, slide the Status toggle to **Disabled**.
The feature flag is displayed on the **Disabled** tab.
@ -283,7 +283,7 @@ Then prepare your application with a client library.
To get the access credentials that your application needs to communicate with GitLab:
1. Navigate to your project's **Operations > Feature Flags**.
1. Navigate to your project's **Deployments > Feature Flags**.
1. Click the **Configure** button to view the following:
- **API URL**: URL where the client (application) connects to get a list of feature flags.
- **Instance ID**: Unique token that authorizes the retrieval of the feature flags.

View File

@ -52,7 +52,7 @@ user interface:
1. Sign in to GitLab as a user with Reporter or greater
[permissions](../user/permissions.md).
1. Navigate to **Operations > Product Analytics**
1. Navigate to **Monitor > Product Analytics**.
The user interface contains:

View File

@ -209,9 +209,14 @@ Seat Link daily sends a count of all users in connected GitLab self-managed inst
Seat Link provides **only** the following information to GitLab:
- Date
- Timestamp
- License key
- Historical maximum user count
- Billable users count
- GitLab version
- Hostname
- Instance ID
- MD5 hash of license
For offline or closed network customers, the existing [true-up model](#users-over-license) is used. Prorated charges are not possible without user count data.
@ -220,6 +225,8 @@ For offline or closed network customers, the existing [true-up model](#users-ove
<pre><code>
{
gitlab_version: '13.12.0',
timestamp: '2020-01-29T18:25:57+00:00',
date: '2020-01-29',
license_key: 'ZXlKa1lYUmhJam9pWm5WNmVsTjVZekZ2YTJoV2NucDBh
RXRxTTA5amQxcG1VMVZqDQpXR3RwZEc5SGIyMVhibmxuZDJ0NWFrNXJTVzVH
@ -255,8 +262,9 @@ TjJ4eVlVUkdkWEJtDQpkSHByYWpreVJrcG9UVlo0Y0hKSU9URndiV2RzVFdO
VlhHNXRhVmszTkV0SVEzcEpNMWRyZEVoRU4ydHINCmRIRnFRVTlCVUVVM1pV
SlRORE4xUjFaYVJGb3JlWGM5UFZ4dUlpd2lhWFlpt2lKV00yRnNVbk5RTjJk
Sg0KU1hNMGExaE9SVGR2V2pKQlBUMWNiaUo5DQo=',
max_historical_user_count: 10,
billable_users_count: 6
hostname: 'gitlab.example.com',
instance_id: 'c1ac02cb-cb3f-4120-b7fe-961bbfa3abb7',
license_md5: '7cd897fffb3517dddf01b79a0889b515'
}
</code></pre>

View File

@ -116,7 +116,7 @@ or by sending requests to the [GraphQL API](../../api/graphql/getting_started.md
To use your [Deploy Board](../../user/project/deploy_boards.md):
1. Navigate to **Operations > Environments** for your project.
1. Navigate to **Deployments > Environments** for your project.
1. Set the new weight with the dropdown on the right side.
1. Confirm your selection.

View File

@ -117,7 +117,7 @@ To display the Deploy Boards for a specific [environment](../../ci/environments/
![Deploy Boards Kubernetes Label](img/deploy_boards_kubernetes_label.png)
Once all of the above are set up and the pipeline has run at least once,
navigate to the environments page under **Operations > Environments**.
navigate to the environments page under **Deployments > Environments**.
Deploy Boards are visible by default. You can explicitly click
the triangle next to their respective environment name in order to hide them.

View File

@ -33,7 +33,7 @@ and attach [release assets](#release-assets), like runbooks or packages.
To view a list of releases:
- On the left sidebar, select **Project information > Releases**, or
- On the left sidebar, select **Deployments > Releases**, or
- On the project's overview page, if at least one release exists, click the number of releases.
@ -64,7 +64,7 @@ Read more about [Release permissions](../../../user/permissions.md#project-membe
To create a new release through the GitLab UI:
1. On the left sidebar, select **Project information > Releases** and select **New release**.
1. On the left sidebar, select **Deployments > Releases** and select **New release**.
1. Open the [**Tag name**](#tag-name) dropdown. Select an existing tag or type
in a new tag name. Selecting an existing tag that is already associated with
a release will result in a validation error.
@ -104,7 +104,7 @@ Read more about [Release permissions](../../../user/permissions.md#project-membe
To edit the details of a release:
1. On the left sidebar, select **Project information > Releases**.
1. On the left sidebar, select **Deployments > Releases**.
1. In the top-right corner of the release you want to modify, click **Edit this release** (the pencil icon).
1. On the **Edit Release** page, change the release's details.
1. Click **Save changes**.
@ -150,12 +150,12 @@ the [Releases API](../../../api/releases/index.md#create-a-release).
In the user interface, to associate milestones to a release:
1. On the left sidebar, select **Project information > Releases**.
1. On the left sidebar, select **Deployments > Releases**.
1. In the top-right corner of the release you want to modify, click **Edit this release** (the pencil icon).
1. From the **Milestones** list, select each milestone you want to associate. You can select multiple milestones.
1. Click **Save changes**.
On the **Project information > Releases** page, the **Milestone** is listed in the top
On the **Deployments > Releases** page, the **Milestone** is listed in the top
section, along with statistics about the issues in the milestones.
![A Release with one associated milestone](img/release_with_milestone_v12_9.png)

View File

@ -134,7 +134,7 @@ audit trail:
include: # Execute individual project's configuration
project: '$CI_PROJECT_PATH'
file: '$CI_PROJECT_CONFIG_PATH'
file: '$CI_CONFIG_PATH'
```
##### Ensure compliance jobs are always run

View File

@ -79,9 +79,9 @@ relatively quickly to work, and they take you to another page in the project.
| <kbd>g</kbd> + <kbd>b</kbd> | Go to the project issue boards list (**Issues > Boards**). |
| <kbd>g</kbd> + <kbd>m</kbd> | Go to the project merge requests list (**Merge Requests**). |
| <kbd>g</kbd> + <kbd>j</kbd> | Go to the CI/CD jobs list (**CI/CD > Jobs**). |
| <kbd>g</kbd> + <kbd>l</kbd> | Go to the project metrics (**Operations > Metrics**). |
| <kbd>g</kbd> + <kbd>e</kbd> | Go to the project environments (**Operations > Environments**). |
| <kbd>g</kbd> + <kbd>k</kbd> | Go to the project Kubernetes cluster integration page (**Operations > Kubernetes**). Note that you must have at least [`maintainer` permissions](permissions.md) to access this page. |
| <kbd>g</kbd> + <kbd>l</kbd> | Go to the project metrics (**Monitor > Metrics**). |
| <kbd>g</kbd> + <kbd>e</kbd> | Go to the project environments (**Deployments > Environments**). |
| <kbd>g</kbd> + <kbd>k</kbd> | Go to the project Kubernetes cluster integration page (**Infrastructure > Kubernetes**). Note that you must have at least [`maintainer` permissions](permissions.md) to access this page. |
| <kbd>g</kbd> + <kbd>s</kbd> | Go to the project snippets list (**Snippets**). |
| <kbd>g</kbd> + <kbd>w</kbd> | Go to the project wiki (**Wiki**), if enabled. |

View File

@ -25,8 +25,12 @@ module API
expose :status
expose :_links do
expose :web_path do |package|
::Gitlab::Routing.url_helpers.project_package_path(package.project, package)
expose :web_path do |package, opts|
if package.infrastructure_package?
::Gitlab::Routing.url_helpers.namespace_project_infrastructure_registry_path(opts[:namespace], package.project, package)
else
::Gitlab::Routing.url_helpers.project_package_path(package.project, package)
end
end
expose :delete_api_path, if: can_destroy(:package, &:project) do |package|

View File

@ -43,7 +43,7 @@ module API
declared(params).slice(:exclude_subgroups, :order_by, :sort, :package_type, :package_name, :include_versionless, :status)
).execute
present paginate(packages), with: ::API::Entities::Package, user: current_user, group: true
present paginate(packages), with: ::API::Entities::Package, user: current_user, group: true, namespace: user_group.root_ancestor
end
end
end

View File

@ -41,7 +41,7 @@ module API
declared_params.slice(:order_by, :sort, :package_type, :package_name, :include_versionless, :status)
).execute
present paginate(packages), with: ::API::Entities::Package, user: current_user
present paginate(packages), with: ::API::Entities::Package, user: current_user, namespace: user_project.root_ancestor
end
desc 'Get a single project package' do
@ -55,7 +55,7 @@ module API
package = ::Packages::PackageFinder
.new(user_project, params[:package_id]).execute
present package, with: ::API::Entities::Package, user: current_user
present package, with: ::API::Entities::Package, user: current_user, namespace: user_project.root_ancestor
end
desc 'Remove a package' do

View File

@ -138,7 +138,8 @@ module Gitlab
end
def helm_version_regex
@helm_version_regex ||= %r{#{prefixed_semver_regex}}.freeze
# identical to semver_regex, with optional preceding 'v'
@helm_version_regex ||= Regexp.new("\\Av?#{::Gitlab::Regex.unbounded_semver_regex.source}\\z", ::Gitlab::Regex.unbounded_semver_regex.options)
end
def unbounded_semver_regex

View File

@ -3,11 +3,12 @@
return if Rails.env.production?
require 'graphql/rake_task'
require_relative '../../../tooling/graphql/docs/renderer'
namespace :gitlab do
OUTPUT_DIR = Rails.root.join("doc/api/graphql/reference")
TEMP_SCHEMA_DIR = Rails.root.join('tmp/tests/graphql')
TEMPLATES_DIR = 'lib/gitlab/graphql/docs/templates/'
TEMPLATES_DIR = 'tooling/graphql/docs/templates/'
# Make all feature flags enabled so that all feature flag
# controlled fields are considered visible and are output.
@ -110,7 +111,7 @@ namespace :gitlab do
desc 'GitLab | GraphQL | Generate GraphQL docs'
task compile_docs: [:environment, :enable_feature_flags] do
renderer = Gitlab::Graphql::Docs::Renderer.new(GitlabSchema, render_options)
renderer = Tooling::Graphql::Docs::Renderer.new(GitlabSchema, render_options)
renderer.write
@ -119,7 +120,7 @@ namespace :gitlab do
desc 'GitLab | GraphQL | Check if GraphQL docs are up to date'
task check_docs: [:environment, :enable_feature_flags] do
renderer = Gitlab::Graphql::Docs::Renderer.new(GitlabSchema, render_options)
renderer = Tooling::Graphql::Docs::Renderer.new(GitlabSchema, render_options)
doc = File.read(Rails.root.join(OUTPUT_DIR, 'index.md'))

View File

@ -1474,9 +1474,6 @@ msgstr ""
msgid "A rebase is already in progress."
msgstr ""
msgid "A rule must be provided to create an escalation policy"
msgstr ""
msgid "A secure token that identifies an external storage request."
msgstr ""
@ -3228,6 +3225,9 @@ msgstr ""
msgid "All epics"
msgstr ""
msgid "All escalations rules must have a schedule in the same project as the policy"
msgstr ""
msgid "All groups and projects"
msgstr ""
@ -13039,7 +13039,7 @@ msgstr ""
msgid "Escalation policies"
msgstr ""
msgid "Escalation policies are not supported for this project"
msgid "Escalation policies must have at least one rule"
msgstr ""
msgid "EscalationPolicies|+ Add an additional rule"
@ -17854,6 +17854,9 @@ msgstr ""
msgid "Invalid login or password"
msgstr ""
msgid "Invalid period"
msgstr ""
msgid "Invalid pin code"
msgstr ""
@ -27444,9 +27447,15 @@ msgstr ""
msgid "Replace"
msgstr ""
msgid "Replace %{name}"
msgstr ""
msgid "Replace all label(s)"
msgstr ""
msgid "Replace file"
msgstr ""
msgid "Replaced all labels with %{label_references} %{label_text}."
msgstr ""
@ -37485,21 +37494,18 @@ msgstr ""
msgid "You have imported from this project %{numberOfPreviousImports} times before. Each new import will create duplicate issues."
msgstr ""
msgid "You have insufficient permissions to configure escalation policies for this project"
msgstr ""
msgid "You have insufficient permissions to create a Todo for this alert"
msgstr ""
msgid "You have insufficient permissions to create an HTTP integration for this project"
msgstr ""
msgid "You have insufficient permissions to create an escalation policy for this project"
msgstr ""
msgid "You have insufficient permissions to create an on-call schedule for this project"
msgstr ""
msgid "You have insufficient permissions to remove an escalation policy from this project"
msgstr ""
msgid "You have insufficient permissions to remove an on-call rotation from this project"
msgstr ""

View File

@ -100,10 +100,11 @@ module QA
expect(import_page).to have_imported_group(source_group.path, wait: 180)
expect { imported_group.reload! }.to eventually_eq(source_group).within(duration: 10)
expect { imported_subgroup.reload! }.to eventually_eq(subgroup).within(duration: 30)
expect { imported_group.labels }.to eventually_include(*source_group.labels).within(duration: 10)
expect { imported_subgroup.labels }.to eventually_include(*subgroup.labels).within(duration: 30)
# Do not validate subgroups until https://gitlab.com/gitlab-org/gitlab/-/issues/332818 is resolved
# expect { imported_subgroup.reload! }.to eventually_eq(subgroup).within(duration: 30)
# expect { imported_subgroup.labels }.to eventually_include(*subgroup.labels).within(duration: 30)
end
end
end

View File

@ -100,6 +100,17 @@ RSpec.describe 'Releases (JavaScript fixtures)' do
link_type: :image)
end
let_it_be(:another_release) do
create(:release,
project: project,
tag: 'v1.2',
name: 'The second release',
author: admin,
description: 'An okay release :shrug:',
created_at: Time.zone.parse('2019-01-03'),
released_at: Time.zone.parse('2019-01-10'))
end
after(:all) do
remove_repository(project)
end

View File

@ -3,6 +3,58 @@
exports[`releases/util.js convertAllReleasesGraphQLResponse matches snapshot 1`] = `
Object {
"data": Array [
Object {
"_links": Object {
"closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.2&scope=all&state=closed",
"closedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.2&scope=all&state=closed",
"editUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.2/edit",
"mergedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.2&scope=all&state=merged",
"openedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.2&scope=all&state=opened",
"openedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.2&scope=all&state=opened",
"self": "http://localhost/releases-namespace/releases-project/-/releases/v1.2",
"selfUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.2",
},
"assets": Object {
"count": 4,
"links": Array [],
"sources": Array [
Object {
"format": "zip",
"url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.zip",
},
Object {
"format": "tar.gz",
"url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar.gz",
},
Object {
"format": "tar.bz2",
"url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar.bz2",
},
Object {
"format": "tar",
"url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar",
},
],
},
"author": Object {
"avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon",
"username": "administrator",
"webUrl": "http://localhost/administrator",
},
"commit": Object {
"shortId": "b83d6e39",
"title": "Merge branch 'branch-merged' into 'master'",
},
"commitPath": "http://localhost/releases-namespace/releases-project/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0",
"descriptionHtml": "<p data-sourcepos=\\"1:1-1:23\\" dir=\\"auto\\">An okay release <gl-emoji title=\\"shrug\\" data-name=\\"shrug\\" data-unicode-version=\\"9.0\\">🤷</gl-emoji></p>",
"evidences": Array [],
"milestones": Array [],
"name": "The second release",
"releasedAt": "2019-01-10T00:00:00Z",
"tagName": "v1.2",
"tagPath": "/releases-namespace/releases-project/-/tags/v1.2",
"upcomingRelease": true,
},
Object {
"_links": Object {
"closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=closed",
@ -124,7 +176,7 @@ Object {
"endCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMSJ9",
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMSJ9",
"startCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTktMDEtMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMiJ9",
},
}
`;

View File

@ -37,12 +37,22 @@ describe('app_index_apollo_client.vue', () => {
const after = 'afterCursor';
let wrapper;
let allReleasesQueryResponse;
let allReleasesQueryMock;
let allReleases;
let singleRelease;
let noReleases;
let queryMock;
const createComponent = (queryResponse = Promise.resolve(allReleasesQueryResponse)) => {
const createComponent = ({
singleResponse = Promise.resolve(singleRelease),
fullResponse = Promise.resolve(allReleases),
} = {}) => {
const apolloProvider = createMockApollo([
[allReleasesQuery, allReleasesQueryMock.mockReturnValueOnce(queryResponse)],
[
allReleasesQuery,
queryMock.mockImplementation((vars) => {
return vars.first === 1 ? singleResponse : fullResponse;
}),
],
]);
wrapper = shallowMountExtended(ReleasesIndexApolloClientApp, {
@ -56,8 +66,19 @@ describe('app_index_apollo_client.vue', () => {
beforeEach(() => {
mockQueryParams = {};
allReleasesQueryResponse = cloneDeep(originalAllReleasesQueryResponse);
allReleasesQueryMock = jest.fn();
allReleases = cloneDeep(originalAllReleasesQueryResponse);
singleRelease = cloneDeep(originalAllReleasesQueryResponse);
singleRelease.data.project.releases.nodes.splice(
1,
singleRelease.data.project.releases.nodes.length,
);
noReleases = cloneDeep(originalAllReleasesQueryResponse);
noReleases.data.project.releases.nodes = [];
queryMock = jest.fn();
});
afterEach(() => {
@ -73,148 +94,88 @@ describe('app_index_apollo_client.vue', () => {
const findPagination = () => wrapper.findComponent(ReleasesPaginationApolloClient);
const findSort = () => wrapper.findComponent(ReleasesSortApolloClient);
// Expectations
const expectLoadingIndicator = () => {
it('renders a loading indicator', () => {
expect(findLoadingIndicator().exists()).toBe(true);
});
};
const expectNoLoadingIndicator = () => {
it('does not render a loading indicator', () => {
expect(findLoadingIndicator().exists()).toBe(false);
});
};
const expectEmptyState = () => {
it('renders the empty state', () => {
expect(findEmptyState().exists()).toBe(true);
});
};
const expectNoEmptyState = () => {
it('does not render the empty state', () => {
expect(findEmptyState().exists()).toBe(false);
});
};
const expectFlashMessage = (message = ReleasesIndexApolloClientApp.i18n.errorMessage) => {
it(`shows a flash message that reads "${message}"`, () => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith({
message,
captureError: true,
error: expect.any(Error),
});
});
};
const expectNewReleaseButton = () => {
it('renders the "New Release" button', () => {
expect(findNewReleaseButton().exists()).toBe(true);
});
};
const expectNoFlashMessage = () => {
it(`does not show a flash message`, () => {
expect(createFlash).not.toHaveBeenCalled();
});
};
const expectReleases = (count) => {
it(`renders ${count} release(s)`, () => {
expect(findAllReleaseBlocks()).toHaveLength(count);
});
};
const expectPagination = () => {
it('renders the pagination buttons', () => {
expect(findPagination().exists()).toBe(true);
});
};
const expectNoPagination = () => {
it('does not render the pagination buttons', () => {
expect(findPagination().exists()).toBe(false);
});
};
const expectSort = () => {
it('renders the sort controls', () => {
expect(findSort().exists()).toBe(true);
});
};
// Tests
describe('when the component is loading data', () => {
beforeEach(() => {
createComponent(new Promise(() => {}));
});
describe('component states', () => {
// These need to be defined as functions, since `singleRelease` and
// `allReleases` are generated in a `beforeEach`, and therefore
// aren't available at test definition time.
const getInProgressResponse = () => new Promise(() => {});
const getErrorResponse = () => Promise.reject(new Error('Oops!'));
const getSingleRequestLoadedResponse = () => Promise.resolve(singleRelease);
const getFullRequestLoadedResponse = () => Promise.resolve(allReleases);
const getLoadedEmptyResponse = () => Promise.resolve(noReleases);
expectLoadingIndicator();
expectNoEmptyState();
expectNoFlashMessage();
expectNewReleaseButton();
expectReleases(0);
expectNoPagination();
expectSort();
});
const toDescription = (bool) => (bool ? 'does' : 'does not');
describe('when the data has successfully loaded, but there are no releases', () => {
beforeEach(() => {
allReleasesQueryResponse.data.project.releases.nodes = [];
createComponent(Promise.resolve(allReleasesQueryResponse));
});
describe.each`
description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | flashMessage | releaseCount | pagination
${'both requests loading'} | ${getInProgressResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
${'both requests failed'} | ${getErrorResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${0} | ${false}
${'both requests loaded'} | ${getSingleRequestLoadedResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
${'both requests loaded with no results'} | ${getLoadedEmptyResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false}
${'single request loading, full request loaded'} | ${getInProgressResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
${'single request loading, full request failed'} | ${getInProgressResponse} | ${getErrorResponse} | ${true} | ${false} | ${true} | ${0} | ${false}
${'single request loaded, full request loading'} | ${getSingleRequestLoadedResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${1} | ${false}
${'single request loaded, full request failed'} | ${getSingleRequestLoadedResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${1} | ${false}
${'single request failed, full request loading'} | ${getErrorResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
${'single request failed, full request loaded'} | ${getErrorResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
${'single request loaded with no results, full request loading'} | ${getLoadedEmptyResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
${'single request loading, full request loadied with no results'} | ${getInProgressResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false}
`(
'$description',
({
singleResponseFn,
fullResponseFn,
loadingIndicator,
emptyState,
flashMessage,
releaseCount,
pagination,
}) => {
beforeEach(() => {
createComponent({
singleResponse: singleResponseFn(),
fullResponse: fullResponseFn(),
});
});
expectNoLoadingIndicator();
expectEmptyState();
expectNoFlashMessage();
expectNewReleaseButton();
expectReleases(0);
expectNoPagination();
expectSort();
});
it(`${toDescription(loadingIndicator)} render a loading indicator`, () => {
expect(findLoadingIndicator().exists()).toBe(loadingIndicator);
});
describe('when an error occurs while loading data', () => {
beforeEach(() => {
createComponent(Promise.reject(new Error('Oops!')));
});
it(`${toDescription(emptyState)} render an empty state`, () => {
expect(findEmptyState().exists()).toBe(emptyState);
});
expectNoLoadingIndicator();
expectNoEmptyState();
expectFlashMessage();
expectNewReleaseButton();
expectReleases(0);
expectNoPagination();
expectSort();
});
it(`${toDescription(flashMessage)} show a flash message`, () => {
if (flashMessage) {
expect(createFlash).toHaveBeenCalledWith({
message: ReleasesIndexApolloClientApp.i18n.errorMessage,
captureError: true,
error: expect.any(Error),
});
} else {
expect(createFlash).not.toHaveBeenCalled();
}
});
describe('when the data has successfully loaded with a single page of results', () => {
beforeEach(() => {
createComponent();
});
it(`renders ${releaseCount} release(s)`, () => {
expect(findAllReleaseBlocks()).toHaveLength(releaseCount);
});
expectNoLoadingIndicator();
expectNoEmptyState();
expectNoFlashMessage();
expectNewReleaseButton();
expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length);
expectNoPagination();
});
it(`${toDescription(pagination)} render the pagination controls`, () => {
expect(findPagination().exists()).toBe(pagination);
});
describe('when the data has successfully loaded with multiple pages of results', () => {
beforeEach(() => {
allReleasesQueryResponse.data.project.releases.pageInfo.hasNextPage = true;
createComponent(Promise.resolve(allReleasesQueryResponse));
});
it('does render the "New release" button', () => {
expect(findNewReleaseButton().exists()).toBe(true);
});
expectNoLoadingIndicator();
expectNoEmptyState();
expectNoFlashMessage();
expectNewReleaseButton();
expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length);
expectPagination();
expectSort();
it('does render the sort controls', () => {
expect(findSort().exists()).toBe(true);
});
},
);
});
describe('URL parameters', () => {
@ -224,7 +185,15 @@ describe('app_index_apollo_client.vue', () => {
});
it('makes a request with the correct GraphQL query parameters', () => {
expect(allReleasesQueryMock).toHaveBeenCalledWith({
expect(queryMock).toHaveBeenCalledTimes(2);
expect(queryMock).toHaveBeenCalledWith({
first: 1,
fullPath: projectPath,
sort: DEFAULT_SORT,
});
expect(queryMock).toHaveBeenCalledWith({
first: PAGE_SIZE,
fullPath: projectPath,
sort: DEFAULT_SORT,
@ -239,7 +208,9 @@ describe('app_index_apollo_client.vue', () => {
});
it('makes a request with the correct GraphQL query parameters', () => {
expect(allReleasesQueryMock).toHaveBeenCalledWith({
expect(queryMock).toHaveBeenCalledTimes(1);
expect(queryMock).toHaveBeenCalledWith({
before,
last: PAGE_SIZE,
fullPath: projectPath,
@ -255,7 +226,16 @@ describe('app_index_apollo_client.vue', () => {
});
it('makes a request with the correct GraphQL query parameters', () => {
expect(allReleasesQueryMock).toHaveBeenCalledWith({
expect(queryMock).toHaveBeenCalledTimes(2);
expect(queryMock).toHaveBeenCalledWith({
after,
first: 1,
fullPath: projectPath,
sort: DEFAULT_SORT,
});
expect(queryMock).toHaveBeenCalledWith({
after,
first: PAGE_SIZE,
fullPath: projectPath,
@ -271,7 +251,16 @@ describe('app_index_apollo_client.vue', () => {
});
it('ignores the "before" parameter and behaves as if only the "after" parameter was provided', () => {
expect(allReleasesQueryMock).toHaveBeenCalledWith({
expect(queryMock).toHaveBeenCalledTimes(2);
expect(queryMock).toHaveBeenCalledWith({
after,
first: 1,
fullPath: projectPath,
sort: DEFAULT_SORT,
});
expect(queryMock).toHaveBeenCalledWith({
after,
first: PAGE_SIZE,
fullPath: projectPath,
@ -292,27 +281,23 @@ describe('app_index_apollo_client.vue', () => {
});
describe('pagination', () => {
beforeEach(async () => {
beforeEach(() => {
mockQueryParams = { before };
allReleasesQueryResponse.data.project.releases.pageInfo.hasNextPage = true;
createComponent(Promise.resolve(allReleasesQueryResponse));
await wrapper.vm.$nextTick();
createComponent();
});
it('requeries the GraphQL endpoint when a pagination button is clicked', async () => {
expect(allReleasesQueryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]);
expect(queryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]);
mockQueryParams = { after };
findPagination().vm.$emit('next', after);
await wrapper.vm.$nextTick();
expect(allReleasesQueryMock.mock.calls).toEqual([
expect(queryMock.mock.calls).toEqual([
[expect.objectContaining({ before })],
[expect.objectContaining({ after })],
[expect.objectContaining({ after })],
]);
});
});
@ -323,7 +308,8 @@ describe('app_index_apollo_client.vue', () => {
});
it(`sorts by ${DEFAULT_SORT} by default`, () => {
expect(allReleasesQueryMock.mock.calls).toEqual([
expect(queryMock.mock.calls).toEqual([
[expect.objectContaining({ sort: DEFAULT_SORT })],
[expect.objectContaining({ sort: DEFAULT_SORT })],
]);
});
@ -333,8 +319,10 @@ describe('app_index_apollo_client.vue', () => {
await wrapper.vm.$nextTick();
expect(allReleasesQueryMock.mock.calls).toEqual([
expect(queryMock.mock.calls).toEqual([
[expect.objectContaining({ sort: DEFAULT_SORT })],
[expect.objectContaining({ sort: DEFAULT_SORT })],
[expect.objectContaining({ sort: CREATED_ASC })],
[expect.objectContaining({ sort: CREATED_ASC })],
]);
@ -347,7 +335,8 @@ describe('app_index_apollo_client.vue', () => {
await wrapper.vm.$nextTick();
expect(allReleasesQueryMock.mock.calls).toEqual([
expect(queryMock.mock.calls).toEqual([
[expect.objectContaining({ sort: DEFAULT_SORT })],
[expect.objectContaining({ sort: DEFAULT_SORT })],
]);
@ -381,11 +370,13 @@ describe('app_index_apollo_client.vue', () => {
});
it(`resets the page's "${paramName}" pagination cursor when the sort is changed`, () => {
const firstRequestVariables = allReleasesQueryMock.mock.calls[0][0];
const secondRequestVariables = allReleasesQueryMock.mock.calls[1][0];
const firstRequestVariables = queryMock.mock.calls[0][0];
// Might be request #2 or #3, depending on the pagination direction
const mostRecentRequestVariables =
queryMock.mock.calls[queryMock.mock.calls.length - 1][0];
expect(firstRequestVariables[paramName]).toBe(paramInitialValue);
expect(secondRequestVariables[paramName]).toBeUndefined();
expect(mostRecentRequestVariables[paramName]).toBeUndefined();
});
it(`updates the URL to not include the "${paramName}" URL query parameter`, () => {

View File

@ -5,6 +5,7 @@ import BlobContent from '~/blob/components/blob_content.vue';
import BlobHeader from '~/blob/components/blob_header.vue';
import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
import BlobHeaderEdit from '~/repository/components/blob_header_edit.vue';
import BlobReplace from '~/repository/components/blob_replace.vue';
let wrapper;
const simpleMockData = {
@ -75,10 +76,11 @@ const factory = createFactory(shallowMount);
const fullFactory = createFactory(mount);
describe('Blob content viewer component', () => {
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findBlobHeader = () => wrapper.find(BlobHeader);
const findBlobHeaderEdit = () => wrapper.find(BlobHeaderEdit);
const findBlobContent = () => wrapper.find(BlobContent);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findBlobHeader = () => wrapper.findComponent(BlobHeader);
const findBlobHeaderEdit = () => wrapper.findComponent(BlobHeaderEdit);
const findBlobContent = () => wrapper.findComponent(BlobContent);
const findBlobReplace = () => wrapper.findComponent(BlobReplace);
afterEach(() => {
wrapper.destroy();
@ -169,6 +171,7 @@ describe('Blob content viewer component', () => {
mockData: { blobInfo: simpleMockData },
stubs: {
BlobContent: true,
BlobReplace: true,
},
});
@ -185,6 +188,7 @@ describe('Blob content viewer component', () => {
mockData: { blobInfo: richMockData },
stubs: {
BlobContent: true,
BlobReplace: true,
},
});
@ -195,5 +199,44 @@ describe('Blob content viewer component', () => {
webIdePath: ideEditPath,
});
});
describe('BlobReplace', () => {
const { name, path } = simpleMockData;
it('renders component', async () => {
window.gon.current_user_id = 1;
fullFactory({
mockData: { blobInfo: simpleMockData },
stubs: {
BlobContent: true,
BlobReplace: true,
},
});
await nextTick();
expect(findBlobReplace().props()).toMatchObject({
name,
path,
});
});
it('does not render if not logged in', async () => {
window.gon.current_user_id = null;
fullFactory({
mockData: { blobInfo: simpleMockData },
stubs: {
BlobContent: true,
BlobReplace: true,
},
});
await nextTick();
expect(findBlobReplace().exists()).toBe(false);
});
});
});
});

View File

@ -0,0 +1,67 @@
import { shallowMount } from '@vue/test-utils';
import BlobReplace from '~/repository/components/blob_replace.vue';
import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
const DEFAULT_PROPS = {
name: 'some name',
path: 'some/path',
canPushCode: true,
replacePath: 'some/replace/path',
};
const DEFAULT_INJECT = {
targetBranch: 'master',
originalBranch: 'master',
};
describe('BlobReplace component', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(BlobReplace, {
propsData: {
...DEFAULT_PROPS,
...props,
},
provide: {
...DEFAULT_INJECT,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal);
it('renders component', () => {
createComponent();
const { name, path } = DEFAULT_PROPS;
expect(wrapper.props()).toMatchObject({
name,
path,
});
});
it('renders UploadBlobModal', () => {
createComponent();
const { targetBranch, originalBranch } = DEFAULT_INJECT;
const { name, path, canPushCode, replacePath } = DEFAULT_PROPS;
const title = `Replace ${name}`;
expect(findUploadBlobModal().props()).toMatchObject({
modalTitle: title,
commitMessage: title,
targetBranch,
originalBranch,
canPushCode,
path,
replacePath,
primaryBtnText: 'Replace file',
});
});
});

View File

@ -200,4 +200,84 @@ describe('UploadBlobModal', () => {
});
},
);
describe('blob file submission type', () => {
const submitForm = async () => {
wrapper.vm.uploadFile = jest.fn();
wrapper.vm.replaceFile = jest.fn();
wrapper.vm.submitForm();
await wrapper.vm.$nextTick();
};
const submitRequest = async () => {
mock = new MockAdapter(axios);
findModal().vm.$emit('primary', mockEvent);
await waitForPromises();
};
describe('upload blob file', () => {
beforeEach(() => {
createComponent();
});
it('displays the default "Upload New File" modal title ', () => {
expect(findModal().props('title')).toBe('Upload New File');
});
it('display the defaul primary button text', () => {
expect(findModal().props('actionPrimary').text).toBe('Upload file');
});
it('calls the default uploadFile when the form submit', async () => {
await submitForm();
expect(wrapper.vm.uploadFile).toHaveBeenCalled();
expect(wrapper.vm.replaceFile).not.toHaveBeenCalled();
});
it('makes a POST request', async () => {
await submitRequest();
expect(mock.history.put).toHaveLength(0);
expect(mock.history.post).toHaveLength(1);
});
});
describe('replace blob file', () => {
const modalTitle = 'Replace foo.js';
const replacePath = 'replace-path';
const primaryBtnText = 'Replace file';
beforeEach(() => {
createComponent({
modalTitle,
replacePath,
primaryBtnText,
});
});
it('displays the passed modal title', () => {
expect(findModal().props('title')).toBe(modalTitle);
});
it('display the passed primary button text', () => {
expect(findModal().props('actionPrimary').text).toBe(primaryBtnText);
});
it('calls the replaceFile when the form submit', async () => {
await submitForm();
expect(wrapper.vm.replaceFile).toHaveBeenCalled();
expect(wrapper.vm.uploadFile).not.toHaveBeenCalled();
});
it('makes a PUT request', async () => {
await submitRequest();
expect(mock.history.put).toHaveLength(1);
expect(mock.history.post).toHaveLength(0);
expect(mock.history.put[0].url).toBe(replacePath);
});
});
});
});

View File

@ -675,9 +675,20 @@ RSpec.describe Gitlab::Regex do
describe '.helm_version_regex' do
subject { described_class.helm_version_regex }
it { is_expected.to match('1.2.3') }
it { is_expected.to match('1.2.3-beta') }
it { is_expected.to match('1.2.3-alpha.3') }
it { is_expected.to match('v1.2.3') }
it { is_expected.to match('v1.2.3-beta') }
it { is_expected.to match('v1.2.3-alpha.3') }
it { is_expected.not_to match('1') }
it { is_expected.not_to match('1.2') }
it { is_expected.not_to match('1./2.3') }
it { is_expected.not_to match('../../../../../1.2.3') }
it { is_expected.not_to match('%2e%2e%2f1.2.3') }
it { is_expected.not_to match('v1') }
it { is_expected.not_to match('v1.2') }
it { is_expected.not_to match('v1./2.3') }

View File

@ -2621,7 +2621,6 @@ RSpec.describe Ci::Build do
{ key: 'CI_PROJECT_VISIBILITY', value: 'private', public: true, masked: false },
{ key: 'CI_PROJECT_REPOSITORY_LANGUAGES', value: project.repository_languages.map(&:name).join(',').downcase, public: true, masked: false },
{ key: 'CI_DEFAULT_BRANCH', value: project.default_branch, public: true, masked: false },
{ key: 'CI_PROJECT_CONFIG_PATH', value: project.ci_config_path_or_default, public: true, masked: false },
{ key: 'CI_CONFIG_PATH', value: project.ci_config_path_or_default, public: true, masked: false },
{ key: 'CI_PAGES_DOMAIN', value: Gitlab.config.pages.host, public: true, masked: false },
{ key: 'CI_PAGES_URL', value: project.pages_url, public: true, masked: false },

View File

@ -404,7 +404,8 @@ RSpec.describe Packages::Package, type: :model do
it { is_expected.not_to allow_value(nil).for(:version) }
it { is_expected.not_to allow_value('').for(:version) }
it { is_expected.to allow_value('v1.2.3').for(:version) }
it { is_expected.not_to allow_value('1.2.3').for(:version) }
it { is_expected.to allow_value('1.2.3').for(:version) }
it { is_expected.not_to allow_value('v1.2').for(:version) }
end
it_behaves_like 'validating version to be SemVer compliant for', :npm_package
@ -897,6 +898,26 @@ RSpec.describe Packages::Package, type: :model do
end
end
describe '#infrastructure_package?' do
let(:package) { create(:package) }
subject { package.infrastructure_package? }
it { is_expected.to eq(false) }
context 'with generic package' do
let(:package) { create(:generic_package) }
it { is_expected.to eq(false) }
end
context 'with terraform module package' do
let(:package) { create(:terraform_module_package) }
it { is_expected.to eq(true) }
end
end
describe 'plan_limits' do
Packages::Package.package_types.keys.without('composer').each do |pt|
plan_limit_name = if pt == 'generic'

View File

@ -4666,7 +4666,6 @@ RSpec.describe Project, factory_default: :keep do
specify do
expect(subject).to include
[
{ key: 'CI_PROJECT_CONFIG_PATH', value: Ci::Pipeline::DEFAULT_CONFIG_PATH, public: true, masked: false },
{ key: 'CI_CONFIG_PATH', value: Ci::Pipeline::DEFAULT_CONFIG_PATH, public: true, masked: false }
]
end
@ -4679,7 +4678,6 @@ RSpec.describe Project, factory_default: :keep do
it do
expect(subject).to include
[
{ key: 'CI_PROJECT_CONFIG_PATH', value: 'random.yml', public: true, masked: false },
{ key: 'CI_CONFIG_PATH', value: 'random.yml', public: true, masked: false }
]
end

View File

@ -40,10 +40,36 @@ RSpec.describe API::ProjectPackages do
context 'with terraform module package' do
let_it_be(:terraform_module_package) { create(:terraform_module_package, project: project) }
it 'filters out terraform module packages when no package_type filter is set' do
subject
context 'when no package_type filter is set' do
let(:params) { {} }
expect(json_response).not_to include(a_hash_including('package_type' => 'terraform_module'))
it 'filters out terraform module packages' do
subject
expect(json_response).not_to include(a_hash_including('package_type' => 'terraform_module'))
end
it 'returns packages with the package registry web_path' do
subject
expect(json_response).to include(a_hash_including('_links' => a_hash_including('web_path' => include('packages'))))
end
end
context 'when package_type filter is set to terraform_module' do
let(:params) { { package_type: :terraform_module } }
it 'returns the terraform module package' do
subject
expect(json_response).to include(a_hash_including('package_type' => 'terraform_module'))
end
it 'returns the terraform module package with the infrastructure registry web_path' do
subject
expect(json_response).to include(a_hash_including('_links' => a_hash_including('web_path' => include('infrastructure_registry'))))
end
end
end

View File

@ -160,21 +160,7 @@ RSpec.describe NotificationRecipients::Builder::Default do
end
end
before do
stub_feature_flags(notification_setting_recipient_refactor: enabled)
end
context 'with notification_setting_recipient_refactor enabled' do
let(:enabled) { true }
it_behaves_like 'custom notification recipients'
end
context 'with notification_setting_recipient_refactor disabled' do
let(:enabled) { false }
it_behaves_like 'custom notification recipients'
end
it_behaves_like 'custom notification recipients'
end
end
end

View File

@ -1,8 +1,8 @@
# frozen_string_literal: true
require 'spec_helper'
require_relative '../../../../tooling/graphql/docs/renderer'
RSpec.describe Gitlab::Graphql::Docs::Renderer do
RSpec.describe Tooling::Graphql::Docs::Renderer do
describe '#contents' do
shared_examples 'renders correctly as GraphQL documentation' do
it 'contains the expected section' do
@ -12,7 +12,7 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
end
end
let(:template) { Rails.root.join('lib/gitlab/graphql/docs/templates/default.md.haml') }
let(:template) { Rails.root.join('tooling/graphql/docs/templates/default.md.haml') }
let(:field_description) { 'List of objects.' }
let(:type) { ::GraphQL::INT_TYPE }

View File

@ -1,8 +1,8 @@
# frozen_string_literal: true
return if Rails.env.production?
require 'gitlab/utils/strong_memoize'
module Gitlab
module Tooling
module Graphql
module Docs
# We assume a few things about the schema. We use the graphql-ruby gem, which enforces:

View File

@ -1,8 +1,8 @@
# frozen_string_literal: true
return if Rails.env.production?
require_relative 'helper'
module Gitlab
module Tooling
module Graphql
module Docs
# Gitlab renderer for graphql-docs.
@ -14,7 +14,7 @@ module Gitlab
# output_dir: The folder where the markdown files will be saved
# template: The path of the haml template to be parsed
class Renderer
include Gitlab::Graphql::Docs::Helper
include Tooling::Graphql::Docs::Helper
attr_reader :schema