├── .gitignore ├── _config.php ├── CONTRIBUTING.md ├── .gitattributes ├── CHANGELOG.md ├── _config └── config.yml ├── phpunit.xml.dist ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── ci.yml ├── .editorconfig ├── composer.json ├── package.json ├── README.md ├── src ├── Controller │ └── FAQPageController.php ├── Page │ └── FAQPage.php ├── Model │ ├── FAQTopic.php │ └── FAQ.php ├── Admin │ └── FAQAdmin.php └── BulkLoader │ └── FAQBulkLoader.php ├── gulpfile.js └── LICENSE.md /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /_config.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | tests 4 | 5 | 6 | 7 | 8 | src/ 9 | 10 | tests/ 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: 'FEATURE ' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # For more information about the properties used in 2 | # this file, please see the EditorConfig documentation: 3 | # http://editorconfig.org/ 4 | 5 | root = true 6 | 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | indent_size = 4 11 | indent_style = tab 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | 15 | # Docs say 80 ideally, 100 ok, no more than 120 16 | # http://doc.silverstripe.org/en/getting_started/coding_conventions/ 17 | max_line_length = 100 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | 22 | [*.yml] 23 | indent_size = 2 24 | indent_style = space 25 | 26 | #PSR 2 27 | [**.php] 28 | indent_style = space 29 | indent_size = 4 30 | 31 | [{.travis.yml,package.json}] 32 | # The indent size used in the `package.json` file cannot be changed 33 | # https://github.com/npm/npm/pull/3180#issuecomment-16336516 34 | indent_size = 2 35 | indent_style = space 36 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dynamic/dynamic-faqs", 3 | "description": "filterable list of FAQs grouped by topic", 4 | "authors": [ 5 | { 6 | "name": "Dynamic", 7 | "email": "dev@dynamicagency.com", 8 | "homepage": "https://www.dynamicagency.com" 9 | } 10 | ], 11 | "keywords": [ 12 | "silverstripe" 13 | ], 14 | "type": "silverstripe-vendormodule", 15 | "license": "BSD-3-Clause", 16 | "require": { 17 | "dynamic/silverstripe-collection": "^2.0", 18 | "dynamic/viewable-dataobject": "^2.0", 19 | "silverstripe/recipe-cms": "^4.0", 20 | "symbiote/silverstripe-gridfieldextensions": "^3.0" 21 | }, 22 | "require-dev": { 23 | "phpunit/phpunit": "^5.7" 24 | }, 25 | "config": { 26 | "process-timeout": 600 27 | }, 28 | "minimum-stability": "dev", 29 | "prefer-stable": true, 30 | "extra": { 31 | "branch-alias": { 32 | "dev-master": "2.x-dev" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: 'BUG ' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dynamic-faqs", 3 | "version": "1.0.0", 4 | "description": "filterable list of FAQs grouped by topic", 5 | "main": "./public/src/main.js", 6 | "license": "BSD-3-Clause", 7 | "author": "Dynamic", 8 | "contributors": [ 9 | { 10 | "name": "Dynamic", 11 | "email": "dev@dynamicagency.com" 12 | } 13 | ], 14 | "engines": { 15 | "node": "^4.2.0" 16 | }, 17 | "devDependencies": { 18 | "babel-core": "^6.4.0", 19 | "babel-jest": "^6.0.1", 20 | "babel-preset-es2015": "^6.3.13", 21 | "babelify": "^7.2.0", 22 | "browserify": "^12.0.1", 23 | "gulp": "^3.9.0", 24 | "gulp-sass": "^2.0.4", 25 | "jest-cli": "^0.8.2", 26 | "semver": "^5.0.3", 27 | "vinyl-source-stream": "^1.1.0" 28 | }, 29 | "scripts": { 30 | "build": "gulp", 31 | "test": "jest" 32 | }, 33 | "babel": { 34 | "presets": [ 35 | "es2015" 36 | ] 37 | }, 38 | "jest": { 39 | "scriptPreprocessor": "/node_modules/babel-jest", 40 | "testDirectoryName": "tests/javascript" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dynamic-faqs 2 | [![Build Status](https://travis-ci.com/dynamic/dynamic-faqs.svg?token=hFT1sXd4nNmguE972zHN&branch=master)](https://travis-ci.com/dynamic/dynamic-faqs) 3 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/dynamic/dynamic-faqs/badges/quality-score.png?b=master&s=ac2fd78ae634001a90792654167eda21a93dfa16)](https://scrutinizer-ci.com/g/dynamic/dynamic-faqs/?branch=master) 4 | [![Code Coverage](https://scrutinizer-ci.com/g/dynamic/dynamic-faqs/badges/coverage.png?b=master&s=b3e96c9506f8d000fda84cbfdf75e850553b3899)](https://scrutinizer-ci.com/g/dynamic/dynamic-faqs/?branch=master) 5 | [![codecov](https://codecov.io/gh/dynamic/dynamic-faqs/branch/master/graph/badge.svg?token=5aCaMK3w8X)](https://codecov.io/gh/dynamic/dynamic-faqs) 6 | 7 | filterable list of FAQs grouped by topic 8 | 9 | ## Requirements 10 | 11 | - SilverStripe 3.2 12 | 13 | ## Installation 14 | 15 | This is how you install dynamic-faqs. 16 | 17 | ## Example usage 18 | 19 | You use dynamic-faqs like this. 20 | 21 | ## Documentation 22 | 23 | See the [docs/en](docs/en/index.md) folder. 24 | -------------------------------------------------------------------------------- /src/Controller/FAQPageController.php: -------------------------------------------------------------------------------- 1 | latestParam('ID'); 26 | 27 | if (!$object = FAQ::get()->filter('URLSegment', $urlSegment)->first()) { 28 | return $this->httpError(404, "The FAQ you're looking for doesn't seem to be here."); 29 | } 30 | 31 | return $this->customise(new ArrayData([ 32 | 'Object' => $object, 33 | 'Title' => $object->Title, 34 | 'MetaTags' => $object->MetaTags(), 35 | 'Breadcrumbs' => $object->Breadcrumbs(), 36 | ]))->renderWith([ 37 | 'FAQ', 38 | 'Page', 39 | ]); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'), 2 | browserify = require('browserify'), 3 | babelify = require('babelify'), 4 | source = require('vinyl-source-stream'), 5 | sass = require('gulp-sass'), 6 | packageJSON = require('./package.json'), 7 | semver = require('semver'); 8 | 9 | // Make sure the Node.js version is valid. 10 | if (!semver.satisfies(process.versions.node, packageJSON.engines.node)) { 11 | console.error('Invalid Node.js version. You need to be using ' + packageJSON.engines.node); 12 | process.exit(1); 13 | } 14 | 15 | gulp.task('js', function () { 16 | browserify({ 17 | entries: './javascript/src/main.js', 18 | extensions: ['.js'], 19 | debug: true 20 | }) 21 | .transform(babelify) 22 | .bundle() 23 | .pipe(source('bundle.js')) 24 | .pipe(gulp.dest('./javascript/dist')); 25 | }); 26 | 27 | gulp.task('sass', function () { 28 | gulp.src('./scss/main.scss') 29 | .pipe(sass().on('error', sass.logError)) 30 | .pipe(gulp.dest('./css')); 31 | }); 32 | 33 | gulp.task('js:watch', function () { 34 | gulp.watch('./javascript/**/*.js', ['js']); 35 | }); 36 | 37 | gulp.task('sass:watch', function () { 38 | gulp.watch('./scss/**/*.scss', ['sass']); 39 | }); 40 | 41 | gulp.task('default', ['js', 'sass', 'js:watch', 'sass:watch']); 42 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Dynamic 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 13 | -------------------------------------------------------------------------------- /src/Page/FAQPage.php: -------------------------------------------------------------------------------- 1 | beforeUpdateCMSFields(function (FieldList $fields) { 35 | if ($this->ID) { 36 | $config = GridFieldConfig_RecordEditor::create(); 37 | $config->addComponents([ 38 | new GridFieldOrderableRows('SortOrder'), 39 | //new GridFieldAddExistingSearchButton(), 40 | ]) 41 | ->removeComponentsByType([ 42 | GridFieldAddExistingAutocompleter::class, 43 | ]); 44 | 45 | $faqs = GridField::create( 46 | 'FAQs', 47 | 'FAQs', 48 | FAQ::get()->sort('SortOrder'), 49 | $config 50 | ); 51 | $fields->addFieldsToTab('Root.FAQs', [ 52 | $faqs, 53 | ]); 54 | } 55 | }); 56 | 57 | return parent::getCMSFields(); 58 | } 59 | 60 | /** 61 | * @return DataList 62 | */ 63 | public function getFAQList() 64 | { 65 | return FAQ::get()->sort('SortOrder'); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Model/FAQTopic.php: -------------------------------------------------------------------------------- 1 | 'Varchar(255)', 26 | 'Content' => 'HTMLText', 27 | ); 28 | 29 | /** 30 | * @var array 31 | */ 32 | private static $belongs_many_many = array( 33 | 'FAQs' => FAQ::class, 34 | ); 35 | 36 | /** 37 | * @var string 38 | */ 39 | private static $table_name = "FAQTopic"; 40 | 41 | /** 42 | * @var string 43 | */ 44 | private static $default_sort = 'Title'; 45 | 46 | public function getCMSFields() 47 | { 48 | $fields = parent::getCMSFields(); 49 | 50 | if ($this->ID) { 51 | $faqs = $fields->dataFieldByName('FAQs'); 52 | $config = $faqs->getConfig(); 53 | $config->removeComponentsByType('GridFieldAddExistingAutocompleter'); 54 | $config->addComponent(new GridFieldAddExistingSearchButton()); 55 | $config->removeComponentsByType('GridFieldAddNewButton'); 56 | } 57 | 58 | return $fields; 59 | } 60 | 61 | /** 62 | * @param null $member 63 | * @return bool|int 64 | */ 65 | public function canCreate($member = null, $context = []) 66 | { 67 | return Permission::check('FAQ_CREATE', 'any', $member); 68 | } 69 | 70 | /** 71 | * @param null $member 72 | * @return bool|int 73 | */ 74 | public function canEdit($member = null) 75 | { 76 | return Permission::check('FAQ_EDIT', 'any', $member); 77 | } 78 | 79 | /** 80 | * @param null $member 81 | * @return bool|int 82 | */ 83 | public function canDelete($member = null) 84 | { 85 | return Permission::check('FAQ_DELETE', 'any', $member); 86 | } 87 | 88 | /** 89 | * @param null $member 90 | * @return bool 91 | */ 92 | public function canView($member = null) 93 | { 94 | return true; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/en/categories/automating-your-workflow-with-github-actions 2 | 3 | on: 4 | push: 5 | branches: [ '**' ] 6 | pull_request: 7 | branches: [ '**' ] 8 | 9 | name: "CI" 10 | 11 | jobs: 12 | tests: 13 | name: "Tests" 14 | 15 | runs-on: "ubuntu-latest" 16 | 17 | env: 18 | php_extensions: ctype, dom, fileinfo, hash, intl, mbstring, session, simplexml, tokenizer, xml, pdo, mysqli, gd, zip 19 | 20 | services: 21 | mysql: 22 | image: "mysql:5.7" 23 | env: 24 | MYSQL_ALLOW_EMPTY_PASSWORD: true 25 | MYSQL_ROOT_PASSWORD: 26 | MYSQL_DATABASE: test_db 27 | ports: 28 | - 3306/tcp 29 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 30 | 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | php-version: 35 | - "7.3" 36 | - "7.4" 37 | 38 | steps: 39 | - name: "Checkout" 40 | uses: "actions/checkout@v2" 41 | 42 | - name: "Install PHP with extensions" 43 | uses: "shivammathur/setup-php@v2" 44 | with: 45 | php-version: "${{ matrix.php-version }}" 46 | extensions: "${{ env.php_extensions }}" 47 | coverage: "xdebug" 48 | 49 | - name: "Start mysql service" 50 | run: "sudo /etc/init.d/mysql start" 51 | 52 | - name: "Cache dependencies installed with composer" 53 | uses: "actions/cache@v1" 54 | with: 55 | path: "~/.composer/cache" 56 | key: "php${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-${{ hashFiles('**/composer.json') }}" 57 | restore-keys: "php${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-" 58 | 59 | - name: "Authorize private packagist" 60 | env: 61 | COMPOSER_TOKEN: ${{ secrets.COMPOSER_TOKEN }} 62 | run: "if [[ $COMPOSER_TOKEN ]]; then composer config --global --auth http-basic.repo.packagist.com token $COMPOSER_TOKEN; fi" 63 | 64 | - name: "Install dependencies with composer" 65 | run: "composer install --no-ansi --no-interaction --no-progress" 66 | 67 | - name: "Run tests with phpunit/phpunit" 68 | env: 69 | SS_DATABASE_PORT: ${{ job.services.mysql.ports['3306'] }} 70 | #run: "vendor/bin/phpunit --coverage-html=build/logs/coverage --coverage-xml=build/logs/coverage" 71 | run: "vendor/bin/phpunit" 72 | 73 | - name: "Run tests with squizlabs/php_codesniffer" 74 | run: "vendor/bin/phpcs -s --report=summary --standard=phpcs.xml.dist --extensions=php,inc --ignore=autoload.php --ignore=vendor/ app/src/ app/tests/ || exit 0" 75 | 76 | 77 | -------------------------------------------------------------------------------- /src/Admin/FAQAdmin.php: -------------------------------------------------------------------------------- 1 | FAQBulkLoader::class, 26 | ); 27 | 28 | /** 29 | * @var string 30 | */ 31 | private static $url_segment = 'faqs'; 32 | 33 | /** 34 | * @var string 35 | */ 36 | private static $menu_title = 'FAQs'; 37 | 38 | /** 39 | * @return \SilverStripe\ORM\Search\SearchContext 40 | */ 41 | public function getSearchContext() 42 | { 43 | $context = parent::getSearchContext(); 44 | $params = $this->request->requestVar('q'); 45 | 46 | if ($this->modelClass == 'FAQ') { 47 | $fields = $context->getFields(); 48 | 49 | $fields->removeByName('q[LastEdited]'); 50 | $fields->push(new CheckboxField('q[Last6Months]', 'Records Requiring Update')); 51 | } 52 | 53 | return $context; 54 | } 55 | 56 | /** 57 | * @return \SilverStripe\ORM\DataList 58 | */ 59 | public function getList() 60 | { 61 | $list = parent::getList(); 62 | 63 | $params = $this->request->requestVar('q'); // use this to access search parameters 64 | 65 | if ($this->modelClass == 'FAQ' && isset($params['Last6Months']) && $params['Last6Months']) { 66 | $list = $list->exclude('LastEdited:GreaterThan', date('Y-m-d', strtotime('-6 months'))); 67 | } 68 | 69 | return $list; 70 | } 71 | 72 | /** 73 | * @return array|string[] 74 | */ 75 | public function getExportFields() 76 | { 77 | if ($this->modelClass == 'FAQ') { 78 | return array( 79 | 'ID' => 'ID', 80 | 'Title' => 'Title', 81 | 'URLSegment' => 'URLSegment', 82 | 'Type' => 'Type', 83 | 'Content' => 'Content', 84 | 'URL' => 'URL', 85 | 'Popularity' => 'Popularity', 86 | 'SilverCloud_ID' => 'SilverCloud_ID', 87 | 'TopicNames' => 'Categories', 88 | 'Keywords' => 'Keywords', 89 | 'ShowInResults' => 'ShowInResults', 90 | ); 91 | } 92 | 93 | return parent::getExportFields(); 94 | } 95 | 96 | /** 97 | * @param null $id 98 | * @param null $fields 99 | * @return \SilverStripe\Forms\Form 100 | */ 101 | public function getEditForm($id = null, $fields = null) 102 | { 103 | $form = parent::getEditForm($id, $fields); 104 | 105 | $gridFieldName = 'FAQ'; 106 | $gridField = $form->Fields()->fieldByName($gridFieldName); 107 | 108 | if ($gridField) { 109 | $gridField->getConfig()->getComponentByType('GridFieldPrintButton')->setPrintColumns($columns = array( 110 | 'ID' => 'ID', 111 | 'Title' => 'Title', 112 | 'Content' => 'Content', 113 | )); 114 | } 115 | 116 | return $form; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Model/FAQ.php: -------------------------------------------------------------------------------- 1 | 'Varchar(255)', 40 | 'Content' => 'HTMLText', 41 | 'SortOrder' => 'Int', 42 | ); 43 | 44 | /** 45 | * @var array 46 | */ 47 | private static $many_many = array( 48 | 'Topics' => FAQTopic::class, 49 | ); 50 | 51 | /** 52 | * @var array 53 | */ 54 | private static $many_many_extraFields = array( 55 | 'Topics' => array( 56 | 'Sort' => 'Int', 57 | ), 58 | ); 59 | 60 | /** 61 | * @var string 62 | */ 63 | private static $default_sort = 'SortOrder'; 64 | 65 | /** 66 | * @var array 67 | */ 68 | private static $extensions = [ 69 | Versioned::class, 70 | ]; 71 | 72 | /** 73 | * @var array 74 | */ 75 | private static $summary_fields = array( 76 | 'Title' => 'Title', 77 | 'TopicNames' => 'Topics', 78 | ); 79 | 80 | /** 81 | * @var array 82 | */ 83 | private static $searchable_fields = array( 84 | 'Title' => [ 85 | 'title' => 'Title', 86 | ], 87 | 'Content' => [ 88 | 'title' => 'Content', 89 | ], 90 | 'Topics.ID' => [ 91 | 'title' => 'Topic', 92 | ], 93 | ); 94 | 95 | /** 96 | * @var string 97 | */ 98 | private static $table_name = "FAQ"; 99 | 100 | /** 101 | * @return FieldList 102 | */ 103 | public function getCMSFields() 104 | { 105 | $this->beforeUpdateCMSFields(function (FieldList $fields) { 106 | $fields->removeByName([ 107 | 'SortOrder', 108 | ]); 109 | 110 | if ($this->ID) { 111 | // Topics 112 | $config = GridFieldConfig_RelationEditor::create(); 113 | $config->addComponent(new GridFieldOrderableRows('Sort')); 114 | $config->removeComponentsByType(GridFieldAddExistingAutocompleter::class); 115 | $config->addComponent(new GridFieldAddExistingSearchButton()); 116 | $topics = $this->Topics()->sort('Sort'); 117 | $topicsField = GridField::create('Topics', 'Topics', $topics, $config); 118 | 119 | $fields->addFieldsToTab('Root.Topics', array( 120 | $topicsField, 121 | )); 122 | } 123 | }); 124 | 125 | return parent::getCMSFields(); 126 | } 127 | 128 | /** 129 | * @return ValidationResult 130 | */ 131 | public function validate() 132 | { 133 | $result = parent::validate(); 134 | if (!$this->Title) { 135 | $result->addError('A Title is required before you can save'); 136 | } 137 | 138 | return $result; 139 | } 140 | 141 | /** 142 | * @return string 143 | */ 144 | public function getTopicNames() 145 | { 146 | if ($this->Topics()->exists()) { 147 | $list = ''; 148 | $ct = 1; 149 | foreach ($this->Topics() as $topic) { 150 | $list .= $topic->Title; 151 | if ($ct < $this->Topics()->Count()) { 152 | $list .= ', '; 153 | } 154 | ++$ct; 155 | } 156 | 157 | return $list; 158 | } 159 | 160 | return ''; 161 | } 162 | 163 | /** 164 | * @return mixed 165 | */ 166 | public function getTopicName() 167 | { 168 | return $this->Topics()->first()->getTitle(); 169 | } 170 | 171 | /** 172 | * @return SearchContext 173 | */ 174 | public function getCustomSearchContext() 175 | { 176 | $fields = $this->scaffoldSearchFields(array( 177 | 'restrictFields' => array('Title', 'Topics.ID') 178 | )); 179 | 180 | $filters = array( 181 | 'Title' => new PartialMatchFilter('Title'), 182 | 'Topics.ID' => new ExactMatchFilter('Topics.ID'), 183 | ); 184 | 185 | return new SearchContext( 186 | $this->class, 187 | $fields, 188 | $filters 189 | ); 190 | } 191 | 192 | /** 193 | * @return string 194 | */ 195 | public function getParentPage() 196 | { 197 | return FAQPage::get()->first(); 198 | } 199 | 200 | /** 201 | * @return string 202 | */ 203 | public function getViewAction() 204 | { 205 | return 'view'; 206 | } 207 | 208 | /** 209 | * @return array 210 | */ 211 | public function providePermissions() 212 | { 213 | return array( 214 | 'FAQ_EDIT' => 'Edit a FAQ', 215 | 'FAQ_DELETE' => 'Delete a FAQ', 216 | 'FAQ_CREATE' => 'Create a FAQ', 217 | ); 218 | } 219 | 220 | /** 221 | * @param null $member 222 | * @return bool|int 223 | */ 224 | public function canCreate($member = null, $context = []) 225 | { 226 | return Permission::check('FAQ_CREATE', 'any', $member); 227 | } 228 | 229 | /** 230 | * @param null $member 231 | * @return bool|int 232 | */ 233 | public function canEdit($member = null) 234 | { 235 | return Permission::check('FAQ_EDIT', 'any', $member); 236 | } 237 | 238 | /** 239 | * @param null $member 240 | * @return bool|int 241 | */ 242 | public function canDelete($member = null) 243 | { 244 | return Permission::check('FAQ_DELETE', 'any', $member); 245 | } 246 | 247 | /** 248 | * @param null $member 249 | * @return bool 250 | */ 251 | public function canView($member = null) 252 | { 253 | return true; 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/BulkLoader/FAQBulkLoader.php: -------------------------------------------------------------------------------- 1 | 'Title', 15 | 'Type' => 'Type', 16 | 'Content' => '->getContent', 17 | 'Categories' => '->getTopic', 18 | 'Keywords' => 'Keywords', 19 | ); 20 | 21 | /** 22 | * @var string[] 23 | */ 24 | public $duplicateChecks = array( 25 | 'SilverCloud_ID', 26 | 'ID', 27 | ); 28 | 29 | /** 30 | * getter functions to use in custom importer methods below 31 | * 32 | * @param $val 33 | * @return bool 34 | */ 35 | public function getBoolean($val) 36 | { 37 | return $val == 'Y'; 38 | } 39 | 40 | /** 41 | * @param $val 42 | * @return string 43 | */ 44 | public function getDate($val) 45 | { 46 | return date('Y-m-d', strtotime($val)); 47 | } 48 | 49 | /** 50 | * @param $val 51 | * @return string 52 | */ 53 | public function getTime($val) 54 | { 55 | return date('H:i:s', strtotime($val)); 56 | } 57 | 58 | /** 59 | * @param $val 60 | * @return array|string|string[]|null 61 | */ 62 | public function getEscape($val) 63 | { 64 | $val = str_replace('_x000D_', '', $val); 65 | return preg_replace("/\r|\n/", '', $val); 66 | } 67 | 68 | /** 69 | * @param $obj 70 | * @param $val 71 | * @param $record 72 | */ 73 | public function getContent(&$obj, $val, $record) 74 | { 75 | if ($val) { 76 | $content = $this->getEscape($val); 77 | if ($obj->Type == 'Link' && $obj->URL == '') { 78 | $obj->URL = $content; 79 | } else { 80 | $obj->Content = $content; 81 | } 82 | } 83 | } 84 | 85 | /** 86 | * @param $obj 87 | * @param $val 88 | * @param $record 89 | */ 90 | public function getTopic(&$obj, $val, $record) 91 | { 92 | if ($val) { 93 | $topics = explode(', ', $this->getEscape($val)); 94 | //SS_Log::log($obj->Title . " parent category = " . $parent, SS_Log::WARN); 95 | $ct = 1; 96 | foreach ($topics as $topic) { 97 | $top = FAQTopic::get()->filter('Title', $topic)->First(); 98 | if (!$top) { 99 | $top = FAQTopic::create(); 100 | $top->Title = $topic; 101 | $top->write(); 102 | } 103 | $obj->Topics()->add($top); 104 | ++$ct; 105 | } 106 | } 107 | } 108 | 109 | /** 110 | * @param $obj 111 | * @param $val 112 | * @param $record 113 | */ 114 | public function getTag(&$obj, $val, $record) 115 | { 116 | if ($val) { 117 | $tags = explode(', ', $this->getEscape($val)); 118 | //SS_Log::log($obj->Title . " parent category = " . $parent, SS_Log::WARN); 119 | $ct = 1; 120 | foreach ($tags as $tag) { 121 | $tg = FAQTag::get()->filter('Title', $tag)->First(); 122 | if (!$tg) { 123 | $tg = FAQTag::create(); 124 | $tg->Title = $tag; 125 | $tg->write(); 126 | } 127 | $obj->Tags()->add($tg); 128 | ++$ct; 129 | } 130 | } 131 | } 132 | 133 | /** 134 | * @todo Better messages for relation checks and duplicate detection 135 | * Note that columnMap isn't used. 136 | * 137 | * @param array $record 138 | * @param array $columnMap 139 | * @param BulkLoader_Result $results 140 | * @param bool $preview 141 | * 142 | * @return int 143 | */ 144 | /* 145 | protected function processRecord($record, $columnMap, &$results, $preview = false) 146 | { 147 | $class = $this->objectClass; 148 | 149 | // find existing object, or create new one 150 | $existingObj = $this->findExistingObject($record, $columnMap); 151 | $obj = ($existingObj) ? $existingObj : new $class(); 152 | 153 | // first run: find/create any relations and store them on the object 154 | // we can't combine runs, as other columns might rely on the relation being present 155 | $relations = array(); 156 | foreach ($record as $fieldName => $val) { 157 | // don't bother querying of value is not set 158 | if ($this->isNullValue($val)) { 159 | continue; 160 | } 161 | 162 | // checking for existing relations 163 | if (isset($this->relationCallbacks[$fieldName])) { 164 | // trigger custom search method for finding a relation based on the given value 165 | // and write it back to the relation (or create a new object) 166 | $relationName = $this->relationCallbacks[$fieldName]['relationname']; 167 | if ($this->hasMethod($this->relationCallbacks[$fieldName]['callback'])) { 168 | $relationObj = $this->{$this->relationCallbacks[$fieldName]['callback']}($obj, $val, $record); 169 | } elseif ($obj->hasMethod($this->relationCallbacks[$fieldName]['callback'])) { 170 | $relationObj = $obj->{$this->relationCallbacks[$fieldName]['callback']}($val, $record); 171 | } 172 | if (!$relationObj || !$relationObj->exists()) { 173 | $relationClass = $obj->has_one($relationName); 174 | $relationObj = new $relationClass(); 175 | //write if we aren't previewing 176 | if (!$preview) { 177 | $relationObj->write(); 178 | } 179 | } 180 | $obj->{"{$relationName}ID"} = $relationObj->ID; 181 | //write if we are not previewing 182 | if (!$preview) { 183 | $obj->write(); 184 | $obj->flushCache(); // avoid relation caching confusion 185 | } 186 | } elseif (strpos($fieldName, '.') !== false) { 187 | // we have a relation column with dot notation 188 | list($relationName, $columnName) = explode('.', $fieldName); 189 | // always gives us an component (either empty or existing) 190 | $relationObj = $obj->getComponent($relationName); 191 | if (!$preview) { 192 | $relationObj->write(); 193 | } 194 | $obj->{"{$relationName}ID"} = $relationObj->ID; 195 | 196 | //write if we are not previewing 197 | if (!$preview) { 198 | $obj->write(); 199 | $obj->flushCache(); // avoid relation caching confusion 200 | } 201 | } 202 | } 203 | 204 | // second run: save data 205 | 206 | foreach ($record as $fieldName => $val) { 207 | // break out of the loop if we are previewing 208 | if ($preview) { 209 | break; 210 | } 211 | 212 | // look up the mapping to see if this needs to map to callback 213 | $mapped = $this->columnMap && isset($this->columnMap[$fieldName]); 214 | 215 | if ($mapped && strpos($this->columnMap[$fieldName], '->') === 0) { 216 | $funcName = substr($this->columnMap[$fieldName], 2); 217 | 218 | $this->$funcName($obj, $val, $record); 219 | } elseif ($obj->hasMethod("import{$fieldName}")) { 220 | $obj->{"import{$fieldName}"}($val, $record); 221 | } else { 222 | $obj->update(array($fieldName => $val)); 223 | } 224 | } 225 | 226 | // write record 227 | $id = ($preview) ? 0 : $obj->write(); 228 | 229 | if ($preview) { 230 | $id = 0; 231 | } else { 232 | $id = $obj->writeToStage('Stage'); 233 | // now publish it 234 | //$obj->publish("Stage", "Live"); 235 | } 236 | 237 | // @todo better message support 238 | $message = ''; 239 | 240 | // save to results 241 | if ($existingObj) { 242 | $results->addUpdated($obj, $message); 243 | } else { 244 | $results->addCreated($obj, $message); 245 | } 246 | 247 | $objID = $obj->ID; 248 | 249 | $obj->destroy(); 250 | 251 | // memory usage 252 | unset($existingObj); 253 | unset($obj); 254 | 255 | return $objID; 256 | } 257 | */ 258 | } 259 | --------------------------------------------------------------------------------