Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
d35de87f96
commit
a149886179
|
|
@ -1 +1 @@
|
|||
56c571a6bf20894de96f4739cfe17ede1befc8a2
|
||||
c50b0080e9996e5db5eb4d75dfc3811618812798
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
|
|
@ -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}`;
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ query getBlobInfo($projectPath: ID!, $filePath: String!) {
|
|||
storedExternally
|
||||
rawPath
|
||||
replacePath
|
||||
canModifyBlob
|
||||
simpleViewer {
|
||||
fileType
|
||||
tooLarge
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
##
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||

|
||||
|
|
@ -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**.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`. |
|
||||
|
|
|
|||
|
|
@ -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 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ To display the Deploy Boards for a specific [environment](../../ci/environments/
|
|||

|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||

|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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|
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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`, () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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') }
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
@ -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:
|
||||
|
|
@ -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
|
||||
|
||||
Loading…
Reference in New Issue