diff --git a/common/lib/xmodule/xmodule/assets/library_source_block/LibrarySourcedBlockPicker.jsx b/common/lib/xmodule/xmodule/assets/library_source_block/LibrarySourcedBlockPicker.jsx new file mode 100644 index 0000000000..74b1d8b485 --- /dev/null +++ b/common/lib/xmodule/xmodule/assets/library_source_block/LibrarySourcedBlockPicker.jsx @@ -0,0 +1,223 @@ +/* globals gettext */ + +import 'whatwg-fetch'; +import PropTypes from 'prop-types'; +import React from 'react'; +import _ from 'underscore'; +import styles from './style.css'; + +class LibrarySourcedBlockPicker extends React.Component { + constructor(props) { + super(props); + this.state = { + libraries: [], + xblocks: [], + searchedLibrary: '', + libraryLoading: false, + xblocksLoading: false, + selectedLibrary: undefined, + selectedXblocks: new Set(this.props.selectedXblocks), + }; + this.onLibrarySearchInput = this.onLibrarySearchInput.bind(this); + this.onXBlockSearchInput = this.onXBlockSearchInput.bind(this); + this.onLibrarySelected = this.onLibrarySelected.bind(this); + this.onXblockSelected = this.onXblockSelected.bind(this); + this.onDeleteClick = this.onDeleteClick.bind(this); + } + + componentDidMount() { + this.fetchLibraries(); + } + + fetchLibraries(textSearch='', page=1, append=false) { + this.setState({ + libraries: append ? this.state.libraries : [], + libraryLoading: true, + }, async function() { + try { + let res = await fetch(`/api/libraries/v2/?pagination=true&page=${page}&text_search=${textSearch}`); + res = await res.json(); + this.setState({ + libraries: this.state.libraries.concat(res.results), + libraryLoading: false, + }, () => { + if (res.next) { + this.fetchLibraries(textSearch, page+1, true); + } + }); + } catch (error) { + $('#library-sourced-block-picker').trigger('error', { + title: 'Could not fetch library', + message: error, + }); + this.setState({ + libraries: [], + libraryLoading: false, + }); + } + }); + } + + fetchXblocks(library, textSearch='', page=1, append=false) { + this.setState({ + xblocks: append ? this.state.xblocks : [], + xblocksLoading: true, + }, async function() { + try { + let res = await fetch(`/api/libraries/v2/${library}/blocks/?pagination=true&page=${page}&text_search=${textSearch}`); + res = await res.json(); + this.setState({ + xblocks: this.state.xblocks.concat(res.results), + xblocksLoading: false, + }, () => { + if (res.next) { + this.fetchXblocks(library, textSearch, page+1, true); + } + }); + } catch (error) { + $('#library-sourced-block-picker').trigger('error', { + title: 'Could not fetch xblocks', + message: error, + }); + this.setState({ + xblocks: [], + xblocksLoading: false, + }); + } + }); + } + + onLibrarySearchInput(event) { + event.persist() + this.setState({ + searchedLibrary: event.target.value, + }); + if (!this.debouncedFetchLibraries) { + this.debouncedFetchLibraries = _.debounce(value => { + this.fetchLibraries(value); + }, 300); + } + this.debouncedFetchLibraries(event.target.value); + } + + onXBlockSearchInput(event) { + event.persist() + if (!this.debouncedFetchXblocks) { + this.debouncedFetchXblocks = _.debounce(value => { + this.fetchXblocks(this.state.selectedLibrary, value); + }, 300); + } + this.debouncedFetchXblocks(event.target.value); + } + + onLibrarySelected(event) { + this.setState({ + selectedLibrary: event.target.value, + }); + this.fetchXblocks(event.target.value); + } + + onXblockSelected(event) { + let state = new Set(this.state.selectedXblocks); + if (event.target.checked) { + state.add(event.target.value); + } else { + state.delete(event.target.value); + } + this.setState({ + selectedXblocks: state, + }, this.updateList); + } + + onDeleteClick(event) { + let value; + if (event.target.tagName == 'SPAN') { + value = event.target.parentElement.dataset.value; + } else { + value = event.target.dataset.value; + } + let state = new Set(this.state.selectedXblocks); + state.delete(value); + this.setState({ + selectedXblocks: state, + }, this.updateList); + } + + updateList(list) { + $('#library-sourced-block-picker').trigger('selected-xblocks', { + sourceBlockIds: Array.from(this.state.selectedXblocks), + }); + } + + render() { + return ( +
+
+
+

+ + Hitting 'Save and Import' will import the latest versions of the selected blocks, overwriting any changes done to this block post-import. +

+
+
+
+
+ +
+ { + this.state.libraries.map(lib => ( +
+ + +
+ )) + } + { this.state.libraryLoading && {gettext('Loading...')} } +
+
+
+ +
+ { + this.state.xblocks.map(block => ( +
+ + +
+ )) + } + { this.state.xblocksLoading && {gettext('Loading...')} } +
+
+
+

{gettext('Selected blocks')}

+
    + { + Array.from(this.state.selectedXblocks).map(block => ( +
  • + + +
  • + )) + } +
+
+
+
+ ); + } +} + +LibrarySourcedBlockPicker.propTypes = { + selectedXblocks: PropTypes.array, +}; + +LibrarySourcedBlockPicker.defaultProps = { + selectedXblocks: [], +}; + +export { LibrarySourcedBlockPicker }; // eslint-disable-line import/prefer-default-export diff --git a/common/lib/xmodule/xmodule/assets/library_source_block/public/js/library_source_block.js b/common/lib/xmodule/xmodule/assets/library_source_block/public/js/library_source_block.js index cbbe6c0790..9674080d59 100644 --- a/common/lib/xmodule/xmodule/assets/library_source_block/public/js/library_source_block.js +++ b/common/lib/xmodule/xmodule/assets/library_source_block/public/js/library_source_block.js @@ -1,17 +1,25 @@ -/* JavaScript for allowing editing options on LibrarySourceBlock's author view */ -window.LibrarySourceBlockAuthorView = function(runtime, element) { +/* JavaScript for allowing editing options on LibrarySourceBlock's studio view */ +window.LibrarySourceBlockStudioView = function(runtime, element) { 'use strict'; - var $element = $(element); + var self = this; - $element.on('click', '.save-btn', function(e) { + $('#library-sourced-block-picker', element).on('selected-xblocks', function(e, params) { + self.sourceBlockIds = params.sourceBlockIds; + }); + + $('#library-sourced-block-picker', element).on('error', function(e, params) { + runtime.notify('error', {title: gettext(params.title), message: params.message}); + }); + + $('.save-button', element).on('click', function(e) { + e.preventDefault(); var url = $(e.target).data('submit-url'); var data = { values: { - source_block_id: $element.find('input').val() + source_block_ids: self.sourceBlockIds }, defaults: ['display_name'] }; - e.preventDefault(); runtime.notify('save', { state: 'start', @@ -42,4 +50,9 @@ window.LibrarySourceBlockAuthorView = function(runtime, element) { runtime.notify('error', {title: gettext('Unable to update settings'), message: message}); }); }); + + $('.cancel-button', element).on('click', function(e) { + e.preventDefault(); + runtime.notify('cancel', {}); + }); }; diff --git a/common/lib/xmodule/xmodule/assets/library_source_block/style.css b/common/lib/xmodule/xmodule/assets/library_source_block/style.css new file mode 100644 index 0000000000..4892f20405 --- /dev/null +++ b/common/lib/xmodule/xmodule/assets/library_source_block/style.css @@ -0,0 +1,58 @@ +.column { + display: flex; + flex-direction: column; + margin: 10px; + max-width: 300px; + flex-grow: 1; +} +.elementList { + margin-top: 10px; +} +input.search { + width: 100% !important; + height: auto !important; +} +.element > input[type='checkbox'], +.element > input[type='radio'] { + position: absolute; + width: 0 !important; + height: 0 !important; + top: -9999px; +} +.element > .elementItem { + display: flex; + flex-grow: 1; + padding: 0.625rem 1.25rem; + border: 1px solid rgba(0, 0, 0, 0.25); +} +.element + .element > label { + border-top: 0; +} +.element > input[type='checkbox']:focus + label, +.element > input[type='radio']:focus + label, +.element > input:hover + label { + background: #f6f6f7; + cursor: pointer; +} +.element > input:checked + label { + background: #23419f; + color: #fff; +} +.element > input[type='checkbox']:checked:focus + label, +.element > input[type='radio']:checked:focus + label, +.element > input:checked:hover + label { + background: #193787; + cursor: pointer; +} +.selectedBlocks { + padding: 12px 8px 20px; +} +button.remove { + background: #e00; + color: #fff; + border: solid rgba(0,0,0,0.25) 1px; +} +button.remove:focus, +button.remove:hover { + background: #d00; +} diff --git a/common/lib/xmodule/xmodule/library_sourced_block.py b/common/lib/xmodule/xmodule/library_sourced_block.py index 205bb7f4a0..4005ffabb1 100644 --- a/common/lib/xmodule/xmodule/library_sourced_block.py +++ b/common/lib/xmodule/xmodule/library_sourced_block.py @@ -4,12 +4,14 @@ Library Sourced Content XBlock import logging from copy import copy -from web_fragments.fragment import Fragment +from mako.template import Template as MakoTemplate from xblock.core import XBlock -from xblock.fields import Scope, String +from xblock.fields import Scope, String, List +from xblock.validation import ValidationMessage from xblockutils.resources import ResourceLoader from xblockutils.studio_editable import StudioEditableXBlockMixin from webob import Response +from web_fragments.fragment import Fragment from cms.lib.xblock.runtime import handler_url from xmodule.studio_editable import StudioEditableBlock as EditableChildrenMixin @@ -41,38 +43,54 @@ class LibrarySourcedBlock(StudioEditableXBlockMixin, EditableChildrenMixin, XBlo display_name=_("Display Name"), scope=Scope.content, ) - source_block_id = String( - display_name=_("Library Block"), - help=_("Enter the IDs of the library XBlock that you wish to use."), + source_block_ids = List( + display_name=_("Library Blocks List"), + help=_("Enter the IDs of the library XBlocks that you wish to use."), scope=Scope.content, ) - editable_fields = ("display_name", "source_block_id") + editable_fields = ("display_name", "source_block_ids") has_children = True has_author_view = True resources_dir = 'assets/library_source_block' + MAX_BLOCKS_ALLOWED = 10 def __str__(self): return "LibrarySourcedBlock: {}".format(self.display_name) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.source_block_ids: + self.has_children = False + + def studio_view(self, context): + """ + Render a form for editing this XBlock + """ + fragment = Fragment() + static_content = ResourceLoader('common.djangoapps.pipeline_mako').load_unicode('templates/static_content.html') + render_react = MakoTemplate(static_content, default_filters=[]).get_def('renderReact') + react_content = render_react.render( + component="LibrarySourcedBlockPicker", + id="library-sourced-block-picker", + props={ + 'selectedXblocks': self.source_block_ids, + } + ) + fragment.content = loader.render_django_template('templates/library-sourced-block-studio-view.html', { + 'react_content': react_content, + 'save_url': handler_url(self, 'submit_studio_edits'), + }) + + fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_source_block.js')) + fragment.initialize_js('LibrarySourceBlockStudioView') + + return fragment + def author_view(self, context): """ Renders the Studio preview view. """ fragment = Fragment() - root_xblock = context.get('root_xblock') - is_root = root_xblock and root_xblock.location == self.location # pylint: disable=no-member - # If block ID is not defined, ask user for the component ID in the author_view itself. - # We don't display the editor if is_root as that page should represent the student_view without any ambiguity - if not self.source_block_id and not is_root: - fragment.add_content( - loader.render_django_template('templates/library-sourced-block-author-view.html', { - 'save_url': handler_url(self, 'submit_studio_edits') - }) - ) - fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_source_block.js')) - fragment.initialize_js('LibrarySourceBlockAuthorView') - return fragment - context = {} if not context else copy(context) # Isolate context - without this there are weird bugs in Studio # EditableChildrenMixin.render_children will render HTML that allows instructors to make edits to the children context['can_move'] = False @@ -92,6 +110,21 @@ class LibrarySourcedBlock(StudioEditableXBlockMixin, EditableChildrenMixin, XBlo result.add_content('') return result + def validate_field_data(self, validation, data): + """ + Validate this block's field data. Instead of checking fields like self.name, check the + fields set on data, e.g. data.name. This allows the same validation method to be re-used + for the studio editor. + """ + if len(data.source_block_ids) > self.MAX_BLOCKS_ALLOWED: + # Because importing library blocks is an expensive operation + validation.add( + ValidationMessage( + ValidationMessage.ERROR, + _(u"A maximum of {0} components may be added.").format(self.MAX_BLOCKS_ALLOWED) + ) + ) + def validate(self): """ Validates the state of this library_sourced_xblock Instance. This is the override of the general XBlock method, @@ -100,11 +133,11 @@ class LibrarySourcedBlock(StudioEditableXBlockMixin, EditableChildrenMixin, XBlo validation = super().validate() validation = StudioValidation.copy(validation) - if not self.source_block_id: + if not self.source_block_ids: validation.set_summary( StudioValidationMessage( StudioValidationMessage.NOT_CONFIGURED, - _(u"No XBlock has been configured for this component. Enter the target ID below or in the editor"), + _(u"No XBlock has been configured for this component. Use the editor to select the target blocks."), action_class='edit-button', action_label=_(u"Open Editor") ) @@ -120,7 +153,7 @@ class LibrarySourcedBlock(StudioEditableXBlockMixin, EditableChildrenMixin, XBlo # Replace our current children with the latest ones from the libraries. lib_tools = self.runtime.service(self, 'library_tools') try: - lib_tools.import_from_blockstore(self, self.source_block_id) + lib_tools.import_from_blockstore(self, self.source_block_ids) except Exception as err: # pylint: disable=broad-except log.exception(err) return Response(_(u"Importing Library Block failed - are the IDs valid and readable?"), status=400) diff --git a/common/lib/xmodule/xmodule/library_tools.py b/common/lib/xmodule/xmodule/library_tools.py index 6968eb1ccd..b3589b78c8 100644 --- a/common/lib/xmodule/xmodule/library_tools.py +++ b/common/lib/xmodule/xmodule/library_tools.py @@ -13,6 +13,7 @@ from xblock.fields import Scope from openedx.core.djangoapps.content_libraries import api as library_api from openedx.core.djangoapps.xblock.api import load_block +from openedx.core.lib import blockstore_api from student.auth import has_studio_write_access from xmodule.capa_module import ProblemBlock from xmodule.library_content_module import ANY_CAPA_TYPE_VALUE @@ -190,7 +191,7 @@ class LibraryToolsService(object): for lib in self.store.get_library_summaries() ] - def import_from_blockstore(self, dest_block, blockstore_block_id): + def import_from_blockstore(self, dest_block, blockstore_block_ids): """ Imports a block from a blockstore-based learning context (usually a content library) into modulestore, as a new child of dest_block. @@ -205,18 +206,31 @@ class LibraryToolsService(object): if self.user_id is None: raise ValueError("Cannot check user permissions - LibraryTools user_id is None") + if len(set(blockstore_block_ids)) != len(blockstore_block_ids): + # We don't support importing the exact same block twice because it would break the way we generate new IDs + # for each block and then overwrite existing copies of blocks when re-importing the same blocks. + raise ValueError("One or more library component IDs is a duplicate.") + dest_course_key = dest_key.context_key user = User.objects.get(id=self.user_id) if not has_studio_write_access(user, dest_course_key): raise PermissionDenied() # Read the source block; this will also confirm that user has permission to read it. - orig_block = load_block(UsageKey.from_string(blockstore_block_id), user) + # (This could be slow and use lots of memory, except for the fact that LibrarySourcedBlock which calls this + # should be limiting the number of blocks to a reasonable limit. We load them all now instead of one at a + # time in order to raise any errors before we start actually copying blocks over.) + orig_blocks = [load_block(UsageKey.from_string(key), user) for key in blockstore_block_ids] with self.store.bulk_operations(dest_course_key): - new_block_id = self._import_block(orig_block, dest_key) + child_ids_updated = set() + + for block in orig_blocks: + new_block_id = self._import_block(block, dest_key) + child_ids_updated.add(new_block_id) + # Remove any existing children that are no longer used - for old_child_id in set(dest_block.children) - set([new_block_id]): + for old_child_id in set(dest_block.children) - child_ids_updated: self.store.delete_item(old_child_id, self.user_id) # If this was called from a handler, it will save dest_block at the end, so we must update # dest_block.children to avoid it saving the old value of children and deleting the new ones. @@ -273,6 +287,8 @@ class LibraryToolsService(object): # If string field (which may also be JSON/XML data), rewrite /static/... URLs to point to blockstore for asset in all_assets: field_value = field_value.replace('/static/{}'.format(asset.path), asset.url) + # Make sure the URL is one that will work from the user's browser when using the docker devstack + field_value = blockstore_api.force_browser_url(field_value) setattr(new_block, field_name, field_value) new_block.save() self.store.update_item(new_block, self.user_id) diff --git a/common/lib/xmodule/xmodule/templates/library-sourced-block-author-view.html b/common/lib/xmodule/xmodule/templates/library-sourced-block-author-view.html deleted file mode 100644 index 003d3b6181..0000000000 --- a/common/lib/xmodule/xmodule/templates/library-sourced-block-author-view.html +++ /dev/null @@ -1,9 +0,0 @@ -
-

