Merge pull request #6459 from edx/content-libraries
Content libraries MVP
This commit is contained in:
48
common/test/acceptance/pages/lms/library.py
Normal file
48
common/test/acceptance/pages/lms/library.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Library Content XBlock Wrapper
|
||||
"""
|
||||
from bok_choy.page_object import PageObject
|
||||
|
||||
|
||||
class LibraryContentXBlockWrapper(PageObject):
|
||||
"""
|
||||
A PageObject representing a wrapper around a LibraryContent block seen in the LMS
|
||||
"""
|
||||
url = None
|
||||
BODY_SELECTOR = '.xblock-student_view div'
|
||||
|
||||
def __init__(self, browser, locator):
|
||||
super(LibraryContentXBlockWrapper, self).__init__(browser)
|
||||
self.locator = locator
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Checks if page is opened
|
||||
"""
|
||||
return self.q(css='{}[data-id="{}"]'.format(self.BODY_SELECTOR, self.locator)).present
|
||||
|
||||
def _bounded_selector(self, selector):
|
||||
"""
|
||||
Return `selector`, but limited to this particular block's context
|
||||
"""
|
||||
return '{}[data-id="{}"] {}'.format(
|
||||
self.BODY_SELECTOR,
|
||||
self.locator,
|
||||
selector
|
||||
)
|
||||
|
||||
@property
|
||||
def children_contents(self):
|
||||
"""
|
||||
Gets contents of all child XBlocks as list of strings
|
||||
"""
|
||||
child_blocks = self.q(css=self._bounded_selector("div[data-id]"))
|
||||
return frozenset(child.text for child in child_blocks)
|
||||
|
||||
@property
|
||||
def children_headers(self):
|
||||
"""
|
||||
Gets headers of all child XBlocks as list of strings
|
||||
"""
|
||||
child_blocks_headers = self.q(css=self._bounded_selector("div[data-id] h2.problem-header"))
|
||||
return frozenset(child.text for child in child_blocks_headers)
|
||||
@@ -246,6 +246,7 @@ class CombinedLoginAndRegisterPage(PageObject):
|
||||
def wait_for_errors(self):
|
||||
"""Wait for errors to be visible, then return them. """
|
||||
def _check_func():
|
||||
"""Return success status and any errors that occurred."""
|
||||
errors = self.errors
|
||||
return (bool(errors), errors)
|
||||
return Promise(_check_func, "Errors are visible").fulfill()
|
||||
@@ -259,6 +260,7 @@ class CombinedLoginAndRegisterPage(PageObject):
|
||||
def wait_for_success(self):
|
||||
"""Wait for a success message to be visible, then return it."""
|
||||
def _check_func():
|
||||
"""Return success status and any errors that occurred."""
|
||||
success = self.success
|
||||
return (bool(success), success)
|
||||
return Promise(_check_func, "Success message is visible").fulfill()
|
||||
|
||||
@@ -15,7 +15,8 @@ class AutoAuthPage(PageObject):
|
||||
this url will create a user and log them in.
|
||||
"""
|
||||
|
||||
def __init__(self, browser, username=None, email=None, password=None, staff=None, course_id=None, roles=None):
|
||||
def __init__(self, browser, username=None, email=None, password=None,
|
||||
staff=None, course_id=None, roles=None, no_login=None):
|
||||
"""
|
||||
Auto-auth is an end-point for HTTP GET requests.
|
||||
By default, it will create accounts with random user credentials,
|
||||
@@ -51,6 +52,9 @@ class AutoAuthPage(PageObject):
|
||||
if roles is not None:
|
||||
self._params['roles'] = roles
|
||||
|
||||
if no_login:
|
||||
self._params['no_login'] = True
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""
|
||||
@@ -66,7 +70,7 @@ class AutoAuthPage(PageObject):
|
||||
|
||||
def is_browser_on_page(self):
|
||||
message = self.q(css='BODY').text[0]
|
||||
match = re.search(r'Logged in user ([^$]+) with password ([^$]+) and user_id ([^$]+)$', message)
|
||||
match = re.search(r'(Logged in|Created) user ([^$]+) with password ([^$]+) and user_id ([^$]+)$', message)
|
||||
return True if match else False
|
||||
|
||||
def get_user_id(self):
|
||||
|
||||
@@ -6,7 +6,7 @@ from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import Promise, EmptyPromise
|
||||
from . import BASE_URL
|
||||
|
||||
from utils import click_css, confirm_prompt
|
||||
from .utils import click_css, confirm_prompt, type_in_codemirror
|
||||
|
||||
|
||||
class ContainerPage(PageObject):
|
||||
@@ -285,6 +285,7 @@ class XBlockWrapper(PageObject):
|
||||
COMPONENT_BUTTONS = {
|
||||
'basic_tab': '.editor-tabs li.inner_tab_wrap:nth-child(1) > a',
|
||||
'advanced_tab': '.editor-tabs li.inner_tab_wrap:nth-child(2) > a',
|
||||
'settings_tab': '.editor-modes .settings-button',
|
||||
'save_settings': '.action-save',
|
||||
}
|
||||
|
||||
@@ -312,6 +313,14 @@ class XBlockWrapper(PageObject):
|
||||
"""
|
||||
return self.q(css=self._bounded_selector('.xblock-student_view'))[0].text
|
||||
|
||||
@property
|
||||
def author_content(self):
|
||||
"""
|
||||
Returns the text content of the xblock as displayed on the container page.
|
||||
(For blocks which implement a distinct author_view).
|
||||
"""
|
||||
return self.q(css=self._bounded_selector('.xblock-author_view'))[0].text
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
titles = self.q(css=self._bounded_selector(self.NAME_SELECTOR)).text
|
||||
@@ -336,6 +345,47 @@ class XBlockWrapper(PageObject):
|
||||
grand_locators = [grandkid.locator for grandkid in grandkids]
|
||||
return [descendant for descendant in descendants if descendant.locator not in grand_locators]
|
||||
|
||||
@property
|
||||
def has_validation_message(self):
|
||||
""" Is a validation warning/error/message shown? """
|
||||
return self.q(css=self._bounded_selector('.xblock-message.validation')).present
|
||||
|
||||
def _validation_paragraph(self, css_class):
|
||||
""" Helper method to return the <p> element of a validation warning """
|
||||
return self.q(css=self._bounded_selector('.xblock-message.validation p.{}'.format(css_class)))
|
||||
|
||||
@property
|
||||
def has_validation_warning(self):
|
||||
""" Is a validation warning shown? """
|
||||
return self._validation_paragraph('warning').present
|
||||
|
||||
@property
|
||||
def has_validation_error(self):
|
||||
""" Is a validation error shown? """
|
||||
return self._validation_paragraph('error').present
|
||||
|
||||
@property
|
||||
# pylint: disable=invalid-name
|
||||
def has_validation_not_configured_warning(self):
|
||||
""" Is a validation "not configured" message shown? """
|
||||
return self._validation_paragraph('not-configured').present
|
||||
|
||||
@property
|
||||
def validation_warning_text(self):
|
||||
""" Get the text of the validation warning. """
|
||||
return self._validation_paragraph('warning').text[0]
|
||||
|
||||
@property
|
||||
def validation_error_text(self):
|
||||
""" Get the text of the validation error. """
|
||||
return self._validation_paragraph('error').text[0]
|
||||
|
||||
@property
|
||||
# pylint: disable=invalid-name
|
||||
def validation_not_configured_warning_text(self):
|
||||
""" Get the text of the validation "not configured" message. """
|
||||
return self._validation_paragraph('not-configured').text[0]
|
||||
|
||||
@property
|
||||
def preview_selector(self):
|
||||
return self._bounded_selector('.xblock-student_view,.xblock-author_view')
|
||||
@@ -365,6 +415,34 @@ class XBlockWrapper(PageObject):
|
||||
"""
|
||||
self._click_button('basic_tab')
|
||||
|
||||
def open_settings_tab(self):
|
||||
"""
|
||||
If editing, click on the "Settings" tab
|
||||
"""
|
||||
self._click_button('settings_tab')
|
||||
|
||||
def set_field_val(self, field_display_name, field_value):
|
||||
"""
|
||||
If editing, set the value of a field.
|
||||
"""
|
||||
selector = '{} li.field label:contains("{}") + input'.format(self.editor_selector, field_display_name)
|
||||
script = "$(arguments[0]).val(arguments[1]).change();"
|
||||
self.browser.execute_script(script, selector, field_value)
|
||||
|
||||
def reset_field_val(self, field_display_name):
|
||||
"""
|
||||
If editing, reset the value of a field to its default.
|
||||
"""
|
||||
scope = '{} li.field label:contains("{}")'.format(self.editor_selector, field_display_name)
|
||||
script = "$(arguments[0]).siblings('.setting-clear').click();"
|
||||
self.browser.execute_script(script, scope)
|
||||
|
||||
def set_codemirror_text(self, text, index=0):
|
||||
"""
|
||||
Set the text of a CodeMirror editor that is part of this xblock's settings.
|
||||
"""
|
||||
type_in_codemirror(self, index, text, find_prefix='$("{}").find'.format(self.editor_selector))
|
||||
|
||||
def save_settings(self):
|
||||
"""
|
||||
Click on settings Save button.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
My Courses page in Studio
|
||||
Studio Home page
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
@@ -8,7 +8,7 @@ from . import BASE_URL
|
||||
|
||||
class DashboardPage(PageObject):
|
||||
"""
|
||||
My Courses page in Studio
|
||||
Studio Home page
|
||||
"""
|
||||
|
||||
url = BASE_URL + "/course/"
|
||||
@@ -40,3 +40,69 @@ class DashboardPage(PageObject):
|
||||
Clicks on the course with run given by run.
|
||||
"""
|
||||
self.q(css='.course-run .value').filter(lambda el: el.text == run)[0].click()
|
||||
|
||||
def has_new_library_button(self):
|
||||
"""
|
||||
(bool) is the "New Library" button present?
|
||||
"""
|
||||
return self.q(css='.new-library-button').present
|
||||
|
||||
def click_new_library(self):
|
||||
"""
|
||||
Click on the "New Library" button
|
||||
"""
|
||||
self.q(css='.new-library-button').click()
|
||||
|
||||
def is_new_library_form_visible(self):
|
||||
"""
|
||||
Is the new library form visisble?
|
||||
"""
|
||||
return self.q(css='.wrapper-create-library').visible
|
||||
|
||||
def fill_new_library_form(self, display_name, org, number):
|
||||
"""
|
||||
Fill out the form to create a new library.
|
||||
Must have called click_new_library() first.
|
||||
"""
|
||||
field = lambda fn: self.q(css='.wrapper-create-library #new-library-{}'.format(fn))
|
||||
field('name').fill(display_name)
|
||||
field('org').fill(org)
|
||||
field('number').fill(number)
|
||||
|
||||
def is_new_library_form_valid(self):
|
||||
"""
|
||||
IS the new library form ready to submit?
|
||||
"""
|
||||
return (
|
||||
self.q(css='.wrapper-create-library .new-library-save:not(.is-disabled)').present and
|
||||
not self.q(css='.wrapper-create-library .wrap-error.is-shown').present
|
||||
)
|
||||
|
||||
def submit_new_library_form(self):
|
||||
"""
|
||||
Submit the new library form.
|
||||
"""
|
||||
self.q(css='.wrapper-create-library .new-library-save').click()
|
||||
|
||||
def list_libraries(self):
|
||||
"""
|
||||
List all the libraries found on the page's list of libraries.
|
||||
"""
|
||||
# Workaround Selenium/Firefox bug: `.text` property is broken on invisible elements
|
||||
self.q(css='#course-index-tabs .libraries-tab a').click()
|
||||
div2info = lambda element: {
|
||||
'name': element.find_element_by_css_selector('.course-title').text,
|
||||
'org': element.find_element_by_css_selector('.course-org .value').text,
|
||||
'number': element.find_element_by_css_selector('.course-num .value').text,
|
||||
'url': element.find_element_by_css_selector('a.library-link').get_attribute('href'),
|
||||
}
|
||||
return self.q(css='.libraries li.course-item').map(div2info).results
|
||||
|
||||
def has_library(self, **kwargs):
|
||||
"""
|
||||
Does the page's list of libraries include a library matching kwargs?
|
||||
"""
|
||||
for lib in self.list_libraries():
|
||||
if all([lib[key] == kwargs[key] for key in kwargs]):
|
||||
return True
|
||||
return False
|
||||
|
||||
281
common/test/acceptance/pages/studio/library.py
Normal file
281
common/test/acceptance/pages/studio/library.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
Library edit page in Studio
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import EmptyPromise
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.support.select import Select
|
||||
from .overview import CourseOutlineModal
|
||||
from .container import XBlockWrapper
|
||||
from ...pages.studio.pagination import PaginatedMixin
|
||||
from ...tests.helpers import disable_animations
|
||||
from .utils import confirm_prompt, wait_for_notification
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class LibraryPage(PageObject, PaginatedMixin):
|
||||
"""
|
||||
Library page in Studio
|
||||
"""
|
||||
|
||||
def __init__(self, browser, locator):
|
||||
super(LibraryPage, self).__init__(browser)
|
||||
self.locator = locator
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""
|
||||
URL to the library edit page for the given library.
|
||||
"""
|
||||
return "{}/library/{}".format(BASE_URL, unicode(self.locator))
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Returns True iff the browser has loaded the library edit page.
|
||||
"""
|
||||
return self.q(css='body.view-library').present
|
||||
|
||||
def get_header_title(self):
|
||||
"""
|
||||
The text of the main heading (H1) visible on the page.
|
||||
"""
|
||||
return self.q(css='h1.page-header-title').text
|
||||
|
||||
def wait_until_ready(self):
|
||||
"""
|
||||
When the page first loads, there is a loading indicator and most
|
||||
functionality is not yet available. This waits for that loading to
|
||||
finish.
|
||||
|
||||
Always call this before using the page. It also disables animations
|
||||
for improved test reliability.
|
||||
"""
|
||||
self.wait_for_ajax()
|
||||
self.wait_for_element_invisibility(
|
||||
'.ui-loading',
|
||||
'Wait for the page to complete its initial loading of XBlocks via AJAX'
|
||||
)
|
||||
disable_animations(self)
|
||||
|
||||
@property
|
||||
def xblocks(self):
|
||||
"""
|
||||
Return a list of xblocks loaded on the container page.
|
||||
"""
|
||||
return self._get_xblocks()
|
||||
|
||||
def click_duplicate_button(self, xblock_id):
|
||||
"""
|
||||
Click on the duplicate button for the given XBlock
|
||||
"""
|
||||
self._action_btn_for_xblock_id(xblock_id, "duplicate").click()
|
||||
wait_for_notification(self)
|
||||
self.wait_for_ajax()
|
||||
|
||||
def click_delete_button(self, xblock_id, confirm=True):
|
||||
"""
|
||||
Click on the delete button for the given XBlock
|
||||
"""
|
||||
self._action_btn_for_xblock_id(xblock_id, "delete").click()
|
||||
if confirm:
|
||||
confirm_prompt(self) # this will also wait_for_notification()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def _get_xblocks(self):
|
||||
"""
|
||||
Create an XBlockWrapper for each XBlock div found on the page.
|
||||
"""
|
||||
prefix = '.wrapper-xblock.level-page '
|
||||
return self.q(css=prefix + XBlockWrapper.BODY_SELECTOR).map(
|
||||
lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))
|
||||
).results
|
||||
|
||||
def _div_for_xblock_id(self, xblock_id):
|
||||
"""
|
||||
Given an XBlock's usage locator as a string, return the WebElement for
|
||||
that block's wrapper div.
|
||||
"""
|
||||
return self.q(css='.wrapper-xblock.level-page .studio-xblock-wrapper').filter(
|
||||
lambda el: el.get_attribute('data-locator') == xblock_id
|
||||
)
|
||||
|
||||
def _action_btn_for_xblock_id(self, xblock_id, action):
|
||||
"""
|
||||
Given an XBlock's usage locator as a string, return one of its action
|
||||
buttons.
|
||||
action is 'edit', 'duplicate', or 'delete'
|
||||
"""
|
||||
return self._div_for_xblock_id(xblock_id)[0].find_element_by_css_selector(
|
||||
'.header-actions .{action}-button.action-button'.format(action=action)
|
||||
)
|
||||
|
||||
|
||||
class StudioLibraryContentXBlockEditModal(CourseOutlineModal, PageObject):
|
||||
"""
|
||||
Library Content XBlock Modal edit window
|
||||
"""
|
||||
url = None
|
||||
MODAL_SELECTOR = ".wrapper-modal-window-edit-xblock"
|
||||
|
||||
# Labels used to identify the fields on the edit modal:
|
||||
LIBRARY_LABEL = "Libraries"
|
||||
COUNT_LABEL = "Count"
|
||||
SCORED_LABEL = "Scored"
|
||||
PROBLEM_TYPE_LABEL = "Problem Type"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Check that we are on the right page in the browser.
|
||||
"""
|
||||
return self.is_shown()
|
||||
|
||||
@property
|
||||
def library_key(self):
|
||||
"""
|
||||
Gets value of first library key input
|
||||
"""
|
||||
library_key_input = self.get_metadata_input(self.LIBRARY_LABEL)
|
||||
if library_key_input is not None:
|
||||
return library_key_input.get_attribute('value').strip(',')
|
||||
return None
|
||||
|
||||
@library_key.setter
|
||||
def library_key(self, library_key):
|
||||
"""
|
||||
Sets value of first library key input, creating it if necessary
|
||||
"""
|
||||
library_key_input = self.get_metadata_input(self.LIBRARY_LABEL)
|
||||
if library_key_input is None:
|
||||
library_key_input = self._add_library_key()
|
||||
if library_key is not None:
|
||||
# can't use lib_text.clear() here as input get deleted by client side script
|
||||
library_key_input.send_keys(Keys.HOME)
|
||||
library_key_input.send_keys(Keys.SHIFT, Keys.END)
|
||||
library_key_input.send_keys(library_key)
|
||||
else:
|
||||
library_key_input.clear()
|
||||
EmptyPromise(lambda: self.library_key == library_key, "library_key is updated in modal.").fulfill()
|
||||
|
||||
@property
|
||||
def count(self):
|
||||
"""
|
||||
Gets value of children count input
|
||||
"""
|
||||
return int(self.get_metadata_input(self.COUNT_LABEL).get_attribute('value'))
|
||||
|
||||
@count.setter
|
||||
def count(self, count):
|
||||
"""
|
||||
Sets value of children count input
|
||||
"""
|
||||
count_text = self.get_metadata_input(self.COUNT_LABEL)
|
||||
count_text.clear()
|
||||
count_text.send_keys(count)
|
||||
EmptyPromise(lambda: self.count == count, "count is updated in modal.").fulfill()
|
||||
|
||||
@property
|
||||
def scored(self):
|
||||
"""
|
||||
Gets value of scored select
|
||||
"""
|
||||
value = self.get_metadata_input(self.SCORED_LABEL).get_attribute('value')
|
||||
if value == 'True':
|
||||
return True
|
||||
elif value == 'False':
|
||||
return False
|
||||
raise ValueError("Unknown value {value} set for {label}".format(value=value, label=self.SCORED_LABEL))
|
||||
|
||||
@scored.setter
|
||||
def scored(self, scored):
|
||||
"""
|
||||
Sets value of scored select
|
||||
"""
|
||||
select_element = self.get_metadata_input(self.SCORED_LABEL)
|
||||
select_element.click()
|
||||
scored_select = Select(select_element)
|
||||
scored_select.select_by_value(str(scored))
|
||||
EmptyPromise(lambda: self.scored == scored, "scored is updated in modal.").fulfill()
|
||||
|
||||
@property
|
||||
def capa_type(self):
|
||||
"""
|
||||
Gets value of CAPA type select
|
||||
"""
|
||||
return self.get_metadata_input(self.PROBLEM_TYPE_LABEL).get_attribute('value')
|
||||
|
||||
@capa_type.setter
|
||||
def capa_type(self, value):
|
||||
"""
|
||||
Sets value of CAPA type select
|
||||
"""
|
||||
select_element = self.get_metadata_input(self.PROBLEM_TYPE_LABEL)
|
||||
select_element.click()
|
||||
problem_type_select = Select(select_element)
|
||||
problem_type_select.select_by_value(value)
|
||||
EmptyPromise(lambda: self.capa_type == value, "problem type is updated in modal.").fulfill()
|
||||
|
||||
def _add_library_key(self):
|
||||
"""
|
||||
Adds library key input
|
||||
"""
|
||||
wrapper = self._get_metadata_element(self.LIBRARY_LABEL)
|
||||
add_button = wrapper.find_element_by_xpath(".//a[contains(@class, 'create-action')]")
|
||||
add_button.click()
|
||||
return self._get_list_inputs(wrapper)[0]
|
||||
|
||||
def _get_list_inputs(self, list_wrapper):
|
||||
"""
|
||||
Finds nested input elements (useful for List and Dict fields)
|
||||
"""
|
||||
return list_wrapper.find_elements_by_xpath(".//input[@type='text']")
|
||||
|
||||
def _get_metadata_element(self, metadata_key):
|
||||
"""
|
||||
Gets metadata input element (a wrapper div for List and Dict fields)
|
||||
"""
|
||||
metadata_inputs = self.find_css(".metadata_entry .wrapper-comp-setting label.setting-label")
|
||||
target_label = [elem for elem in metadata_inputs if elem.text == metadata_key][0]
|
||||
label_for = target_label.get_attribute('for')
|
||||
return self.find_css("#" + label_for)[0]
|
||||
|
||||
def get_metadata_input(self, metadata_key):
|
||||
"""
|
||||
Gets input/select element for given field
|
||||
"""
|
||||
element = self._get_metadata_element(metadata_key)
|
||||
if element.tag_name == 'div':
|
||||
# List or Dict field - return first input
|
||||
# TODO support multiple values
|
||||
inputs = self._get_list_inputs(element)
|
||||
element = inputs[0] if inputs else None
|
||||
return element
|
||||
|
||||
|
||||
class StudioLibraryContainerXBlockWrapper(XBlockWrapper):
|
||||
"""
|
||||
Wraps :class:`.container.XBlockWrapper` for use with LibraryContent blocks
|
||||
"""
|
||||
url = None
|
||||
|
||||
@classmethod
|
||||
def from_xblock_wrapper(cls, xblock_wrapper):
|
||||
"""
|
||||
Factory method: creates :class:`.StudioLibraryContainerXBlockWrapper` from :class:`.container.XBlockWrapper`
|
||||
"""
|
||||
return cls(xblock_wrapper.browser, xblock_wrapper.locator)
|
||||
|
||||
def get_body_paragraphs(self):
|
||||
"""
|
||||
Gets library content body paragraphs
|
||||
"""
|
||||
return self.q(css=self._bounded_selector(".xblock-message-area p"))
|
||||
|
||||
def refresh_children(self):
|
||||
"""
|
||||
Click "Update now..." button
|
||||
"""
|
||||
btn_selector = self._bounded_selector(".library-update-btn")
|
||||
refresh_button = self.q(css=btn_selector)
|
||||
refresh_button.click()
|
||||
self.wait_for_element_absence(btn_selector, 'Wait for the XBlock to reload')
|
||||
62
common/test/acceptance/pages/studio/pagination.py
Normal file
62
common/test/acceptance/pages/studio/pagination.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Mixin to include for Paginated container pages
|
||||
"""
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
|
||||
class PaginatedMixin(object):
|
||||
"""
|
||||
Mixin class used for paginated page tests.
|
||||
"""
|
||||
def nav_disabled(self, position, arrows=('next', 'previous')):
|
||||
"""
|
||||
Verifies that pagination nav is disabled. Position can be 'top' or 'bottom'.
|
||||
|
||||
`top` is the header, `bottom` is the footer.
|
||||
|
||||
To specify a specific arrow, pass an iterable with a single element, 'next' or 'previous'.
|
||||
"""
|
||||
return all([
|
||||
self.q(css='nav.%s * a.%s-page-link.is-disabled' % (position, arrow))
|
||||
for arrow in arrows
|
||||
])
|
||||
|
||||
def move_back(self, position):
|
||||
"""
|
||||
Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'.
|
||||
"""
|
||||
self.q(css='nav.%s * a.previous-page-link' % position)[0].click()
|
||||
self.wait_until_ready()
|
||||
|
||||
def move_forward(self, position):
|
||||
"""
|
||||
Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'.
|
||||
"""
|
||||
self.q(css='nav.%s * a.next-page-link' % position)[0].click()
|
||||
self.wait_until_ready()
|
||||
|
||||
def go_to_page(self, number):
|
||||
"""
|
||||
Enter a number into the page number input field, and then try to navigate to it.
|
||||
"""
|
||||
page_input = self.q(css="#page-number-input")[0]
|
||||
page_input.click()
|
||||
page_input.send_keys(str(number))
|
||||
page_input.send_keys(Keys.RETURN)
|
||||
self.wait_until_ready()
|
||||
|
||||
def get_page_number(self):
|
||||
"""
|
||||
Returns the page number as the page represents it, in string form.
|
||||
"""
|
||||
return self.q(css="span.current-page")[0].get_attribute('innerHTML')
|
||||
|
||||
def check_page_unchanged(self, first_block_name):
|
||||
"""
|
||||
Used to make sure that a page has not transitioned after a bogus number is given.
|
||||
"""
|
||||
if not self.xblocks[0].name == first_block_name:
|
||||
return False
|
||||
if not self.q(css='#page-number-input')[0].get_attribute('value') == '':
|
||||
return False
|
||||
return True
|
||||
191
common/test/acceptance/pages/studio/users.py
Normal file
191
common/test/acceptance/pages/studio/users.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Page classes to test either the Course Team page or the Library Team page.
|
||||
"""
|
||||
from bok_choy.promise import EmptyPromise
|
||||
from bok_choy.page_object import PageObject
|
||||
from ...tests.helpers import disable_animations
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
def wait_for_ajax_or_reload(browser):
|
||||
"""
|
||||
Wait for all ajax requests to finish, OR for the page to reload.
|
||||
Normal wait_for_ajax() chokes on occasion if the pages reloads,
|
||||
giving "WebDriverException: Message: u'jQuery is not defined'"
|
||||
"""
|
||||
def _is_ajax_finished():
|
||||
""" Wait for jQuery to finish all AJAX calls, if it is present. """
|
||||
return browser.execute_script("return typeof(jQuery) == 'undefined' || jQuery.active == 0")
|
||||
|
||||
EmptyPromise(_is_ajax_finished, "Finished waiting for ajax requests.").fulfill()
|
||||
|
||||
|
||||
class UsersPage(PageObject):
|
||||
"""
|
||||
Base class for either the Course Team page or the Library Team page
|
||||
"""
|
||||
|
||||
def __init__(self, browser, locator):
|
||||
super(UsersPage, self).__init__(browser)
|
||||
self.locator = locator
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""
|
||||
URL to this page - override in subclass
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Returns True iff the browser has loaded the page.
|
||||
"""
|
||||
return self.q(css='body.view-team').present
|
||||
|
||||
@property
|
||||
def users(self):
|
||||
"""
|
||||
Return a list of users listed on this page.
|
||||
"""
|
||||
return self.q(css='.user-list .user-item').map(
|
||||
lambda el: UserWrapper(self.browser, el.get_attribute('data-email'))
|
||||
).results
|
||||
|
||||
@property
|
||||
def has_add_button(self):
|
||||
"""
|
||||
Is the "New Team Member" button present?
|
||||
"""
|
||||
return self.q(css='.create-user-button').present
|
||||
|
||||
def click_add_button(self):
|
||||
"""
|
||||
Click on the "New Team Member" button
|
||||
"""
|
||||
self.q(css='.create-user-button').click()
|
||||
|
||||
@property
|
||||
def new_user_form_visible(self):
|
||||
""" Is the new user form visible? """
|
||||
return self.q(css='.form-create.create-user .user-email-input').visible
|
||||
|
||||
def set_new_user_email(self, email):
|
||||
""" Set the value of the "New User Email Address" field. """
|
||||
self.q(css='.form-create.create-user .user-email-input').fill(email)
|
||||
|
||||
def click_submit_new_user_form(self):
|
||||
""" Submit the "New User" form """
|
||||
self.q(css='.form-create.create-user .action-primary').click()
|
||||
wait_for_ajax_or_reload(self.browser)
|
||||
|
||||
|
||||
class LibraryUsersPage(UsersPage):
|
||||
"""
|
||||
Library Team page in Studio
|
||||
"""
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""
|
||||
URL to the "User Access" page for the given library.
|
||||
"""
|
||||
return "{}/library/{}/team/".format(BASE_URL, unicode(self.locator))
|
||||
|
||||
|
||||
class UserWrapper(PageObject):
|
||||
"""
|
||||
A PageObject representing a wrapper around a user listed on the course/library team page.
|
||||
"""
|
||||
url = None
|
||||
COMPONENT_BUTTONS = {
|
||||
'basic_tab': '.editor-tabs li.inner_tab_wrap:nth-child(1) > a',
|
||||
'advanced_tab': '.editor-tabs li.inner_tab_wrap:nth-child(2) > a',
|
||||
'save_settings': '.action-save',
|
||||
}
|
||||
|
||||
def __init__(self, browser, email):
|
||||
super(UserWrapper, self).__init__(browser)
|
||||
self.email = email
|
||||
self.selector = '.user-list .user-item[data-email="{}"]'.format(self.email)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Sanity check that our wrapper element is on the page.
|
||||
"""
|
||||
return self.q(css=self.selector).present
|
||||
|
||||
def _bounded_selector(self, selector):
|
||||
"""
|
||||
Return `selector`, but limited to this particular user entry's context
|
||||
"""
|
||||
return '{} {}'.format(self.selector, selector)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Get this user's username, as displayed. """
|
||||
return self.q(css=self._bounded_selector('.user-username')).text[0]
|
||||
|
||||
@property
|
||||
def role_label(self):
|
||||
""" Get this user's role, as displayed. """
|
||||
return self.q(css=self._bounded_selector('.flag-role .value')).text[0]
|
||||
|
||||
@property
|
||||
def is_current_user(self):
|
||||
""" Does the UI indicate that this is the current user? """
|
||||
return self.q(css=self._bounded_selector('.flag-role .msg-you')).present
|
||||
|
||||
@property
|
||||
def can_promote(self):
|
||||
""" Can this user be promoted to a more powerful role? """
|
||||
return self.q(css=self._bounded_selector('.add-admin-role')).present
|
||||
|
||||
@property
|
||||
def promote_button_text(self):
|
||||
""" What does the promote user button say? """
|
||||
return self.q(css=self._bounded_selector('.add-admin-role')).text[0]
|
||||
|
||||
def click_promote(self):
|
||||
""" Click on the button to promote this user to the more powerful role """
|
||||
self.q(css=self._bounded_selector('.add-admin-role')).click()
|
||||
wait_for_ajax_or_reload(self.browser)
|
||||
|
||||
@property
|
||||
def can_demote(self):
|
||||
""" Can this user be demoted to a less powerful role? """
|
||||
return self.q(css=self._bounded_selector('.remove-admin-role')).present
|
||||
|
||||
@property
|
||||
def demote_button_text(self):
|
||||
""" What does the demote user button say? """
|
||||
return self.q(css=self._bounded_selector('.remove-admin-role')).text[0]
|
||||
|
||||
def click_demote(self):
|
||||
""" Click on the button to demote this user to the less powerful role """
|
||||
self.q(css=self._bounded_selector('.remove-admin-role')).click()
|
||||
wait_for_ajax_or_reload(self.browser)
|
||||
|
||||
@property
|
||||
def can_delete(self):
|
||||
""" Can this user be deleted? """
|
||||
return self.q(css=self._bounded_selector('.action-delete:not(.is-disabled) .remove-user')).present
|
||||
|
||||
def click_delete(self):
|
||||
""" Click the button to delete this user. """
|
||||
disable_animations(self)
|
||||
self.q(css=self._bounded_selector('.remove-user')).click()
|
||||
# We can't use confirm_prompt because its wait_for_ajax is flaky when the page is expected to reload.
|
||||
self.wait_for_element_visibility('.prompt', 'Prompt is visible')
|
||||
self.wait_for_element_visibility('.prompt .action-primary', 'Confirmation button is visible')
|
||||
self.q(css='.prompt .action-primary').click()
|
||||
wait_for_ajax_or_reload(self.browser)
|
||||
|
||||
@property
|
||||
def has_no_change_warning(self):
|
||||
""" Does this have a warning in place of the promote/demote buttons? """
|
||||
return self.q(css=self._bounded_selector('.notoggleforyou')).present
|
||||
|
||||
@property
|
||||
def no_change_warning_text(self):
|
||||
""" Text of the warning seen in place of the promote/demote buttons. """
|
||||
return self.q(css=self._bounded_selector('.notoggleforyou')).text[0]
|
||||
@@ -103,6 +103,33 @@ def add_advanced_component(page, menu_index, name):
|
||||
click_css(page, component_css, 0)
|
||||
|
||||
|
||||
def add_component(page, item_type, specific_type):
|
||||
"""
|
||||
Click one of the "Add New Component" buttons.
|
||||
|
||||
item_type should be "advanced", "html", "problem", or "video"
|
||||
|
||||
specific_type is required for some types and should be something like
|
||||
"Blank Common Problem".
|
||||
"""
|
||||
btn = page.q(css='.add-xblock-component .add-xblock-component-button[data-type={}]'.format(item_type))
|
||||
multiple_templates = btn.filter(lambda el: 'multiple-templates' in el.get_attribute('class')).present
|
||||
btn.click()
|
||||
if multiple_templates:
|
||||
sub_template_menu_div_selector = '.new-component-{}'.format(item_type)
|
||||
page.wait_for_element_visibility(sub_template_menu_div_selector, 'Wait for the templates sub-menu to appear')
|
||||
page.wait_for_element_invisibility(
|
||||
'.add-xblock-component .new-component',
|
||||
'Wait for the add component menu to disappear'
|
||||
)
|
||||
|
||||
all_options = page.q(css='.new-component-{} ul.new-component-template li a span'.format(item_type))
|
||||
chosen_option = all_options.filter(lambda el: el.text == specific_type).first
|
||||
chosen_option.click()
|
||||
wait_for_notification(page)
|
||||
page.wait_for_ajax()
|
||||
|
||||
|
||||
@js_defined('window.jQuery')
|
||||
def type_in_codemirror(page, index, text, find_prefix="$"):
|
||||
script = """
|
||||
|
||||
Reference in New Issue
Block a user