Merge pull request #12044 from edx/schen/ECOM-3198
ECOM-3198 Add banner image to the program listing program cards
This commit is contained in:
@@ -919,6 +919,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
|
||||
self.course_3 = CourseFactory.create()
|
||||
self.program_name = 'Testing Program'
|
||||
self.category = 'xseries'
|
||||
self.display_category = 'XSeries'
|
||||
|
||||
CourseModeFactory.create(
|
||||
course_id=self.course_1.id,
|
||||
@@ -938,6 +939,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
|
||||
programs[unicode(course)] = [{
|
||||
'id': _id,
|
||||
'category': self.category,
|
||||
'display_category': self.display_category,
|
||||
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
|
||||
'marketing_slug': 'fake-marketing-slug-xseries-1',
|
||||
'status': program_status,
|
||||
@@ -980,6 +982,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
|
||||
u'edx/demox/Run_1': [{
|
||||
'id': 0,
|
||||
'category': self.category,
|
||||
'display_category': self.display_category,
|
||||
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
|
||||
'marketing_slug': marketing_slug,
|
||||
'status': program_status,
|
||||
|
||||
@@ -2446,7 +2446,7 @@ def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invali
|
||||
'xseries' + '/{}'
|
||||
).format(program['marketing_slug'])
|
||||
})
|
||||
programs_for_course['display_category'] = 'XSeries'
|
||||
programs_for_course['display_category'] = program.get('display_category')
|
||||
programs_for_course['category'] = program.get('category')
|
||||
except KeyError:
|
||||
log.warning('Program structure is invalid, skipping display: %r', program)
|
||||
|
||||
@@ -1249,6 +1249,8 @@ base_vendor_js = [
|
||||
'js/vendor/url.min.js',
|
||||
'common/js/vendor/underscore.js',
|
||||
'common/js/vendor/underscore.string.js',
|
||||
'js/vendor/underscore.string.min.js',
|
||||
'common/js/vendor/picturefill.min.js',
|
||||
|
||||
# Make some edX UI Toolkit utilities available in the global "edx" namespace
|
||||
'edx-ui-toolkit/js/utils/global-loader.js',
|
||||
|
||||
@@ -12,10 +12,21 @@
|
||||
if (data){
|
||||
this.set({
|
||||
name: data.name,
|
||||
category: data.category,
|
||||
type: data.display_category + ' Program',
|
||||
subtitle: data.subtitle,
|
||||
organizations: data.organizations,
|
||||
marketingUrl: data.marketing_url
|
||||
marketingUrl: data.marketing_url,
|
||||
smallBannerUrl: data.banner_image_urls.w348h116,
|
||||
mediumBannerUrl: data.banner_image_urls.w435h145,
|
||||
largeBannerUrl: data.banner_image_urls.w726h242,
|
||||
breakpoints: {
|
||||
max: {
|
||||
tiny: '320px',
|
||||
small: '540px',
|
||||
medium: '768px',
|
||||
large: '979px'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
'jquery',
|
||||
'underscore',
|
||||
'gettext',
|
||||
'text!../../../templates/learner_dashboard/program_card.underscore'
|
||||
'text!../../../templates/learner_dashboard/program_card.underscore',
|
||||
'picturefill'
|
||||
],
|
||||
function(
|
||||
Backbone,
|
||||
$,
|
||||
_,
|
||||
gettext,
|
||||
programCardTpl
|
||||
programCardTpl,
|
||||
picturefill
|
||||
) {
|
||||
return Backbone.View.extend({
|
||||
className: 'program-card',
|
||||
@@ -23,6 +25,35 @@
|
||||
render: function() {
|
||||
var templated = this.tpl(this.model.toJSON());
|
||||
this.$el.html(templated);
|
||||
this.postRender();
|
||||
},
|
||||
postRender: function() {
|
||||
if(navigator.userAgent.indexOf('MSIE') !== -1 ||
|
||||
navigator.appVersion.indexOf('Trident/') > 0){
|
||||
/* Microsoft Internet Explorer detected in. */
|
||||
window.setTimeout( function() {
|
||||
this.reLoadBannerImage();
|
||||
}.bind(this), 100);
|
||||
}
|
||||
},
|
||||
// Defer loading the rest of the page to limit FOUC
|
||||
reLoadBannerImage: function() {
|
||||
var $img = this.$('.program_card .banner-image'),
|
||||
imgSrcAttr = $img ? $img.attr('src') : {};
|
||||
|
||||
if (!imgSrcAttr || imgSrcAttr.length < 0) {
|
||||
try{
|
||||
this.reEvaluatePicture();
|
||||
}catch(err){
|
||||
//Swallow the error here
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
reEvaluatePicture: function(){
|
||||
picturefill({
|
||||
reevaluate: true
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,7 +29,12 @@ define([
|
||||
modified: '2016-03-25T13:45:21.220732Z',
|
||||
marketing_slug: 'p_2?param=haha&test=b',
|
||||
id: 146,
|
||||
marketing_url: 'http://www.edx.org/xseries/p_2?param=haha&test=b'
|
||||
marketing_url: 'http://www.edx.org/xseries/p_2?param=haha&test=b',
|
||||
banner_image_urls: {
|
||||
w348h116: 'http://www.edx.org/images/org1/test1',
|
||||
w435h145: 'http://www.edx.org/images/org1/test2',
|
||||
w726h242: 'http://www.edx.org/images/org1/test3'
|
||||
}
|
||||
},
|
||||
{
|
||||
category: 'xseries',
|
||||
@@ -46,7 +51,12 @@ define([
|
||||
modified: '2016-03-09T14:30:52.840898Z',
|
||||
marketing_slug: 'gdaf',
|
||||
id: 147,
|
||||
marketing_url: 'http://www.edx.org/xseries/gdaf'
|
||||
marketing_url: 'http://www.edx.org/xseries/gdaf',
|
||||
banner_image_urls: {
|
||||
w348h116: 'http://www.edx.org/images/org2/test1',
|
||||
w435h145: 'http://www.edx.org/images/org2/test2',
|
||||
w726h242: 'http://www.edx.org/images/org2/test3'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ define([
|
||||
programModel,
|
||||
program = {
|
||||
category: 'xseries',
|
||||
display_category: 'XSeries',
|
||||
status: 'active',
|
||||
subtitle: 'program 1',
|
||||
name: 'test program 1',
|
||||
@@ -26,7 +27,12 @@ define([
|
||||
modified: '2016-03-25T13:45:21.220732Z',
|
||||
marketing_slug: 'p_2?param=haha&test=b',
|
||||
id: 146,
|
||||
marketing_url: 'http://www.edx.org/xseries/p_2?param=haha&test=b'
|
||||
marketing_url: 'http://www.edx.org/xseries/p_2?param=haha&test=b',
|
||||
banner_image_urls: {
|
||||
w348h116: 'http://www.edx.org/images/test1',
|
||||
w435h145: 'http://www.edx.org/images/test2',
|
||||
w726h242: 'http://www.edx.org/images/test3'
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
@@ -49,10 +55,26 @@ define([
|
||||
var $cards = view.$el;
|
||||
expect($cards).toBeDefined();
|
||||
expect($cards.find('.title').html().trim()).toEqual(program.name);
|
||||
expect($cards.find('.category span').html().trim()).toEqual(program.category);
|
||||
expect($cards.find('.organization span').html().trim()).toEqual(program.organizations[0].display_name);
|
||||
expect($cards.find('.category span').html().trim()).toEqual('XSeries Program');
|
||||
expect($cards.find('.organization').html().trim()).toEqual(program.organizations[0].display_name);
|
||||
expect($cards.find('.card-link').attr('href')).toEqual(program.marketing_url);
|
||||
});
|
||||
|
||||
it('should call reEvaluatePicture if reLoadBannerImage is called', function(){
|
||||
spyOn(view, 'reEvaluatePicture');
|
||||
view.reLoadBannerImage();
|
||||
expect(view.reEvaluatePicture).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle exceptions from reEvaluatePicture', function(){
|
||||
spyOn(view, 'reEvaluatePicture').andCallFake(function(){
|
||||
throw {name:'Picturefill had exceptions'};
|
||||
});
|
||||
view.reLoadBannerImage();
|
||||
expect(view.reEvaluatePicture).toHaveBeenCalled();
|
||||
expect(view.reLoadBannerImage).not.toThrow('Picturefill had exceptions');
|
||||
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
'_split': 'js/split',
|
||||
'mathjax_delay_renderer': 'coffee/src/mathjax_delay_renderer',
|
||||
'MathJaxProcessor': 'coffee/src/customwmd',
|
||||
'picturefill': 'common/js/vendor/picturefill.min',
|
||||
|
||||
// Manually specify LMS files that are not converted to RequireJS
|
||||
'history': 'js/vendor/history',
|
||||
|
||||
@@ -69,6 +69,7 @@ lib_paths:
|
||||
- xmodule_js/common_static/js/vendor/slick.core.js
|
||||
- xmodule_js/common_static/js/vendor/slick.grid.js
|
||||
- xmodule_js/common_static/js/vendor/jquery.event.drag-2.2.js
|
||||
- xmodule_js/common_static/common/js/vendor/picturefill.min.js
|
||||
|
||||
# Paths to source JavaScript files
|
||||
src_paths:
|
||||
|
||||
@@ -94,7 +94,8 @@
|
||||
"catch": "js/vendor/ova/catch/js/catch",
|
||||
"handlebars": "js/vendor/ova/catch/js/handlebars-1.1.2",
|
||||
"tinymce": "js/vendor/tinymce/js/tinymce/tinymce.full.min",
|
||||
"jquery.tinymce": "js/vendor/tinymce/js/tinymce/jquery.tinymce.min"
|
||||
"jquery.tinymce": "js/vendor/tinymce/js/tinymce/jquery.tinymce.min",
|
||||
"picturefill": "common/js/vendor/picturefill.min"
|
||||
// end of files needed by OVA
|
||||
},
|
||||
shim: {
|
||||
|
||||
@@ -3,58 +3,90 @@
|
||||
@import '../base/grid-settings';
|
||||
@import 'neat/neat'; // lib - Neat
|
||||
|
||||
$card-height: 150px;
|
||||
|
||||
.program-card{
|
||||
@include span-columns(12);
|
||||
height: $card-height;
|
||||
border:1px solid $border-color-l3;
|
||||
box-sizing:border-box;
|
||||
border: 1px solid $border-color-l3;
|
||||
box-sizing: border-box;
|
||||
padding: $baseline;
|
||||
margin-bottom: $baseline;
|
||||
position:relative;
|
||||
position: relative;
|
||||
display: inline;
|
||||
.card-link{
|
||||
position:absolute;
|
||||
top:0;
|
||||
bottom:0;
|
||||
right:0;
|
||||
left:0;
|
||||
outline:0;
|
||||
border:0;
|
||||
z-index:1;
|
||||
height: $card-height;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
border: 0;
|
||||
z-index: 1;
|
||||
opacity: 0.5;
|
||||
&:hover,
|
||||
&:focus{
|
||||
opacity: 1;
|
||||
}
|
||||
.banner-image-container{
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 116px;
|
||||
.banner-image{
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
z-index: 0;
|
||||
transform: translate(-50%, 0);
|
||||
min-height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.text-section{
|
||||
margin-top: 106px;
|
||||
.meta-info{
|
||||
@include outer-container;
|
||||
margin-bottom: $baseline;
|
||||
margin-bottom: $baseline*0.25;
|
||||
font-size: em(12);
|
||||
color: $gray;
|
||||
.organization{
|
||||
@include span-columns(6);
|
||||
color: $gray;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.category{
|
||||
@include span-columns(6);
|
||||
text-align:right;
|
||||
span{
|
||||
text-align: right;
|
||||
.category-text{
|
||||
@include float(right);
|
||||
}
|
||||
.xseries-icon{
|
||||
@include float(right);
|
||||
@include margin-right($baseline*0.2);
|
||||
@include margin-right($baseline*0.25);
|
||||
background: url('#{$static-path}/images/icon-sm-xseries-black.png') no-repeat;
|
||||
background-color: transparent;
|
||||
|
||||
width: ($baseline*1);
|
||||
height: ($baseline*1);
|
||||
background-size: 100%;
|
||||
width: ($baseline*0.7);
|
||||
height: ($baseline*0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
.title{
|
||||
font-size:em(30);
|
||||
font-size: em(24);
|
||||
color: $gray-l1;
|
||||
margin-bottom: 10px;
|
||||
line-height: 100%;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media($bp-small) {
|
||||
.program-card{
|
||||
@include omega(n);
|
||||
@include span-columns(4);
|
||||
.card-link{
|
||||
.banner-image-container{
|
||||
height: 166px;
|
||||
}
|
||||
}
|
||||
.text-section{
|
||||
margin-top: 156px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,7 +94,16 @@ $card-height: 150px;
|
||||
|
||||
@include media($bp-medium) {
|
||||
.program-card{
|
||||
@include omega(n);
|
||||
@include span-columns(8);
|
||||
.card-link{
|
||||
.banner-image-container{
|
||||
height: 242px;
|
||||
}
|
||||
}
|
||||
.text-section{
|
||||
margin-top: 232px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +113,14 @@ $card-height: 150px;
|
||||
.program-card{
|
||||
@include omega(2n);
|
||||
@include span-columns(6);
|
||||
display:inline;
|
||||
.card-link{
|
||||
.banner-image-container{
|
||||
height: 116px;
|
||||
}
|
||||
}
|
||||
.text-section{
|
||||
margin-top: 106px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +128,14 @@ $card-height: 150px;
|
||||
.program-card{
|
||||
@include omega(2n);
|
||||
@include span-columns(6);
|
||||
display:inline;
|
||||
.card-link{
|
||||
.banner-image-container{
|
||||
height: 145px;
|
||||
}
|
||||
}
|
||||
.text-section{
|
||||
margin-top: 135px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
@import '../base/grid-settings';
|
||||
@import 'neat/neat'; // lib - Neat
|
||||
|
||||
//Patern Library button colors
|
||||
$pl-button-border-color: #065683;
|
||||
$pl-button-color: #0079bc;
|
||||
|
||||
.program-list-wrapper{
|
||||
@include outer-container;
|
||||
padding: $baseline $baseline;
|
||||
@@ -20,20 +24,30 @@
|
||||
background-color: $body-bg;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid $border-color-l3;
|
||||
clear:both;
|
||||
clear: both;
|
||||
.advertise-message{
|
||||
font-size:em(12);
|
||||
font-size: em(12);
|
||||
color: $gray-d4;
|
||||
margin-bottom: $baseline;
|
||||
}
|
||||
.ad-link{
|
||||
padding:$baseline * 0.5;
|
||||
border: 1px solid $blue-t1;
|
||||
font-size: em(16);
|
||||
text-align:center;
|
||||
a{
|
||||
padding: $baseline * 0.5;
|
||||
border: 1px solid $pl-button-border-color;
|
||||
color: $pl-button-color;
|
||||
font-size: em(16);
|
||||
text-decoration: none;
|
||||
&:hover, &:focus, &:active{
|
||||
background-color: $button-bg-hover-color;
|
||||
display: block;
|
||||
line-height: 1.2;
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active{
|
||||
color: $white;
|
||||
background-color: $pl-button-color;
|
||||
}
|
||||
span{
|
||||
@include margin-left($baseline*0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
|
||||
<div class="banner-image">
|
||||
<a href="<%- marketingUrl %>" class="card-link">
|
||||
<img alt="<%- gettext(name)%>" src="" />
|
||||
</a>
|
||||
</div>
|
||||
<a href="<%- marketingUrl %>" class="card-link">
|
||||
<div class="banner-image-container">
|
||||
<picture>
|
||||
<source srcset="<%- smallBannerUrl %>" media="(max-width: <%- breakpoints.max.tiny %>)">
|
||||
<source srcset="<%- mediumBannerUrl %>" media="(max-width: <%- breakpoints.max.small %>)">
|
||||
<source srcset="<%- largeBannerUrl %>" media="(max-width: <%- breakpoints.max.medium %>)">
|
||||
<source srcset="<%- smallBannerUrl %>" media="(max-width: <%- breakpoints.max.large %>)">
|
||||
<img class="banner-image" srcset="<%- mediumBannerUrl %>" alt="<%- gettext(name)%>">
|
||||
</picture>
|
||||
</div>
|
||||
</a>
|
||||
<div class="text-section">
|
||||
<div class="meta-info">
|
||||
<div class="organization">
|
||||
<% _.each(organizations, function(org){ %>
|
||||
<span><%- gettext(org.display_name) %></span>
|
||||
<%- gettext(org.display_name) %>
|
||||
<% }); %>
|
||||
</div>
|
||||
<div class="category">
|
||||
<span><%- gettext(category) %></span>
|
||||
<span class="category-text"><%- gettext(type) %></span>
|
||||
<i class="xseries-icon" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ from openedx.core.djangoapps.programs.utils import (
|
||||
get_programs_for_dashboard,
|
||||
get_programs_for_credentials,
|
||||
get_engaged_programs,
|
||||
get_display_category
|
||||
)
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
|
||||
@@ -109,6 +110,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
|
||||
actual = get_programs_for_dashboard(self.user, self.COURSE_KEYS)
|
||||
expected = {}
|
||||
for program in self.PROGRAMS_API_RESPONSE['results']:
|
||||
program['display_category'] = get_display_category(program)
|
||||
for course_code in program['course_codes']:
|
||||
for run in course_code['run_modes']:
|
||||
course_key = run['course_key']
|
||||
@@ -206,6 +208,8 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
|
||||
actual = get_engaged_programs(self.user, enrollments)
|
||||
|
||||
programs = self.PROGRAMS_API_RESPONSE['results']
|
||||
for program in programs:
|
||||
program['display_category'] = get_display_category(program)
|
||||
# get_engaged_programs iterates across a list returned by the programs
|
||||
# API to create flattened lists keyed by course ID. These lists are
|
||||
# joined in order of enrollment creation time when constructing the
|
||||
@@ -234,6 +238,8 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
|
||||
actual = get_engaged_programs(self.user, enrollments)
|
||||
|
||||
programs = self.PROGRAMS_API_RESPONSE['results']
|
||||
for program in programs:
|
||||
program['display_category'] = get_display_category(program)
|
||||
expected = [programs[0]]
|
||||
|
||||
self.assertEqual(expected, actual)
|
||||
@@ -251,6 +257,8 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
|
||||
actual = get_engaged_programs(self.user, enrollments)
|
||||
|
||||
programs = self.PROGRAMS_API_RESPONSE['results']
|
||||
for program in programs:
|
||||
program['display_category'] = get_display_category(program)
|
||||
expected = programs[-2:]
|
||||
|
||||
self.assertEqual(expected, actual)
|
||||
@@ -277,3 +285,16 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin,
|
||||
expected = []
|
||||
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@httpretty.activate
|
||||
def test_get_display_category_success(self):
|
||||
self.create_programs_config()
|
||||
self.mock_programs_api()
|
||||
actual_programs = get_programs(self.user)
|
||||
for program in actual_programs:
|
||||
expected = 'XSeries'
|
||||
self.assertEqual(expected, get_display_category(program))
|
||||
|
||||
def test_get_display_category_none(self):
|
||||
self.assertEqual('', get_display_category(None))
|
||||
self.assertEqual('', get_display_category({"id": "test"}))
|
||||
|
||||
@@ -46,6 +46,7 @@ def flatten_programs(programs, course_ids):
|
||||
for run in course_code['run_modes']:
|
||||
run_id = run['course_key']
|
||||
if run_id in course_ids:
|
||||
program['display_category'] = get_display_category(program)
|
||||
flattened.setdefault(run_id, []).append(program)
|
||||
except KeyError:
|
||||
log.exception('Unable to parse Programs API response: %r', program)
|
||||
@@ -113,6 +114,24 @@ def get_programs_for_credentials(user, programs_credentials):
|
||||
return certificate_programs
|
||||
|
||||
|
||||
def get_display_category(program):
|
||||
""" Given the program, return the category of the program for display
|
||||
Arguments:
|
||||
program (Program): The program to get the display category string from
|
||||
|
||||
Returns:
|
||||
string, the category for display to the user.
|
||||
Empty string if the program has no category or is null.
|
||||
"""
|
||||
display_candidate = ''
|
||||
if program and program.get('category'):
|
||||
if program.get('category') == 'xseries':
|
||||
display_candidate = 'XSeries'
|
||||
else:
|
||||
display_candidate = program.get('category', '').capitalize()
|
||||
return display_candidate
|
||||
|
||||
|
||||
def get_engaged_programs(user, enrollments):
|
||||
"""Derive a list of programs in which the given user is engaged.
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"requirejs": "~2.1.22",
|
||||
"uglify-js": "2.4.24",
|
||||
"underscore": "~1.8.3",
|
||||
"underscore.string": "~3.3.4"
|
||||
"underscore.string": "~3.3.4",
|
||||
"picturefill": "~3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jshint": "^2.7.0",
|
||||
|
||||
@@ -45,7 +45,8 @@ COMMON_LOOKUP_PATHS = [
|
||||
# static directory.
|
||||
NPM_INSTALLED_LIBRARIES = [
|
||||
'underscore/underscore.js',
|
||||
'underscore.string/dist/underscore.string.js'
|
||||
'underscore.string/dist/underscore.string.js',
|
||||
'picturefill/dist/picturefill.min.js'
|
||||
]
|
||||
|
||||
# Directory to install static vendor files
|
||||
|
||||
Reference in New Issue
Block a user