- To display a component from a content library here, enter the component ID (XBlock ID) that you want to use: -

-
- - -
-
\ No newline at end of file diff --git a/common/lib/xmodule/xmodule/templates/library-sourced-block-studio-view.html b/common/lib/xmodule/xmodule/templates/library-sourced-block-studio-view.html new file mode 100644 index 0000000000..08a2882b51 --- /dev/null +++ b/common/lib/xmodule/xmodule/templates/library-sourced-block-studio-view.html @@ -0,0 +1,19 @@ +{% load i18n %} +
+
+
+ {{ react_content|safe }} +
+
+
+ +
+
diff --git a/common/lib/xmodule/xmodule/tests/test_library_sourced_block.py b/common/lib/xmodule/xmodule/tests/test_library_sourced_block.py index c3b58a726d..9a8f2b8d68 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_sourced_block.py +++ b/common/lib/xmodule/xmodule/tests/test_library_sourced_block.py @@ -19,50 +19,48 @@ class LibrarySourcedBlockTestCase(ContentLibrariesRestApiTest): def setUp(self): super().setUp() self.store = modulestore() - - def test_block_views(self): - # Create a blockstore content library - library = self._create_library(slug="testlib1_preview", title="Test Library 1", description="Testing XBlocks") - # Add content to the library - html_block_id = self._add_block_to_library(library["id"], "html", "html_student_preview")["id"] - self._set_library_block_olx(html_block_id, 'Student Preview Test') - # Create a modulestore course course = CourseFactory.create(modulestore=self.store, user_id=self.user.id) CourseInstructorRole(course.id).add_users(self.user) # Add a "Source from Library" block to the course - source_block = ItemFactory.create( + self.source_block = ItemFactory.create( category="library_sourced", parent=course, parent_location=course.location, user_id=self.user.id, modulestore=self.store ) + self.submit_url = '/xblock/{0}/handler/submit_studio_edits'.format(self.source_block.scope_ids.usage_id) - # Check if author_view for empty block renders using the editor template - html = source_block.render(AUTHOR_VIEW).content - loader = ResourceLoader('xmodule.library_sourced_block') - expected_html = loader.render_django_template('templates/library-sourced-block-author-view.html', { - 'save_url': handler_url(source_block, 'submit_studio_edits') - }) - self.assertEqual(expected_html, html) + def test_block_views(self): + # Create a blockstore content library + library = self._create_library(slug="testlib1_preview", title="Test Library 1", description="Testing XBlocks") + # Add content to the library + html_block_1 = self._add_block_to_library(library["id"], "html", "html_student_preview_1")["id"] + self._set_library_block_olx(html_block_1, 'Student Preview Test 1') + html_block_2 = self._add_block_to_library(library["id"], "html", "html_student_preview_2")["id"] + self._set_library_block_olx(html_block_2, 'Student Preview Test 2') - submit_studio_edits_url = '/xblock/{0}/handler/submit_studio_edits'.format(source_block.scope_ids.usage_id) - post_data = {"values": {"source_block_id": html_block_id}, "defaults": ["display_name"]} - # Import the html block from the library to the course - self.client.post(submit_studio_edits_url, data=post_data, format='json') - - # Check if author_view for a configured block renders the children correctly - # Use self.get_block_view for rendering these as mako templates are mocked to return repr of the template - # instead of the rendered html - res = self.get_block_view(source_block, AUTHOR_VIEW) - self.assertNotIn('library-sourced-block-author-view.html', res) - self.assertIn('studio_render_children_view.html', res) - self.assertIn('Student Preview Test', res) + # Import the html blocks from the library to the course + post_data = {"values": {"source_block_ids": [html_block_1, html_block_2]}, "defaults": ["display_name"]} + res = self.client.post(self.submit_url, data=post_data, format='json') # Check if student_view renders the children correctly - res = self.get_block_view(source_block, STUDENT_VIEW) - self.assertIn('Student Preview Test', res) + res = self.get_block_view(self.source_block, STUDENT_VIEW) + self.assertIn('Student Preview Test 1', res) + self.assertIn('Student Preview Test 2', res) + + def test_block_limits(self): + # Create a blockstore content library + library = self._create_library(slug="testlib2_preview", title="Test Library 2", description="Testing XBlocks") + # Add content to the library + blocks = [self._add_block_to_library(library["id"], "html", "block_{0}".format(i))["id"] for i in range(11)] + + # Import the html blocks from the library to the course + post_data = {"values": {"source_block_ids": blocks}, "defaults": ["display_name"]} + res = self.client.post(self.submit_url, data=post_data, format='json') + self.assertEqual(res.status_code, 400) + self.assertEqual(res.json()['error']['messages'][0]['text'], "A maximum of 10 components may be added.") def get_block_view(self, block, view, context=None): """ diff --git a/common/lib/xmodule/xmodule/tests/test_library_tools.py b/common/lib/xmodule/xmodule/tests/test_library_tools.py index 188dbf180b..472ebb1355 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_tools.py +++ b/common/lib/xmodule/xmodule/tests/test_library_tools.py @@ -66,7 +66,7 @@ class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest): sourced_block = self.make_block("library_sourced", course, user_id=self.user.id) # Import the unit block from the library to the course - self.tools.import_from_blockstore(sourced_block, unit_block_id) + self.tools.import_from_blockstore(sourced_block, [unit_block_id]) # Verify imported block with its children self.assertEqual(len(sourced_block.children), 1) @@ -86,7 +86,7 @@ class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest): # Check that reimporting updates the target block self._set_library_block_olx(html_block_id, 'Foo bar') - self.tools.import_from_blockstore(sourced_block, unit_block_id) + self.tools.import_from_blockstore(sourced_block, [unit_block_id]) self.assertEqual(len(sourced_block.children), 1) imported_unit_block = self.store.get_item(sourced_block.children[0]) diff --git a/webpack.common.config.js b/webpack.common.config.js index dd4111065f..d54f82437b 100644 --- a/webpack.common.config.js +++ b/webpack.common.config.js @@ -72,6 +72,7 @@ module.exports = Merge.smart({ // Studio Import: './cms/static/js/features/import/factories/import.js', CourseOrLibraryListing: './cms/static/js/features_jsx/studio/CourseOrLibraryListing.jsx', + LibrarySourcedBlockPicker: './common/lib/xmodule/xmodule/assets/library_source_block/LibrarySourcedBlockPicker.jsx', // eslint-disable-line max-len 'js/factories/textbooks': './cms/static/js/factories/textbooks.js', 'js/factories/container': './cms/static/js/factories/container.js', 'js/factories/context_course': './cms/static/js/factories/context_course.js', @@ -346,6 +347,18 @@ module.exports = Merge.smart({ { test: /logger/, loader: 'imports-loader?this=>window' + }, + { + test: /\.css$/, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { + modules: true + } + } + ] } ] }, diff --git a/webpack.dev.config.js b/webpack.dev.config.js index 913d773cac..68906fcab1 100644 --- a/webpack.dev.config.js +++ b/webpack.dev.config.js @@ -26,7 +26,7 @@ module.exports = _.values(Merge.smart(commonConfig, { module: { rules: [ { - test: /(.scss|.css)$/, + test: /.scss$/, include: [ /paragon/, /font-awesome/