@@ -45,12 +45,18 @@ class SearchConsoleDashboardWidgetSiteStats extends Component {

 	setOptions() {
 		const { selectedStats, series, vAxes } = this.props;
+		const { pageTitle } = global.googlesitekit;

-		const pageTitle = global.googlesitekit.pageTitle && global.googlesitekit.pageTitle.length ? sprintf( __( 'Search Traffic Summary for %s', 'google-site-kit' ), decodeHtmlEntity( global.googlesitekit.pageTitle ) ) : __( 'Search Traffic Summary', 'google-site-kit' );
+		let title = __( 'Search Traffic Summary', 'google-site-kit' );
+
+		if ( pageTitle && pageTitle.length ) {
+			/* translators: %s: page title */
+			title = sprintf( __( 'Search Traffic Summary for %s', 'google-site-kit' ), decodeHtmlEntity( pageTitle ) );
+		}

 		const options = {
 			chart: {
-				title: pageTitle,
+				title,
 			},
 			curveType: 'line',
 			height: 270,

/**
 * SearchConsoleDashboardWidgetSiteStats component.
 *
 * Site Kit by Google, Copyright 2019 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * External dependencies
 */
import withData from 'GoogleComponents/higherorder/withdata';
import { TYPE_MODULES } from 'GoogleComponents/data';
import GoogleChart from 'GoogleComponents/google-chart.js';
import PreviewBlock from 'GoogleComponents/preview-block';
import { decodeHtmlEntity, getTimeInSeconds } from 'GoogleUtil';

/**
 * Internal dependencies
 */
import { extractSearchConsoleDashboardData } from './util';

/**
 * WordPress dependencies
 */
import { __, sprintf } from '@wordpress/i18n';
import { Component } from '@wordpress/element';

class SearchConsoleDashboardWidgetSiteStats extends Component {
	constructor( props ) {
		super( props );

		this.setOptions = this.setOptions.bind( this );
	}

	setOptions() {
		const { selectedStats, series, vAxes } = this.props;

		const pageTitle = global.googlesitekit.pageTitle && global.googlesitekit.pageTitle.length ? sprintf( __( 'Search Traffic Summary for %s', 'google-site-kit' ), decodeHtmlEntity( global.googlesitekit.pageTitle ) ) : __( 'Search Traffic Summary', 'google-site-kit' );

		const options = {
			chart: {
				title: pageTitle,
			},
			curveType: 'line',
			height: 270,
			width: '100%',
			chartArea: {
				height: '80%',
				width: '87%',
			},
			legend: {
				position: 'top',
				textStyle: {
					color: '#616161',
					fontSize: 12,
				},
			},
			hAxis: {
				format: 'M/d/yy',
				gridlines: {
					color: '#fff',
				},
				textStyle: {
					color: '#616161',
					fontSize: 12,
				},
			},
			vAxis: {
				gridlines: {
					color: '#eee',
				},
				minorGridlines: {
					color: '#eee',
				},
				textStyle: {
					color: '#616161',
					fontSize: 12,
				},
				titleTextStyle: {
					color: '#616161',
					fontSize: 12,
					italic: false,
				},
			},
		};

		options.series = series;
		options.vAxes = vAxes;

		// Clean up chart if more than three stats are selected.
		if ( 3 <= selectedStats.length ) {
			options.vAxis.textPosition = 'none';
			options.vAxis.gridlines.color = '#fff';
			options.vAxis.minorGridlines.color = '#fff';
			options.chartArea.width = '98%';
		}

		return options;
	}

	render() {
		const { data, selectedStats } = this.props;

		if ( ! data || ! data.length ) {
			return null;
		}

		const options = this.setOptions();
		const processedData = extractSearchConsoleDashboardData( data );

		return (

						<GoogleChart
							selectedStats={ selectedStats }
							data={ processedData.dataMap }
							options={ options }
							singleStat={ false }
						/>

		);
	}
}

export default withData(
	SearchConsoleDashboardWidgetSiteStats,
	[
		{
			type: TYPE_MODULES,
			identifier: 'search-console',
			datapoint: 'searchanalytics',
			data: {
				dimensions: 'date',
				compareDateRanges: true,
			},
			priority: 1,
			maxAge: getTimeInSeconds( 'day' ),
			context: 'Single',
		},
	],
	<PreviewBlock width="100%" height="270px" padding />,
	{ createGrid: true }
);

The ESLint rules weren't properly detecting the translators string in a multiline ternary so I split it up this way, which is probably cleaner anyway


@@ -19,28 +19,23 @@
 /**
  * WordPress dependencies
  */
-import { Component, createPortal } from '@wordpress/element';
+import { createPortal, useEffect, useState } from '@wordpress/element';

-export default class Modal extends Component {
-	constructor( props ) {
-		super( props );
-		this.el = document.createElement( 'div' );
-		// This class is the outermost wrapper which is present on all Site Kit pages.
-		this.root = document.querySelector( '.googlesitekit-plugin' ) || document.body;
-	}
+function Modal( { children } ) {
+	// Using state as we need `el` to not change when the component re-renders
+	const [ el ] = useState( document.createElement( 'div' ) );

-	componentDidMount() {
-		this.root.appendChild( this.el );
-	}
+	useEffect( () => {
+		const root = document.querySelector( '.googlesitekit-plugin' ) || document.body;
+		root.appendChild( el );

-	componentWillUnmount() {
-		this.root.removeChild( this.el );
-	}
+		return () => root.removeChild( el );
+	}, [] );

-	render() {
-		return createPortal(
-			this.props.children,
-			this.el,
-		);
-	}
+	return createPortal(
+		children,
+		el,
+	);
 }
+
+export default Modal;

/**
 * Modal component.
 *
 * Site Kit by Google, Copyright 2019 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * WordPress dependencies
 */
import { Component, createPortal } from '@wordpress/element';

export default class Modal extends Component {
	constructor( props ) {
		super( props );
		this.el = document.createElement( 'div' );
		// This class is the outermost wrapper which is present on all Site Kit pages.
		this.root = document.querySelector( '.googlesitekit-plugin' ) || document.body;
	}

	componentDidMount() {
		this.root.appendChild( this.el );
	}

	componentWillUnmount() {
		this.root.removeChild( this.el );
	}

	render() {
		return createPortal(
			this.props.children,
			this.el,
		);
	}
}

I think this file was accidentally kept. It should be removed in favor of `Modal.js`.


@@ -1,5 +1,5 @@
 const moment = require('moment');
-const packageBody = require('./package.json');
+const packageBody = require('./handsontable/package.json');

 module.exports = {
   HOT_FILENAME: 'handsontable',

const moment = require('moment');
const packageBody = require('./package.json');

module.exports = {
  HOT_FILENAME: 'handsontable',
  HOT_VERSION: packageBody.version,
  HOT_PACKAGE_NAME: packageBody.name,
  HOT_BUILD_DATE: moment().format('DD/MM/YYYY HH:mm:ss'),
  HOT_RELEASE_DATE: '29/09/2021',
};

Ok, now I see what you meant when asking about hot.config   And those are probably used by `licenseBanner.js` to insert package name and file name. Maybe we should rethink how and when we alter the monorepo version. Maybe `handsontable/package.json` should have it injected as wrappers do and this file will become the source of truth?


@@ -1,4 +1,11 @@
 var parent = axe.commons.dom.getComposedParent(node);
-return (['UL', 'OL'].includes(parent.nodeName.toUpperCase()) ||
-    (parent.getAttribute('role') || '').toLowerCase() === 'list');
-
+
+var parentRole = (parent.getAttribute('role') || '').toLowerCase();
+
+var isListRole = parentRole === 'list';
+
+return (
+	(['UL', 'OL'].includes(parent.nodeName.toUpperCase()) &&
+		(!parentRole || isListRole)) ||
+	isListRole
+);

var parent = axe.commons.dom.getComposedParent(node);
return (['UL', 'OL'].includes(parent.nodeName.toUpperCase()) ||
    (parent.getAttribute('role') || '').toLowerCase() === 'list');

This doesn't follow our spacing convention. It's also a little hard to read. Can you reformat?


@@ -1,12 +1,27 @@
-/**
- *
- * sends auth token to uppy client
- */
 const { URL } = require('url')
 const tokenService = require('../helpers/jwt')
 const { hasMatch, sanitizeHtml } = require('../helpers/utils')
 const oAuthState = require('../helpers/oauth-state')
-const versionCmp = require('../helpers/version')
+
+/**
+ *
+ * @param {string} token uppy auth token
+ * @param {string} origin url string
+ */
+const htmlContent = (token, origin) => {
+  return `
+    <!DOCTYPE html>
+    <html>
+    <head>
+        <meta charset="utf-8" />
+        <script>
+          window.opener.postMessage(JSON.stringify({token: "${token}"}), "${sanitizeHtml(origin)}")
+          window.close()
+        </script>
+    </head>
+    <body></body>
+    </html>`
+}

 /**
  *

/**
 *
 * sends auth token to uppy client
 */
const { URL } = require('url')
const tokenService = require('../helpers/jwt')
const { hasMatch, sanitizeHtml } = require('../helpers/utils')
const oAuthState = require('../helpers/oauth-state')
const versionCmp = require('../helpers/version')

/**
 *
 * @param {object} req
 * @param {object} res
 * @param {Function} next
 */
module.exports = function sendToken (req, res, next) {
  const uppyAuthToken = req.companion.authToken
  // some providers need the token in cookies for thumbnail/image requests
  if (req.companion.provider.needsCookieAuth) {
    tokenService.addToCookies(res, uppyAuthToken, req.companion.options, req.companion.provider.authProvider)
  }

  const dynamic = (req.session.grant || {}).dynamic || {}
  const { state } = dynamic
  if (state) {
    const origin = oAuthState.getFromState(state, 'origin', req.companion.options.secret)
    const clientVersion = oAuthState.getFromState(
      state,
      'clientVersion',
      req.companion.options.secret
    )
    const allowedClients = req.companion.options.clients
    // if no preset clients then allow any client
    if (!allowedClients || hasMatch(origin, allowedClients) || hasMatch((new URL(origin)).host, allowedClients)) {
      const allowsStringMessage = versionCmp.gte(clientVersion, '1.0.2')
      return res.send(allowsStringMessage ? htmlContent(uppyAuthToken, origin) : oldHtmlContent(uppyAuthToken, origin))
    }
  }
  next()
}

/**
 *
 * @param {string} token uppy auth token
 * @param {string} origin url string
 */
const htmlContent = (token, origin) => {
  return `
    <!DOCTYPE html>

        <meta charset="utf-8" />

    `
}

/**
 * @todo remove this function in next major release
 * @param {string} token uppy auth token
 * @param {string} origin url string
 */
const oldHtmlContent = (token, origin) => {
  return `
    <!DOCTYPE html>

        <meta charset="utf-8" />

    `
}

I know you didn't write this code, but can we 100% trust the value of token and origin never being manipulated by the user? If not, then I think we should consider some more robust html serialization, maybe use react-dom renderToString or similar. JSON.stringify I believe is very unsafe


@@ -25,9 +25,11 @@ import {
 	subscribeUntil,
 	unsubscribeFromAll,
 } from 'tests/js/utils';
-import { INITIAL_STATE } from './index';
+import store from './index';
 import { STORE_NAME } from './constants';

+const { INITIAL_STATE } = store;
+
 describe( 'core/site site info', () => {
 	const baseInfoVar = '_googlesitekitBaseData';
 	const baseInfo = {

/**
 * core/site data store: site info tests.
 *
 * Site Kit by Google, Copyright 2020 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * Internal dependencies
 */
import {
	createTestRegistry,
	muteConsole,
	subscribeUntil,
	unsubscribeFromAll,
} from 'tests/js/utils';
import { INITIAL_STATE } from './index';
import { STORE_NAME } from './constants';

describe( 'core/site site info', () => {
	const baseInfoVar = '_googlesitekitBaseData';
	const baseInfo = {
		adminURL: 'http://something.test/wp-admin',
		ampMode: 'reader',
		homeURL: 'http://something.test/homepage',
		referenceSiteURL: 'http://something.test',
	};
	const entityInfoVar = '_googlesitekitEntityData';
	const entityInfo = {
		currentEntityURL: 'http://something.test',
		currentEntityType: 'post',
		currentEntityTitle: 'Something Witty',
		currentEntityID: '4',
	};
	let registry;

	beforeEach( () => {
		registry = createTestRegistry();
	} );

	afterEach( () => {
		delete global[ baseInfoVar ];
		delete global[ entityInfoVar ];
		unsubscribeFromAll( registry );
	} );

	describe( 'actions', () => {
		describe( 'receiveSiteInfo', () => {
			it( 'requires the siteInfo param', () => {
				expect( () => {
					registry.dispatch( STORE_NAME ).receiveSiteInfo();
				} ).toThrow( 'siteInfo is required.' );
			} );

			it( 'receives and sets site info ', async () => {
				await registry.dispatch( STORE_NAME ).receiveSiteInfo( { ...baseInfo, ...entityInfo } );

				expect(
					registry.select( STORE_NAME ).getSiteInfo()
				).toMatchObject( { ...baseInfo, ...entityInfo, currentEntityID: 4 } );
			} );
		} );
	} );

	describe( 'selectors', () => {
		describe( 'getSiteInfo', () => {
			it( 'uses a resolver to load site info from a global variable by default, then deletes that global variable after consumption', async () => {
				global[ baseInfoVar ] = baseInfo;
				global[ entityInfoVar ] = entityInfo;

				expect( global[ baseInfoVar ] ).not.toEqual( undefined );
				expect( global[ entityInfoVar ] ).not.toEqual( undefined );

				registry.select( STORE_NAME ).getSiteInfo();
				await subscribeUntil( registry,
					() => (
						registry.select( STORE_NAME ).getSiteInfo() !== INITIAL_STATE
					),
				);

				const info = registry.select( STORE_NAME ).getSiteInfo();

				expect( info ).toEqual( { ...baseInfo, ...entityInfo, currentEntityID: 4 } );

				// Data must not be wiped after retrieving, as it could be used by other dependants.
				expect( global[ baseInfoVar ] ).not.toEqual( undefined );
				expect( global[ entityInfoVar ] ).not.toEqual( undefined );
			} );

			it( 'will return initial state (undefined) when no data is available', async () => {
				expect( global[ baseInfoVar ] ).toEqual( undefined );
				expect( global[ entityInfoVar ] ).toEqual( undefined );

				muteConsole( 'error' );
				const info = registry.select( STORE_NAME ).getSiteInfo();

				expect( info ).toBe( INITIAL_STATE.siteInfo );
			} );
		} );

		describe.each( [
			[ 'getAdminURL' ],
			[ 'getAMPMode' ],
			[ 'getCurrentEntityID' ],
			[ 'getCurrentEntityTitle' ],
			[ 'getCurrentEntityType' ],
			[ 'getCurrentEntityURL' ],
			[ 'getHomeURL' ],
			[ 'getReferenceSiteURL' ],
		] )( `%i()`, ( selector ) => {
			it( 'uses a resolver to load site info then returns the info when this specific selector is used', async () => {
				global[ baseInfoVar ] = baseInfo;
				global[ entityInfoVar ] = entityInfo;

				registry.select( STORE_NAME )[ selector ]();
				await subscribeUntil( registry,
					() => (
						registry.select( STORE_NAME )[ selector ]() !== undefined
					),
				);

				const info = registry.select( STORE_NAME ).getSiteInfo();

				expect( info ).toEqual( { ...baseInfo, ...entityInfo, currentEntityID: 4 } );
			} );

			it( 'will return initial state (undefined) when no data is available', async () => {
				expect( global[ baseInfoVar ] ).toEqual( undefined );
				expect( global[ entityInfoVar ] ).toEqual( undefined );

				muteConsole( 'error' );
				const result = registry.select( STORE_NAME )[ selector ]();

				expect( result ).toEqual( undefined );
			} );
		} );

		describe( 'isAmp', () => {
			it( 'uses a resolver to load site info, then returns true if AMP mode is set', async () => {
				global[ baseInfoVar ] = baseInfo;
				global[ entityInfoVar ] = entityInfo;

				registry.select( STORE_NAME ).isAmp();
				await subscribeUntil( registry,
					() => (
						registry.select( STORE_NAME ).isAmp() !== undefined
					),
				);

				const isAmp = registry.select( STORE_NAME ).isAmp();

				expect( isAmp ).toEqual( true );
			} );

			it( 'uses a resolver to load site info, then returns false if AMP mode is not set', async () => {
				global[ baseInfoVar ] = {
					...baseInfo,
					ampMode: null,
				};
				global[ entityInfoVar ] = entityInfo;

				registry.select( STORE_NAME ).isAmp();
				await subscribeUntil( registry,
					() => (
						registry.select( STORE_NAME ).isAmp() !== undefined
					),
				);

				const isAmp = registry.select( STORE_NAME ).isAmp();

				expect( isAmp ).toEqual( false );
			} );

			it( 'will return initial state (undefined) when no data is available', async () => {
				expect( global[ baseInfoVar ] ).toEqual( undefined );
				expect( global[ entityInfoVar ] ).toEqual( undefined );

				muteConsole( 'error' );
				const result = registry.select( STORE_NAME ).isAmp();

				expect( result ).toEqual( undefined );
			} );
		} );
	} );
} );

Please restore to the previous usage now that the exports are available.


@@ -32,19 +32,19 @@ const getConfigFromEnv = () => {
     providerOptions: {
       google: {
         key: process.env.COMPANION_GOOGLE_KEY,
-        secret: process.env.COMPANION_GOOGLE_SECRET
+        secret: typeof process.env.COMPANION_GOOGLE_SECRET_FILE === 'string' ? fs.readFileSync(process.env.COMPANION_GOOGLE_SECRET_FILE).toString() : process.env.COMPANION_GOOGLE_SECRET
       },
       dropbox: {
         key: process.env.COMPANION_DROPBOX_KEY,
-        secret: process.env.COMPANION_DROPBOX_SECRET
+        secret: typeof process.env.COMPANION_DROPBOX_SECRET_FILE === 'string' ? fs.readFileSync(process.env.COMPANION_DROPBOX_SECRET_FILE).toString() : process.env.COMPANION_DROPBOX_SECRET
       },
       instagram: {
         key: process.env.COMPANION_INSTAGRAM_KEY,
-        secret: process.env.COMPANION_INSTAGRAM_SECRET
+        secret: typeof process.env.COMPANION_INSTAGRAM_SECRET_FILE === 'string' ? fs.readFileSync(process.env.COMPANION_INSTAGRAM_SECRET_FILE).toString() : process.env.COMPANION_INSTAGRAM_SECRET
       },
       s3: {
         key: process.env.COMPANION_AWS_KEY,
-        secret: process.env.COMPANION_AWS_SECRET,
+        secret: typeof process.env.COMPANION_AWS_SECRET_FILE === 'string' ? fs.readFileSync(process.env.COMPANION_AWS_SECRET_FILE).toString() : process.env.COMPANION_AWS_SECRET,
         bucket: process.env.COMPANION_AWS_BUCKET,
         endpoint: process.env.COMPANION_AWS_ENDPOINT,
         region: process.env.COMPANION_AWS_REGION

const fs = require('fs')
const merge = require('lodash.merge')
const stripIndent = require('common-tags/lib/stripIndent')
const utils = require('../server/helpers/utils')
const logger = require('../server/logger')
const crypto = require('crypto')
// @ts-ignore
const { version } = require('../../package.json')

/**
 * Reads all companion configuration set via environment variables
 * and via the config file path
 *
 * @returns {object}
 */
exports.getUppyOptions = () => {
  return merge({}, getConfigFromEnv(), getConfigFromFile())
}

/**
 * Loads the config from environment variables
 *
 * @returns {object}
 */
const getConfigFromEnv = () => {
  const uploadUrls = process.env.COMPANION_UPLOAD_URLS
  const domains = process.env.COMPANION_DOMAINS || process.env.COMPANION_DOMAIN || null
  const validHosts = domains ? domains.split(',') : []

  return {
    // TODO: Rename providerOptions to providers.
    providerOptions: {
      google: {
        key: process.env.COMPANION_GOOGLE_KEY,
        secret: process.env.COMPANION_GOOGLE_SECRET
      },
      dropbox: {
        key: process.env.COMPANION_DROPBOX_KEY,
        secret: process.env.COMPANION_DROPBOX_SECRET
      },
      instagram: {
        key: process.env.COMPANION_INSTAGRAM_KEY,
        secret: process.env.COMPANION_INSTAGRAM_SECRET
      },
      s3: {
        key: process.env.COMPANION_AWS_KEY,
        secret: process.env.COMPANION_AWS_SECRET,
        bucket: process.env.COMPANION_AWS_BUCKET,
        endpoint: process.env.COMPANION_AWS_ENDPOINT,
        region: process.env.COMPANION_AWS_REGION
      }
    },
    server: {
      host: process.env.COMPANION_DOMAIN,
      protocol: process.env.COMPANION_PROTOCOL,
      path: process.env.COMPANION_PATH,
      implicitPath: process.env.COMPANION_IMPLICIT_PATH,
      oauthDomain: process.env.COMPANION_OAUTH_DOMAIN,
      validHosts: validHosts
    },
    filePath: process.env.COMPANION_DATADIR,
    redisUrl: process.env.COMPANION_REDIS_URL,
    sendSelfEndpoint: process.env.COMPANION_SELF_ENDPOINT,
    uploadUrls: uploadUrls ? uploadUrls.split(',') : null,
    secret: process.env.COMPANION_SECRET || generateSecret(),
    debug: process.env.NODE_ENV !== 'production',
    // TODO: this is a temporary hack to support distributed systems.
    // it is not documented, because it should be changed soon.
    cookieDomain: process.env.COMPANION_COOKIE_DOMAIN,
    multipleInstances: true
  }
}

/**
 * Auto-generates server secret
 *
 * @returns {string}
 */
const generateSecret = () => {
  logger.warn('auto-generating server secret because none was specified', 'startup.secret')
  return crypto.randomBytes(64).toString('hex')
}

/**
 * Loads the config from a file and returns it as an object
 *
 * @returns {object}
 */
const getConfigFromFile = () => {
  const path = getConfigPath()
  if (!path) return {}

  const rawdata = fs.readFileSync(getConfigPath())
  // TODO validate the json object fields to match the uppy config schema
  // @ts-ignore
  return JSON.parse(rawdata)
}

/**
 * Returns the config path specified via cli arguments
 *
 * @returns {string}
 */
const getConfigPath = () => {
  let configPath

  for (let i = process.argv.length - 1; i >= 0; i--) {
    const isConfigFlag = process.argv[i] === '-c' || process.argv[i] === '--config'
    const flagHasValue = i + 1 <= process.argv.length
    if (isConfigFlag && flagHasValue) {
      configPath = process.argv[i + 1]
      break
    }
  }

  return configPath
}

/**
 * validates that the mandatory companion options are set.
 * If it is invalid, it will console an error of unset options and exits the process.
 * If it is valid, nothing happens.
 *
 * @param {object} config
 */
exports.validateConfig = (config) => {
  const mandatoryOptions = ['secret', 'filePath', 'server.host']
  /** @type {string[]} */
  const unspecified = []

  mandatoryOptions.forEach((i) => {
    const value = i.split('.').reduce((prev, curr) => prev[curr], config)

    if (!value) unspecified.push(`"${i}"`)
  })

  // vaidate that all required config is specified
  if (unspecified.length) {
    console.error('\x1b[31m', 'Please specify the following options',
      'to run companion as Standalone:\n', unspecified.join(',\n'), '\x1b[0m')
    process.exit(1)
  }

  // validate that specified filePath is writeable/readable.
  // TODO: consider moving this into the uppy module itself.
  try {
    // @ts-ignore
    fs.accessSync(`${config.filePath}`, fs.R_OK | fs.W_OK)
  } catch (err) {
    console.error('\x1b[31m', `No access to "${config.filePath}".`,
      'Please ensure the directory exists and with read/write permissions.', '\x1b[0m')
    process.exit(1)
  }
}

/**
 *
 * @param {string} url
 */
exports.hasProtocol = (url) => {
  return url.startsWith('http://') || url.startsWith('https://')
}

exports.buildHelpfulStartupMessage = (uppyOptions) => {
  const buildURL = utils.getURLBuilder(uppyOptions)
  const callbackURLs = []
  Object.keys(uppyOptions.providerOptions).forEach((providerName) => {
    // s3 does not need redirect_uris
    if (providerName === 's3') {
      return
    }

    if (providerName === 'google') {
      providerName = 'drive'
    }

    callbackURLs.push(buildURL(`/connect/${providerName}/callback`, true))
  })

  return stripIndent`
    Welcome to Companion v${version}
    ===================================

    Congratulations on setting up Companion! Thanks for joining our cause, you have taken
    the first step towards the future of file uploading! We
    hope you are as excited about this as we are!

    While you did an awesome job on getting Companion running, this is just the welcome
    message, so let's talk about the places that really matter:

    - Be sure to add ${callbackURLs.join(', ')} as your Oauth redirect uris on their corresponding developer interfaces.
    - The URL ${buildURL('/metrics', true)} is available for  statistics to keep Companion running smoothly
    - https://github.com/transloadit/uppy/issues - report your bugs here

    So quit lollygagging, start uploading and experience the future!
  `
}

can we pull this into a function instead? And reuse it for others too?


@@ -37,7 +37,12 @@ class WriteConcern {
   /**
    * Construct a WriteConcern given an options object.
    *
-   * @param {object} options The options object from which to extract the write concern.
+   * @param {object} [options] The options object from which to extract the write concern.
+   * @param {(number|string)} [options.w] **Deprecated** Use `options.writeConcern` instead
+   * @param {number} [options.wtimeout] **Deprecated** Use `options.writeConcern` instead
+   * @param {boolean} [options.j] **Deprecated** Use `options.writeConcern` instead
+   * @param {boolean} [options.fsync] **Deprecated** Use `options.writeConcern` instead
+   * @param {object|WriteConcern} [options.writeConcern] Specify write concern settings.
    * @return {WriteConcern}
    */
   static fromOptions(options) {

'use strict';

const kWriteConcernKeys = new Set(['w', 'wtimeout', 'j', 'fsync']);

/**
 * The **WriteConcern** class is a class that represents a MongoDB WriteConcern.
 * @class
 * @property {(number|string)} w The write concern
 * @property {number} wtimeout The write concern timeout
 * @property {boolean} j The journal write concern
 * @property {boolean} fsync The file sync write concern
 * @see https://docs.mongodb.com/manual/reference/write-concern/index.html
 */
class WriteConcern {
  /**
   * Constructs a WriteConcern from the write concern properties.
   * @param {(number|string)} [w] The write concern
   * @param {number} [wtimeout] The write concern timeout
   * @param {boolean} [j] The journal write concern
   * @param {boolean} [fsync] The file sync write concern
   */
  constructor(w, wtimeout, j, fsync) {
    if (w != null) {
      this.w = w;
    }
    if (wtimeout != null) {
      this.wtimeout = wtimeout;
    }
    if (j != null) {
      this.j = j;
    }
    if (fsync != null) {
      this.fsync = fsync;
    }
  }

  /**
   * Construct a WriteConcern given an options object.
   *
   * @param {object} options The options object from which to extract the write concern.
   * @return {WriteConcern}
   */
  static fromOptions(options) {
    if (
      options == null ||
      (options.writeConcern == null &&
        options.w == null &&
        options.wtimeout == null &&
        options.j == null &&
        options.fsync == null)
    ) {
      return;
    }

    if (options.writeConcern) {
      if (typeof options.writeConcern === 'string') {
        return new WriteConcern(options.writeConcern);
      }

      if (!Object.keys(options.writeConcern).some(key => kWriteConcernKeys.has(key))) {
        return;
      }

      return new WriteConcern(
        options.writeConcern.w,
        options.writeConcern.wtimeout,
        options.writeConcern.j,
        options.writeConcern.fsync
      );
    }

    return new WriteConcern(options.w, options.wtimeout, options.j, options.fsync);
  }
}

module.exports = WriteConcern;

Let's be consistent with the Deprecated tag in the docs, I think the use of bold is good so can we change the `DEPRECATED:` tags to match?


@@ -13,7 +13,7 @@ describe('has-visible-text', function() {
   });

   it('should return false if there is no visible text', function() {
-    var params = checkSetup('<object id="target"></object>');
+    var params = checkSetup('

');
     assert.isFalse(
       axe.testUtils
         .getCheckEvaluate('has-visible-text')

describe('has-visible-text', function() {
  'use strict';

  var fixture = document.getElementById('fixture');
  var checkSetup = axe.testUtils.checkSetup;

  var checkContext = axe.testUtils.MockCheckContext();

  afterEach(function() {
    fixture.innerHTML = '';
    axe._tree = undefined;
    checkContext.reset();
  });

  it('should return false if there is no visible text', function() {
    var params = checkSetup('<object id="target"></object>');
    assert.isFalse(
      axe.testUtils
        .getCheckEvaluate('has-visible-text')
        .apply(checkContext, params)
    );
  });

  it('should return false if there is text, but its hidden', function() {
    var params = checkSetup(
      '<object id="target">hello!</object>'
    );
    assert.isFalse(
      axe.testUtils
        .getCheckEvaluate('has-visible-text')
        .apply(checkContext, params)
    );
  });

  it('should return true if there is visible text', function() {
    var params = checkSetup('<object id="target">hello!</object>');
    assert.isTrue(
      axe.testUtils
        .getCheckEvaluate('has-visible-text')
        .apply(checkContext, params)
    );
  });

  describe('SerialVirtualNode', function() {
    it('should return false if element is not named from contents', function() {
      var node = new axe.SerialVirtualNode({
        nodeName: 'article'
      });

      assert.isFalse(
        axe.testUtils.getCheckEvaluate('has-visible-text')(null, {}, node)
      );
    });

    it('should return incomplete if no other properties are set', function() {
      var node = new axe.SerialVirtualNode({
        nodeName: 'button'
      });

      assert.isUndefined(
        axe.testUtils.getCheckEvaluate('has-visible-text')(null, {}, node)
      );
    });

    it('should return false if there is no visible text', function() {
      var node = new axe.SerialVirtualNode({
        nodeName: 'button'
      });
      node.children = [];

      assert.isFalse(
        axe.testUtils.getCheckEvaluate('has-visible-text')(null, {}, node)
      );
    });

    it('should return true if there is visible text', function() {
      var node = new axe.SerialVirtualNode({
        nodeName: 'object'
      });
      var child = new axe.SerialVirtualNode({
        nodeName: '#text',
        nodeType: 3,
        nodeValue: 'hello!'
      });
      node.children = [child];

      assert.isTrue(
        axe.testUtils.getCheckEvaluate('has-visible-text')(null, {}, node)
      );
    });
  });
});

Don't know why we did that. Clearly that's wrong.


@@ -15,7 +15,9 @@ const browsers = {
   chrome: tester((ua, vendor) => /Chrome/.test(ua) && /Google/.test(vendor)),
   edge: tester(ua => /Edge/.test(ua)),
   ie: tester(ua => /Trident/.test(ua)),
+  // eslint-disable-next-line
   ie8: tester(() => !(document.createTextNode('test').textContent)),
+  // eslint-disable-next-line
   ie9: tester(() => !!(document.documentMode)),
   mobile: tester(ua => /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua)),
   safari: tester((ua, vendor) => /Safari/.test(ua) && /Apple Computer/.test(vendor)),

import { objectEach } from './object';

const tester = (testerFunc) => {
  const result = {
    value: false,
  };
  result.test = (ua, vendor) => {
    result.value = testerFunc(ua, vendor);
  };

  return result;
};

const browsers = {
  chrome: tester((ua, vendor) => /Chrome/.test(ua) && /Google/.test(vendor)),
  edge: tester(ua => /Edge/.test(ua)),
  ie: tester(ua => /Trident/.test(ua)),
  ie8: tester(() => !(document.createTextNode('test').textContent)),
  ie9: tester(() => !!(document.documentMode)),
  mobile: tester(ua => /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua)),
  safari: tester((ua, vendor) => /Safari/.test(ua) && /Apple Computer/.test(vendor)),
};

export function setBrowserMeta({ userAgent = navigator.userAgent, vendor = navigator.vendor } = {}) {
  objectEach(browsers, ({ test }) => void test(userAgent, vendor));
}

setBrowserMeta();

export function isChrome() {
  return browsers.chrome.value;
}

export function isEdge() {
  return browsers.edge.value;
}

export function isIE() {
  return browsers.ie.value;
}

export function isIE8() {
  return browsers.ie8.value;
}

export function isIE9() {
  return browsers.ie9.value;
}

export function isMSBrowser() {
  return browsers.ie.value || browsers.edge.value;
}

export function isMobileBrowser() {
  return browsers.mobile.value;
}

export function isSafari() {
  return browsers.safari.value;
}

Maybe change from `eslint-disable-next-line` to `eslint-disable-next-line no-restricted-globals` here and in next occurs?


@@ -95,7 +95,7 @@ define(["loading", "dialogHelper", "dom", "jQuery", "components/libraryoptionsed

     function getFolderHtml(pathInfo, index) {
         var html = "";
-        return html += '
', html += '
', html += '
' + pathInfo.Path + "
", pathInfo.NetworkPath && (html += '
' + pathInfo.NetworkPath + "
"), html += "
", html += '<button type="button" is="paper-icon-button-light"" class="listItemButton btnRemovePath" data-index="' + index + '">remove_circle</button>', html += "
"
+        return html += '
', html += '
', html += '
' + pathInfo.Path + "
", pathInfo.NetworkPath && (html += '
' + pathInfo.NetworkPath + "
"), html += "
", html += '<button type="button" is="paper-icon-button-light"" class="listItemButton btnRemovePath" data-index="' + index + '">remove_circle</button>', html += "
"
     }

     function renderPaths(page) {

define(["loading", "dialogHelper", "dom", "jQuery", "components/libraryoptionseditor/libraryoptionseditor", "emby-toggle", "emby-input", "emby-select", "paper-icon-button-light", "listViewStyle", "formDialogStyle", "emby-button", "flexStyles"], function(loading, dialogHelper, dom, $, libraryoptionseditor) {
    "use strict";

    function onAddLibrary() {
        if (isCreating) return false;

        if (pathInfos.length == 0) {
            require(["alert"], function(alert) {
                alert({
                    text: Globalize.translate("PleaseAddAtLeastOneFolder"),
                    type: "error"
                })
            });
            return false;
        }

        isCreating = true;
        loading.show();

        var dlg = dom.parentWithClass(this, "dlg-librarycreator");
        var name = $("#txtValue", dlg).val();
        var type = $("#selectCollectionType", dlg).val();
        if (type == "mixed") type = null;
        var libraryOptions = libraryoptionseditor.getLibraryOptions(dlg.querySelector(".libraryOptions"));
        libraryOptions.PathInfos = pathInfos;
        ApiClient.addVirtualFolder(name, type, currentOptions.refresh, libraryOptions).then(function() {
            hasChanges = true;
            isCreating = false;
            loading.hide();
            dialogHelper.close(dlg);
        }, function() {
            require(["toast"], function(toast) {
                toast(Globalize.translate("ErrorAddingMediaPathToVirtualFolder"));
            });
            isCreating = false;
            loading.hide();
        });
        return false;
    }

    function getCollectionTypeOptionsHtml(collectionTypeOptions) {
        return collectionTypeOptions.map(function(i) {
            return '<option value="' + i.value + '">' + i.name + "</option>";
        }).join("");
    }

    function initEditor(page, collectionTypeOptions) {
        $("#selectCollectionType", page).html(getCollectionTypeOptionsHtml(collectionTypeOptions)).val("").on("change", function() {
            var value = this.value;
            var dlg = $(this).parents(".dialog")[0];
            libraryoptionseditor.setContentType(dlg.querySelector(".libraryOptions"), value == "mixed" ? "" : value);
            if (value) {
                dlg.querySelector(".libraryOptions").classList.remove("hide");
            } else {
                dlg.querySelector(".libraryOptions").classList.add("hide");
            }

            if (value != "mixed") {
                var index = this.selectedIndex;
                if (index != -1) {
                    var name = this.options[index].innerHTML.replace("*", "").replace("&", "&");
                    $("#txtValue", dlg).val(name);
                    var folderOption = collectionTypeOptions.filter(function(i) {
                        return i.value == value
                    })[0];
                    $(".collectionTypeFieldDescription", dlg).html(folderOption.message || "")
                }
            }
        });

        page.querySelector(".btnAddFolder").addEventListener("click", onAddButtonClick);
        page.querySelector(".btnSubmit").addEventListener("click", onAddLibrary);
        page.querySelector(".folderList").addEventListener("click", onRemoveClick);
        page.querySelector(".chkAdvanced").addEventListener("change", onToggleAdvancedChange);
    }

    function onToggleAdvancedChange() {
        var dlg = dom.parentWithClass(this, "dlg-librarycreator");
        libraryoptionseditor.setAdvancedVisible(dlg.querySelector(".libraryOptions"), this.checked);
    }

    function onAddButtonClick() {
        var page = dom.parentWithClass(this, "dlg-librarycreator");
        require(["directorybrowser"], function(directoryBrowser) {
            var picker = new directoryBrowser;
            picker.show({
                enableNetworkSharePath: true,
                callback: function(path, networkSharePath) {
                    path && addMediaLocation(page, path, networkSharePath);
                    picker.close();
                }
            })
        })
    }

    function getFolderHtml(pathInfo, index) {
        var html = "";
        return html += '
', html += '
', html += '
' + pathInfo.Path + "
", pathInfo.NetworkPath && (html += '
' + pathInfo.NetworkPath + "
"), html += "
", html += '<button type="button" is="paper-icon-button-light"" class="listItemButton btnRemovePath" data-index="' + index + '">remove_circle</button>', html += "
"
    }

    function renderPaths(page) {
        var foldersHtml = pathInfos.map(getFolderHtml).join("");
        var folderList = page.querySelector(".folderList");
        folderList.innerHTML = foldersHtml;
        foldersHtml ? folderList.classList.remove("hide") : folderList.classList.add("hide");
    }

    function addMediaLocation(page, path, networkSharePath) {
        var pathLower = path.toLowerCase();
        var pathFilter = pathInfos.filter(function(p) {
            return p.Path.toLowerCase() == pathLower;
        });
        if (!pathFilter.length) {
            var pathInfo = {
                Path: path
            };
            networkSharePath && (pathInfo.NetworkPath = networkSharePath);
            pathInfos.push(pathInfo);
            renderPaths(page);
        }
    }

    function onRemoveClick(e) {
        var button = dom.parentWithClass(e.target, "btnRemovePath");
        var index = parseInt(button.getAttribute("data-index"));
        var location = pathInfos[index].Path;
        var locationLower = location.toLowerCase();
        pathInfos = pathInfos.filter(function(p) {
            return p.Path.toLowerCase() != locationLower;
        });
        renderPaths(dom.parentWithClass(button, "dlg-librarycreator"));
    }

    function onDialogClosed() {
        currentResolve(hasChanges);
    }

    function initLibraryOptions(dlg) {
        libraryoptionseditor.embed(dlg.querySelector(".libraryOptions")).then(function() {
            $("#selectCollectionType", dlg).trigger("change");
            onToggleAdvancedChange.call(dlg.querySelector(".chkAdvanced"));
        })
    }

    function editor() {
        this.show = function(options) {
            return new Promise(function(resolve, reject) {
                currentOptions = options;
                currentResolve = resolve;
                hasChanges = false;
                var xhr = new XMLHttpRequest;
                xhr.open("GET", "components/medialibrarycreator/medialibrarycreator.template.html", true);
                xhr.onload = function(e) {
                    var template = this.response;
                    var dlg = dialogHelper.createDialog({
                        size: "medium-tall",
                        modal: false,
                        removeOnClose: true,
                        scrollY: false
                    });
                    dlg.classList.add("ui-body-a");
                    dlg.classList.add("background-theme-a");
                    dlg.classList.add("dlg-librarycreator");
                    dlg.classList.add("formDialog");
                    dlg.innerHTML = Globalize.translateDocument(template);
                    initEditor(dlg, options.collectionTypeOptions);
                    dlg.addEventListener("close", onDialogClosed);
                    dialogHelper.open(dlg);
                    dlg.querySelector(".btnCancel").addEventListener("click", function() {
                        dialogHelper.close(dlg)
                    });
                    pathInfos = [];
                    renderPaths(dlg);
                    initLibraryOptions(dlg);
                };
                xhr.send();
            });
        }
    }

    var pathInfos = [];
    var currentResolve;
    var currentOptions;

    var hasChanges = false;
    var isCreating = false;

    return editor
});


@@ -7,11 +7,18 @@ const sinon = require('sinon');
 class MockTopology extends EventEmitter {
   constructor() {
     super();
+    this.s = {
+      promiseLibrary: Promise
+    };
   }

   capabilities() {
     return {};
   }
+
+  hasSessionSupport() {
+    return false;
+  }
 }

 const test = {};

'use strict';

const EventEmitter = require('events');
const expect = require('chai').expect;
const sinon = require('sinon');

class MockTopology extends EventEmitter {
  constructor() {
    super();
  }

  capabilities() {
    return {};
  }
}

const test = {};
describe('Database', function() {
  before(() => {
    // NOTE: These modules are being used prior to test run. In order to monkey-patch them
    //       we must remove their cached versions.
    const resolvedUtils = require.resolve('../../lib/utils');
    const resolvedDb = require.resolve('../../lib/db');
    delete require.cache[resolvedUtils];
    delete require.cache[resolvedDb];
    test.utils = require('../../lib/utils');

    // create a sandbox for stub cleanup
    test.sandbox = sinon.sandbox.create();
  });

  afterEach(() => test.sandbox.restore());

  it('should ignore a readPreference for dropDatabase', {
    metadata: { requires: { topology: 'single' } },
    test: function() {
      sinon.stub(test.utils, 'executeOperation').callsFake((topology, operation, args) => {
        const options = args[args.length - 2];
        expect(options.readPreference).to.equal('primary');
      });

      const Db = require('../../lib/db');
      const db = new Db('fakeDb', new MockTopology(), { readPreference: 'nearest' });
      db.dropDatabase();
    }
  });
});

I'm not sure if this is the best way to ensure the `MockTopology` has a `promiseLibrary`, but I figured this was in the right direction.


@@ -61,7 +61,20 @@ export default Controller.extend({
             this.integration.rollbackAttributes();

             return transition.retry();
+        },
+
+        deleteIntegration() {
+            this.integration.destroyRecord();
+        },
+
+        confirmIntegrationDeletion() {
+            this.set('showDeleteIntegrationModal', true);
+        },
+
+        cancelIntegrationDeletion() {
+            this.set('showDeleteIntegrationModal', false);
         }
+
     },

     save: task(function* () {

import Controller from '@ember/controller';
import {alias} from '@ember/object/computed';
import {computed} from '@ember/object';
import {task, timeout} from 'ember-concurrency';

export default Controller.extend({
    integration: alias('model'),

    allWebhooks: computed(function () {
        return this.store.peekAll('webhook');
    }),

    filteredWebhooks: computed('allWebhooks.@each.{isNew,isDeleted}', function () {
        return this.allWebhooks.filter((webhook) => {
            let matchesIntegration = webhook.belongsTo('integration').id() === this.integration.id;

            return matchesIntegration
                && !webhook.isNew
                && !webhook.isDeleted;
        });
    }),

    actions: {
        save() {
            return this.save.perform();
        },

        toggleUnsavedChangesModal(transition) {
            let leaveTransition = this.leaveScreenTransition;

            if (!transition && this.showUnsavedChangesModal) {
                this.set('leaveScreenTransition', null);
                this.set('showUnsavedChangesModal', false);
                return;
            }

            if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
                this.set('leaveScreenTransition', transition);

                // if a save is running, wait for it to finish then transition
                if (this.save.isRunning) {
                    return this.save.last.then(() => {
                        transition.retry();
                    });
                }

                // we genuinely have unsaved data, show the modal
                this.set('showUnsavedChangesModal', true);
            }
        },

        leaveScreen() {
            let transition = this.leaveScreenTransition;

            if (!transition) {
                this.notifications.showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
                return;
            }

            // roll back changes on model props
            this.integration.rollbackAttributes();

            return transition.retry();
        }
    },

    save: task(function* () {
        return yield this.integration.save();
    }),

    copyContentKey: task(function* () {
        this._copyInputTextToClipboard('input#content_key');
        yield timeout(3000);
    }),

    copyAdminKey: task(function* () {
        this._copyInputTextToClipboard('input#admin_key');
        yield timeout(3000);
    }),

    _copyInputTextToClipboard(selector) {
        let input = document.querySelector(selector);
        input.disabled = false;
        input.focus();
        input.select();
        document.execCommand('copy');
        input.disabled = true;
    }
});

Similar pattern to webhooks, feels unnecessary as there's only ever one integration on a page. But it's useful to show the modal, maybe have a `showIntegrationDeleteModal` instead and set to true/false...?


@@ -56,7 +56,7 @@ class TopProductFacade
     }

     /**
-     * @param $domainId
+     * @param int $domainId
      * @param \Shopsys\FrameworkBundle\Model\Product\Product[] $products
      */
     public function saveTopProductsForDomain($domainId, array $products)

<?php

namespace Shopsys\FrameworkBundle\Model\Product\TopProduct;

use Doctrine\ORM\EntityManagerInterface;

class TopProductFacade
{
    /**
     * @var \Doctrine\ORM\EntityManagerInterface
     */
    protected $em;

    /**
     * @var \Shopsys\FrameworkBundle\Model\Product\TopProduct\TopProductRepository
     */
    protected $topProductRepository;

    /**
     * @var \Shopsys\FrameworkBundle\Model\Product\TopProduct\TopProductFactoryInterface
     */
    protected $topProductFactory;

    /**
     * @param \Doctrine\ORM\EntityManagerInterface $em
     * @param \Shopsys\FrameworkBundle\Model\Product\TopProduct\TopProductRepository $topProductRepository
     * @param \Shopsys\FrameworkBundle\Model\Product\TopProduct\TopProductFactoryInterface $topProductFactory
     */
    public function __construct(
        EntityManagerInterface $em,
        TopProductRepository $topProductRepository,
        TopProductFactoryInterface $topProductFactory
    ) {
        $this->em = $em;
        $this->topProductRepository = $topProductRepository;
        $this->topProductFactory = $topProductFactory;
    }

    /**
     * @param int $domainId
     * @return \Shopsys\FrameworkBundle\Model\Product\TopProduct\TopProduct[]
     */
    public function getAll($domainId)
    {
        return $this->topProductRepository->getAll($domainId);
    }

    /**
     * @param int $domainId
     * @param \Shopsys\FrameworkBundle\Model\Pricing\Group\PricingGroup $pricingGroup
     * @return \Shopsys\FrameworkBundle\Model\Product\Product[]
     */
    public function getAllOfferedProducts($domainId, $pricingGroup)
    {
        return $this->topProductRepository->getOfferedProductsForTopProductsOnDomain($domainId, $pricingGroup);
    }

    /**
     * @param $domainId
     * @param \Shopsys\FrameworkBundle\Model\Product\Product[] $products
     */
    public function saveTopProductsForDomain($domainId, array $products)
    {
        $oldTopProducts = $this->topProductRepository->getAll($domainId);
        foreach ($oldTopProducts as $oldTopProduct) {
            $this->em->remove($oldTopProduct);
        }
        $this->em->flush($oldTopProducts);

        $topProducts = [];
        $position = 1;
        foreach ($products as $product) {
            $topProduct = $this->topProductFactory->create($product, $domainId, $position++);
            $this->em->persist($topProduct);
            $topProducts[] = $topProduct;
        }
        $this->em->flush($topProducts);
    }
}

hmm, strange a few line below $this->em->flush with parameter seems like no problem. Why is that so ? Because it is array?


@@ -77,10 +77,12 @@ class MediaAdminController extends Controller
         $datagrid->setValue('context', null, $context);

         // retrieve the main category for the tree view
-        $category = $this->container->get('sonata.classification.manager.category')->getRootCategory($context);
+        $rootCategory = $this->container->get('sonata.classification.manager.category')->getRootCategory($context);
+        // This should be safe as the root category has to exist for a given context but I do not like fatal errors
+        $rootCategoryId = !empty($rootCategory) ? $rootCategory->getId() : null;

         if (!$filters) {
-            $datagrid->setValue('category', null, $category);
+            $datagrid->setValue('category', null, $rootCategoryId);
         }
         if ($request->get('category')) {
             $categoryByContext = $this->container->get('sonata.classification.manager.category')->findOneBy(array(

<?php

/*
 * This file is part of the Sonata Project package.
 *
 * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Sonata\MediaBundle\Controller;

use Sonata\AdminBundle\Controller\CRUDController as Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;

class MediaAdminController extends Controller
{
    /**
     * {@inheritdoc}
     */
    public function createAction(Request $request = null)
    {
        if (false === $this->admin->isGranted('CREATE')) {
            throw new AccessDeniedException();
        }

        if (!$request->get('provider') && $request->isMethod('get')) {
            return $this->render('SonataMediaBundle:MediaAdmin:select_provider.html.twig', array(
                'providers' => $this->get('sonata.media.pool')->getProvidersByContext($request->get('context', $this->get('sonata.media.pool')->getDefaultContext())),
                'base_template' => $this->getBaseTemplate(),
                'admin' => $this->admin,
                'action' => 'create',
            ));
        }

        return parent::createAction();
    }

    /**
     * {@inheritdoc}
     */
    public function render($view, array $parameters = array(), Response $response = null, Request $request = null)
    {
        $parameters['media_pool'] = $this->container->get('sonata.media.pool');
        $parameters['persistent_parameters'] = $this->admin->getPersistentParameters();

        return parent::render($view, $parameters, $response, $request);
    }

    /**
     * {@inheritdoc}
     */
    public function listAction(Request $request = null)
    {
        if (false === $this->admin->isGranted('LIST')) {
            throw new AccessDeniedException();
        }

        if ($listMode = $request->get('_list_mode', 'mosaic')) {
            $this->admin->setListMode($listMode);
        }

        $datagrid = $this->admin->getDatagrid();

        $filters = $request->get('filter');

        // set the default context
        if (!$filters || !array_key_exists('context', $filters)) {
            $context = $this->admin->getPersistentParameter('context', $this->get('sonata.media.pool')->getDefaultContext());
        } else {
            $context = $filters['context']['value'];
        }

        $datagrid->setValue('context', null, $context);

        // retrieve the main category for the tree view
        $category = $this->container->get('sonata.classification.manager.category')->getRootCategory($context);

        if (!$filters) {
            $datagrid->setValue('category', null, $category);
        }
        if ($request->get('category')) {
            $categoryByContext = $this->container->get('sonata.classification.manager.category')->findOneBy(array(
                'id' => (int) $request->get('category'),
                'context' => $context,
            ));

            if (!empty($categoryByContext)) {
                $datagrid->setValue('category', null, $categoryByContext);
            } else {
                $datagrid->setValue('category', null, $category);
            }
        }

        $formView = $datagrid->getForm()->createView();

        // set the theme for the current Admin Form
        $this->get('twig')->getExtension('form')->renderer->setTheme($formView, $this->admin->getFilterTheme());

        return $this->render($this->admin->getTemplate('list'), array(
            'action' => 'list',
            'form' => $formView,
            'datagrid' => $datagrid,
            'root_category' => $category,
            'csrf_token' => $this->getCsrfToken('sonata.batch'),
        ));
    }
}

Why not throw an exception instead then?


@@ -76,7 +76,7 @@ class CustomerUserRepository
     {
         $customerUser = $this->findById($id);
         if ($customerUser === null) {
-            throw new \Shopsys\FrameworkBundle\Model\Customer\Exception\CustomerUserNotFoundException($id);
+            throw new \Shopsys\FrameworkBundle\Model\Customer\Exception\CustomerUserNotFoundException((string)$id);
         }
         return $customerUser;
     }

<?php

namespace Shopsys\FrameworkBundle\Model\Customer\User;

use Doctrine\ORM\EntityManagerInterface;
use Shopsys\FrameworkBundle\Component\String\DatabaseSearching;
use Shopsys\FrameworkBundle\Form\Admin\QuickSearch\QuickSearchFormData;
use Shopsys\FrameworkBundle\Model\Customer\BillingAddress;
use Shopsys\FrameworkBundle\Model\Customer\Exception\CustomerUserNotFoundException;
use Shopsys\FrameworkBundle\Model\Order\Order;
use Shopsys\FrameworkBundle\Model\Pricing\Group\PricingGroup;

class CustomerUserRepository
{
    /**
     * @var \Doctrine\ORM\EntityManagerInterface
     */
    protected $em;

    /**
     * @param \Doctrine\ORM\EntityManagerInterface $entityManager
     */
    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->em = $entityManager;
    }

    /**
     * @return \Doctrine\ORM\EntityRepository
     */
    protected function getCustomerUserRepository()
    {
        return $this->em->getRepository(CustomerUser::class);
    }

    /**
     * @param string $email
     * @param int $domainId
     *
     * @return \Shopsys\FrameworkBundle\Model\Customer\User\CustomerUser|null
     */
    public function findCustomerUserByEmailAndDomain($email, $domainId)
    {
        return $this->getCustomerUserRepository()->findOneBy([
            'email' => mb_strtolower($email),
            'domainId' => $domainId,
        ]);
    }

    /**
     * @param string $email
     * @param int $domainId
     *
     * @return \Shopsys\FrameworkBundle\Model\Customer\User\CustomerUser|null
     */
    public function getCustomerUserByEmailAndDomain($email, $domainId)
    {
        $customerUser = $this->findCustomerUserByEmailAndDomain($email, $domainId);

        if ($customerUser === null) {
            throw new \Shopsys\FrameworkBundle\Model\Customer\Exception\CustomerUserNotFoundByEmailAndDomainException(
                $email,
                $domainId
            );
        }

        return $customerUser;
    }

    /**
     * @param int $id
     *
     * @return \Shopsys\FrameworkBundle\Model\Customer\User\CustomerUser
     */
    public function getCustomerUserById($id)
    {
        $customerUser = $this->findById($id);
        if ($customerUser === null) {
            throw new \Shopsys\FrameworkBundle\Model\Customer\Exception\CustomerUserNotFoundException($id);
        }
        return $customerUser;
    }

    /**
     * @param int $id
     *
     * @return \Shopsys\FrameworkBundle\Model\Customer\User\CustomerUser|null
     */
    public function findById($id)
    {
        return $this->getCustomerUserRepository()->find($id);
    }

    /**
     * @param int $id
     * @param string $loginToken
     *
     * @return \Shopsys\FrameworkBundle\Model\Customer\User\CustomerUser|null
     */
    public function findByIdAndLoginToken($id, $loginToken)
    {
        return $this->getCustomerUserRepository()->findOneBy([
            'id' => $id,
            'loginToken' => $loginToken,
        ]);
    }

    /**
     * @param int $domainId
     * @param \Shopsys\FrameworkBundle\Form\Admin\QuickSearch\QuickSearchFormData $quickSearchData
     * @return \Doctrine\ORM\QueryBuilder
     */
    public function getCustomerUserListQueryBuilderByQuickSearchData(
        $domainId,
        QuickSearchFormData $quickSearchData
    ) {
        $queryBuilder = $this->em->createQueryBuilder()
            ->select('
                u.id,
                u.email,
                u.telephone,
                MAX(pg.name) AS pricingGroup,
                MAX(ba.city) city,
                MAX(CASE WHEN ba.companyCustomer = true
                        THEN ba.companyName
                        ELSE CONCAT(u.lastName, \' \', u.firstName)
                    END) AS name,
                COUNT(o.id) ordersCount,
                SUM(o.totalPriceWithVat) ordersSumPrice,
                MAX(o.createdAt) lastOrderAt')
            ->from(CustomerUser::class, 'u')
            ->where('u.domainId = :selectedDomainId')
            ->setParameter('selectedDomainId', $domainId)
            ->join('u.customer', 'c')
            ->leftJoin(BillingAddress::class, 'ba', 'WITH', 'c.id = ba.customer')
            ->leftJoin(Order::class, 'o', 'WITH', 'o.customerUser = u.id AND o.deleted = :deleted')
            ->setParameter('deleted', false)
            ->leftJoin(PricingGroup::class, 'pg', 'WITH', 'pg.id = u.pricingGroup')
            ->groupBy('u.id');

        if ($quickSearchData->text !== null && $quickSearchData->text !== '') {
            $queryBuilder
                ->andWhere('
                    (
                        NORMALIZE(u.lastName) LIKE NORMALIZE(:text)
                        OR
                        NORMALIZE(u.email) LIKE NORMALIZE(:text)
                        OR
                        NORMALIZE(ba.companyName) LIKE NORMALIZE(:text)
                        OR
                        NORMALIZE(u.telephone) LIKE :text
                    )');
            $querySearchText = DatabaseSearching::getFullTextLikeSearchString($quickSearchData->text);
            $queryBuilder->setParameter('text', $querySearchText);
        }

        return $queryBuilder;
    }

    /**
     * @param \Shopsys\FrameworkBundle\Model\Pricing\Group\PricingGroup $oldPricingGroup
     * @param \Shopsys\FrameworkBundle\Model\Pricing\Group\PricingGroup $newPricingGroup
     */
    public function replaceCustomerUsersPricingGroup(PricingGroup $oldPricingGroup, PricingGroup $newPricingGroup)
    {
        $this->em->createQueryBuilder()
            ->update(CustomerUser::class, 'u')
            ->set('u.pricingGroup', ':newPricingGroup')->setParameter('newPricingGroup', $newPricingGroup)
            ->where('u.pricingGroup = :oldPricingGroup')->setParameter('oldPricingGroup', $oldPricingGroup)
            ->getQuery()->execute();
    }

    /**
     * @param string $uuid
     * @return \Shopsys\FrameworkBundle\Model\Customer\User\CustomerUser
     */
    public function getOneByUuid(string $uuid): CustomerUser
    {
        /** @var \Shopsys\FrameworkBundle\Model\Customer\User\CustomerUser|null $customerUser */
        $customerUser = $this->getCustomerUserRepository()->findOneBy(['uuid' => $uuid]);

        if ($customerUser === null) {
            throw new CustomerUserNotFoundException('Customer with UUID ' . $uuid . ' does not exist.');
        }

        return $customerUser;
    }
}

Well you should probably send nothing here right?


@@ -38,6 +38,28 @@ namespace VuFind\Hierarchy\TreeDataSource;
  */
 class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
 {
+    /**
+     * Default plugin aliases.
+     *
+     * @var array
+     */
+    protected $aliases = [
+        'solr' => 'VuFind\Hierarchy\TreeDataSource\Solr',
+        'xmlfile' => 'VuFind\Hierarchy\TreeDataSource\XMLFile',
+    ];
+
+    /**
+     * Default plugin factories.
+     *
+     * @var array
+     */
+    protected $factories = [
+        'VuFind\Hierarchy\TreeDataSource\Solr' =>
+            'VuFind\Hierarchy\TreeDataSource\Factory::getSolr',
+        'VuFind\Hierarchy\TreeDataSource\XMLFile' =>
+            'Zend\ServiceManager\Factory\InvokableFactory',
+    ];
+
     /**
      * Return the name of the base class or interface that plug-ins must conform
      * to.

<?php
/**
 * Hierarchy tree data source plugin manager
 *
 * PHP version 5
 *
 * Copyright (C) Villanova University 2010.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 2,
 * as published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
 *
 * @category VuFind
 * @package  HierarchyTree_DataSource
 * @author   Demian Katz <demian.katz@villanova.edu>
 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
 * @link     https://vufind.org/wiki/development:plugins:hierarchy_components Wiki
 */
namespace VuFind\Hierarchy\TreeDataSource;

/**
 * Hierarchy tree data source plugin manager
 *
 * @category VuFind
 * @package  HierarchyTree_DataSource
 * @author   Demian Katz <demian.katz@villanova.edu>
 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
 * @link     https://vufind.org/wiki/development:plugins:hierarchy_components Wiki
 */
class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
{
    /**
     * Return the name of the base class or interface that plug-ins must conform
     * to.
     *
     * @return string
     */
    protected function getExpectedInterface()
    {
        return 'VuFind\Hierarchy\TreeDataSource\AbstractBase';
    }
}

Might be worth replacing this static factory with a non-static one (i.e. SolrFactory, implementing FactoryInterface).


@@ -94,8 +94,8 @@ abstract class AbstractSolrTask extends AbstractTask {
     public function __sleep()
     {
         $properties = get_object_vars($this);
-        // avoid serialization if the site object
-        unset($properties['site']);
+        // avoid serialization if the site and logger object
+        unset($properties['site'], $properties['logger']);
         return array_keys($properties);
     }
-}
+}

<?php
namespace ApacheSolrForTypo3\Solr\Task;

/***************************************************************
 *  Copyright notice
 *
 *  (c) 2017 Timo Hund <timo.hund@dkd.de>
 *  All rights reserved
 *
 *  This script is part of the TYPO3 project. The TYPO3 project is
 *  free software; you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation; either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  The GNU General Public License can be found at
 *  http://www.gnu.org/copyleft/gpl.html.
 *
 *  This script is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  This copyright notice MUST APPEAR in all copies of the script!
 ***************************************************************/

use ApacheSolrForTypo3\Solr\Domain\Site\SiteRepository;
use ApacheSolrForTypo3\Solr\Domain\Site\Site;
use ApacheSolrForTypo3\Solr\System\Logging\SolrLogManager;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Scheduler\Task\AbstractTask;

/**
 * Abstract scheduler task for solr scheduler tasks, contains the logic to
 * retrieve the site, avoids serialization of site, when scheduler task is saved.
 *
 * @package ApacheSolrForTypo3\Solr\Task
 */
abstract class AbstractSolrTask extends AbstractTask {
    /**
     * The site this task is supposed to initialize the index queue for.
     *
     * @var Site
     */
    protected $site;

    /**
     * The rootPageId of the site that should be reIndexed
     *
     * @var integer
     */
    protected $rootPageId;

    /**
     * @return int
     */
    public function getRootPageId()
    {
        return $this->rootPageId;
    }

    /**
     * @param int $rootPageId
     */
    public function setRootPageId($rootPageId)
    {
        $this->rootPageId = $rootPageId;
    }

    /**
     * @return Site
     */
    public function getSite()
    {
        if (!is_null($this->site)) {
            return $this->site;
        }

        try {
            /** @var $siteRepository SiteRepository */
            $siteRepository = GeneralUtility::makeInstance(SiteRepository::class);
            $this->site = $siteRepository->getSiteByRootPageId($this->rootPageId);
        } catch (\InvalidArgumentException $e) {
            $logger = GeneralUtility::makeInstance(SolrLogManager::class, /** @scrutinizer ignore-type */ __CLASS__);
            $logger->log(SolrLogManager::ERROR, 'Scheduler task tried to get invalid site');
        }

        return $this->site;
    }

    /**
     * @return array
     */
    public function __sleep()
    {
        $properties = get_object_vars($this);
        // avoid serialization if the site object
        unset($properties['site']);
        return array_keys($properties);
    }
}

Maybe you can correct the typo "if" too :)


@@ -4,6 +4,10 @@ declare(strict_types=1);

 use Doctrine\Common\Annotations\AnnotationRegistry;

+$symfonyDumpFunctionPath = 'vendor/symfony/var-dumper/Resources/functions/dump.php';
+
+file_exists(__DIR__ . '/../' . $symfonyDumpFunctionPath) ? require_once __DIR__ . '/../' . $symfonyDumpFunctionPath : require_once __DIR__ . '/../../' . $symfonyDumpFunctionPath;
+
 /* @var \Composer\Autoload\ClassLoader $loader */
 $loader = file_exists(__DIR__ . '/../vendor/autoload.php') ? require __DIR__ . '/../vendor/autoload.php' : require __DIR__ . '/../../vendor/autoload.php';

<?php

declare(strict_types=1);

use Doctrine\Common\Annotations\AnnotationRegistry;

/* @var \Composer\Autoload\ClassLoader $loader */
$loader = file_exists(__DIR__ . '/../vendor/autoload.php') ? require __DIR__ . '/../vendor/autoload.php' : require __DIR__ . '/../../vendor/autoload.php';

AnnotationRegistry::registerLoader([$loader, 'loadClass']);

return $loader;

this might kill whole application if `var-dumper` will not be installed. What about two separate file_exists conditions (monorepo/project)?


@@ -17,7 +17,7 @@ const SiteNotFound = "not found"
 const SiteDirMissing = "app directory missing"

 // SiteConfigMissing defines the string used to denote when a site is missing its .ddev/config.yml file.
-const SiteConfigMissing = ".ddev/config.yml missing"
+const SiteConfigMissing = ".ddev/config.yaml missing"

 // SiteStopped defines the string used to denote when a site is in the stopped state.
 const SiteStopped = "stopped"

package platform

import "fmt"
import (
	"strings"

	"github.com/fsouza/go-dockerclient"
)

// SiteRunning defines the string used to denote running sites.
const SiteRunning = "running"

// SiteNotFound defines the string used to denote a site where the containers were not found/do not exist.
const SiteNotFound = "not found"

// SiteDirMissing defines the string used to denote when a site is missing its application directory.
const SiteDirMissing = "app directory missing"

// SiteConfigMissing defines the string used to denote when a site is missing its .ddev/config.yml file.
const SiteConfigMissing = ".ddev/config.yml missing"

// SiteStopped defines the string used to denote when a site is in the stopped state.
const SiteStopped = "stopped"

// App is an interface apps for Drud Local must implement to use shared functionality
type App interface {
	Init(string) error
	Describe() (map[string]interface{}, error)
	GetType() string
	AppRoot() string
	GetName() string
	Start() error
	Stop() error
	DockerEnv()
	DockerComposeYAMLPath() string
	Down(removeData bool) error
	CreateSettingsFile() error
	HostName() string
	URL() string
	Import() error
	ImportDB(imPath string, extPath string) error
	ImportFiles(imPath string, extPath string) error
	SiteStatus() string
	FindContainerByType(containerType string) (docker.APIContainers, error)
	// Returns err, stdout, stderr
	Exec(service string, cmd ...string) (string, string, error)
	ExecWithTty(service string, cmd ...string) error
	Logs(service string, follow bool, timestamps bool, tail string) error
}

// PluginMap maps the name of the plugins to their implementation.
var PluginMap = map[string]App{
	"local": &LocalApp{},
}

// GetPluginApp will return an application of the type specified by pluginType
func GetPluginApp(pluginType string) (App, error) {
	switch strings.ToLower(pluginType) {
	case "local":
		return &LocalApp{}, nil
	default:
		return nil, fmt.Errorf("could not find plugin type %s", pluginType)
	}
}

Hmm, thought this would happen if .ddev was missing or docker_compose.yml was missing? Fine with me, don't think it's that important.


@@ -3,7 +3,7 @@ namespace Psalm\Tests\Provider;

 use PhpParser;

-class ParserInstanceCacheProvider extends \Psalm\Provider\ParserCacheProvider
+class ParserInstanceCacheProvider extends \Psalm\Internal\Provider\ParserCacheProvider
 {
     /**
      * @var array<string, string>

<?php
namespace Psalm\Tests\Provider;

use PhpParser;

class ParserInstanceCacheProvider extends \Psalm\Provider\ParserCacheProvider
{
    /**
     * @var array<string, string>
     */
    private $file_contents_cache = [];

    /**
     * @var array<string, string>
     */
    private $file_content_hash = [];

    /**
     * @var array<string, array<int, PhpParser\Node\Stmt>>
     */
    private $statements_cache = [];

    /**
     * @var array<string, float>
     */
    private $statements_cache_time = [];

    /**
     * @var int
     */
    private $last_good_run = 0;

    public function __construct()
    {
    }

    public function loadStatementsFromCache($file_path, $file_modified_time, $file_content_hash)
    {
        if (isset($this->statements_cache[$file_path])
            && $this->statements_cache_time[$file_path] >= $file_modified_time
            && $this->file_content_hash[$file_path] === $file_content_hash
        ) {
            return $this->statements_cache[$file_path];
        }

        return null;
    }

    /**
     * @param  string   $file_content_hash
     * @param  string   $file_path
     * @param mixed $file_modified_time
     *
     * @return array<int, PhpParser\Node\Stmt>|null
     */
    public function loadExistingStatementsFromCache($file_path)
    {
        if (isset($this->statements_cache[$file_path])) {
            return $this->statements_cache[$file_path];
        }

        return null;
    }

    /**
     * @param  string                           $file_path
     * @param  string                           $file_content_hash
     * @param  array<int, PhpParser\Node\Stmt>  $stmts
     * @param  bool                             $touch_only
     *
     * @return void
     */
    public function saveStatementsToCache($file_path, $file_content_hash, array $stmts, $touch_only)
    {
        $this->statements_cache[$file_path] = $stmts;
        $this->statements_cache_time[$file_path] = microtime(true);
        $this->file_content_hash[$file_path] = $file_content_hash;
    }

    /**
     * @param  string   $file_path
     *
     * @return string|null
     */
    public function loadExistingFileContentsFromCache($file_path)
    {
        if (isset($this->file_contents_cache[$file_path])) {
            return $this->file_contents_cache[$file_path];
        }

        return null;
    }

    /**
     * @param  string  $file_path
     * @param  string  $file_contents
     *
     * @return void
     */
    public function cacheFileContents($file_path, $file_contents)
    {
        $this->file_contents_cache[$file_path] = $file_contents;
    }

    /**
     * @return int
     */
    public function getLastGoodRun()
    {
        return $this->last_good_run;
    }

    /**
     * @param float $start_time
     *
     * @return void
     */
    public function processSuccessfulRun($start_time)
    {
        $this->last_good_run = (int) $start_time;
    }

    /**
     * @return bool
     */
    public function canDiffFiles()
    {
        return $this->last_good_run > 0;
    }
}

nit: Would it make more sense to move tests/Provider to tests/Internal/Provider (etc.) so that Psalm\Tests\Internal\Foo contains overrides of Psalm\Internal\Foo (e.g. if both Psalm\Provider and Psalm\Internal\Provider exist later)


@@ -4,13 +4,30 @@ declare(strict_types=1);

 namespace Bolt\DataFixtures;

+use Bolt\Configuration\Areas;
+use Bolt\Configuration\Config;
 use Doctrine\Bundle\FixturesBundle\Fixture;
+use Symfony\Component\Finder\Finder;
+use Tightenco\Collect\Support\Collection;
+use Webmozart\PathUtil\Path;

 abstract class BaseFixture extends Fixture
 {
     private $referencesIndex = [];
     private $taxonomyIndex = [];

+    /** @var Config */
+    protected $config;
+
+    /** @var Areas */
+    protected $areas;
+
+    public function __construct(Config $config, Areas $areas)
+    {
+        $this->config = $config;
+        $this->areas = $areas;
+    }
+
     protected function getRandomReference(string $entityName)
     {
         if (isset($this->referencesIndex[$entityName]) === false) {

<?php

declare(strict_types=1);

namespace Bolt\DataFixtures;

use Doctrine\Bundle\FixturesBundle\Fixture;

abstract class BaseFixture extends Fixture
{
    private $referencesIndex = [];
    private $taxonomyIndex = [];

    protected function getRandomReference(string $entityName)
    {
        if (isset($this->referencesIndex[$entityName]) === false) {
            $this->referencesIndex[$entityName] = [];

            foreach (array_keys($this->referenceRepository->getReferences()) as $key) {
                if (mb_strpos($key, $entityName.'_') === 0) {
                    $this->referencesIndex[$entityName][] = $key;
                }
            }
        }
        if (empty($this->referencesIndex[$entityName])) {
            throw new \Exception(sprintf('Cannot find any references for Entity "%s"', $entityName));
        }
        $randomReferenceKey = array_rand($this->referencesIndex[$entityName], 1);

        return $this->getReference($this->referencesIndex[$entityName][$randomReferenceKey]);
    }

    protected function getRandomTaxonomies(string $type, int $amount): array
    {
        if (empty($this->taxonomyIndex)) {
            foreach (array_keys($this->referenceRepository->getReferences()) as $key) {
                if (mb_strpos($key, 'taxonomy_') === 0) {
                    $tuples = explode('_', $key);
                    $this->taxonomyIndex[$tuples[1]][] = $key;
                }
            }
        }

        if (empty($this->taxonomyIndex[$type])) {
            return [];
        }

        $taxonomies = [];

        foreach ((array) array_rand($this->taxonomyIndex[$type], $amount) as $key) {
            $taxonomies[] = $this->getReference($this->taxonomyIndex[$type][$key]);
        }

        return $taxonomies;
    }
}

Why this constructor is added here when none of these dependencies is used in this class? Areas should not be a part of BaseFixture. move it to trait or to another abstract class


@@ -52,7 +52,8 @@ if (TYPO3_MODE == 'BE') {
         '',
         [
             // An array holding the controller-action-combinations that are accessible
-            'Administration' => 'index,setSite,setCore,noSiteAvailable'
+            'Administration' => 'index,setSite,setCore,noSiteAvailable',
+            'Backend\\Web\\Info\\ApacheSolrDocument' => 'index'
         ],
         [
             'access' => 'admin',

<?php
if (!defined('TYPO3_MODE')) {
    die('Access denied.');
}

# ----- # ----- # ----- # ----- # ----- # ----- # ----- # ----- # ----- #

// add search plugin to content element wizard
if (TYPO3_MODE == 'BE') {
    $TBE_MODULES_EXT['xMOD_db_new_content_el']['addElClasses']['ApacheSolrForTypo3\\Solr\\Backend\\ContentElementWizardIconProvider'] =
        \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::extPath($_EXTKEY) . 'Classes/Backend/ContentElementWizardIconProvider.php';
}
# ----- # ----- # ----- # ----- # ----- # ----- # ----- # ----- # ----- #

$extIconPath = 'EXT:solr/Resources/Public/Images/Icons/';
if (TYPO3_MODE === 'BE') {
    $modulePrefix = 'extensions-solr-module';
    $bitmapProvider = \TYPO3\CMS\Core\Imaging\IconProvider\BitmapIconProvider::class;
    $svgProvider = \TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider::class;

        // register all module icons with extensions-solr-module-modulename
    $iconRegistry = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Imaging\IconRegistry::class);
    $iconRegistry->registerIcon($modulePrefix . '-administration', $svgProvider,
        ['source' => $extIconPath . 'ModuleAdministration.svg']);
    $iconRegistry->registerIcon($modulePrefix . '-overview', $bitmapProvider,
        ['source' => $extIconPath . 'Search.png']);
    $iconRegistry->registerIcon($modulePrefix . '-indexqueue', $bitmapProvider,
        ['source' => $extIconPath . 'IndexQueue.png']);
    $iconRegistry->registerIcon($modulePrefix . '-indexmaintenance', $bitmapProvider,
        ['source' => $extIconPath . 'IndexMaintenance.png']);
    $iconRegistry->registerIcon($modulePrefix . '-indexfields', $bitmapProvider,
        ['source' => $extIconPath . 'IndexFields.png']);
    $iconRegistry->registerIcon($modulePrefix . '-stopwords', $bitmapProvider,
        ['source' => $extIconPath . 'StopWords.png']);
    $iconRegistry->registerIcon($modulePrefix . '-synonyms', $bitmapProvider,
        ['source' => $extIconPath . 'Synonyms.png']);
    $iconRegistry->registerIcon($modulePrefix . '-searchstatistics', $bitmapProvider,
        ['source' => $extIconPath . 'SearchStatistics.png']);
    $iconRegistry->registerIcon($modulePrefix . '-initsolrconnections', $svgProvider,
        ['source' => $extIconPath . 'InitSolrConnections.svg']);

    // register plugin icon
    $iconRegistry->registerIcon('extensions-solr-plugin-contentelement', $svgProvider,
        ['source' => $extIconPath . 'ContentElement.svg']);
}

if (TYPO3_MODE == 'BE') {
    \TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerModule(
        'ApacheSolrForTypo3.' . $_EXTKEY,
        'tools',
        'administration',
        '',
        [
            // An array holding the controller-action-combinations that are accessible
            'Administration' => 'index,setSite,setCore,noSiteAvailable'
        ],
        [
            'access' => 'admin',
            'icon' => 'EXT:solr/Resources/Public/Images/Icons/ModuleAdministration.svg',
            'labels' => 'LLL:EXT:' . $_EXTKEY . '/Resources/Private/Language/locallang.xlf',
        ]
    );

    ApacheSolrForTypo3\Solr\Backend\SolrModule\AdministrationModuleManager::registerModule(
        'ApacheSolrForTypo3.' . $_EXTKEY,
        'Overview',
        ['index']
    );

    ApacheSolrForTypo3\Solr\Backend\SolrModule\AdministrationModuleManager::registerModule(
        'ApacheSolrForTypo3.' . $_EXTKEY,
        'IndexQueue',
        ['index,initializeIndexQueue,resetLogErrors,clearIndexQueue']
    );

    ApacheSolrForTypo3\Solr\Backend\SolrModule\AdministrationModuleManager::registerModule(
        'ApacheSolrForTypo3.' . $_EXTKEY,
        'IndexMaintenance',
        ['index,cleanUpIndex,emptyIndex,reloadIndexConfiguration']
    );

    ApacheSolrForTypo3\Solr\Backend\SolrModule\AdministrationModuleManager::registerModule(
        'ApacheSolrForTypo3.' . $_EXTKEY,
        'IndexFields',
        ['index']
    );

    ApacheSolrForTypo3\Solr\Backend\SolrModule\AdministrationModuleManager::registerModule(
        'ApacheSolrForTypo3.' . $_EXTKEY,
        'SearchStatistics',
        ['index']
    );

    ApacheSolrForTypo3\Solr\Backend\SolrModule\AdministrationModuleManager::registerModule(
        'ApacheSolrForTypo3.' . $_EXTKEY,
        'StopWords',
        ['index,saveStopWords']
    );

    ApacheSolrForTypo3\Solr\Backend\SolrModule\AdministrationModuleManager::registerModule(
        'ApacheSolrForTypo3.' . $_EXTKEY,
        'Synonyms',
        ['index,addSynonyms,deleteSynonyms']
    );

    // registering reports
    $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['reports']['tx_reports']['status']['providers']['solr'] = [
        \ApacheSolrForTypo3\Solr\Report\SchemaStatus::class,
        \ApacheSolrForTypo3\Solr\Report\SolrConfigStatus::class,
        \ApacheSolrForTypo3\Solr\Report\SolrConfigurationStatus::class,
        \ApacheSolrForTypo3\Solr\Report\SolrStatus::class,
        \ApacheSolrForTypo3\Solr\Report\SolrVersionStatus::class,
        \ApacheSolrForTypo3\Solr\Report\AccessFilterPluginInstalledStatus::class,
        \ApacheSolrForTypo3\Solr\Report\AllowUrlFOpenStatus::class,
        \ApacheSolrForTypo3\Solr\Report\FilterVarStatus::class
    ];

    // Index Inspector
    \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::insertModuleFunction(
        'web_info',
        \ApacheSolrForTypo3\Solr\Backend\IndexInspector\IndexInspector::class,
        null,
        'LLL:EXT:solr/Resources/Private/Language/locallang.xlf:module_indexinspector'
    );

    // register Clear Cache Menu hook
    $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['additionalBackendItems']['cacheActions']['clearSolrConnectionCache'] = \ApacheSolrForTypo3\Solr\ConnectionManager::class;
}
if ((TYPO3_MODE === 'BE') || (TYPO3_MODE === 'FE' && isset($_POST['TSFE_EDIT']))) {
    // the order of registering the garbage collector and the record monitor is important!
    // for certain scenarios items must be removed by GC first, and then be re-added to to Index Queue

    // hooking into TCE Main to monitor record updates that may require deleting documents from the index
    $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'][] = \ApacheSolrForTypo3\Solr\GarbageCollector::class;
    $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'][] = \ApacheSolrForTypo3\Solr\GarbageCollector::class;

    // hooking into TCE Main to monitor record updates that may require reindexing by the index queue
    $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'][] = \ApacheSolrForTypo3\Solr\IndexQueue\RecordMonitor::class;
    $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'][] = \ApacheSolrForTypo3\Solr\IndexQueue\RecordMonitor::class;
}

# ----- # ----- # ----- # ----- # ----- # ----- # ----- # ----- # ----- #

// register click menu item to initialize the Solr connections for a single site
// visible for admin users only
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addUserTSConfig('
[adminUser = 1]
options.contextMenu.table.pages.items.850 = ITEM
options.contextMenu.table.pages.items.850 {
	name = Tx_Solr_initializeSolrConnections
	label = Initialize Solr Connections
	iconName = extensions-solr-module-initsolrconnections
	displayCondition = getRecord|is_siteroot = 1
	callbackAction = initializeSolrConnections
}

options.contextMenu.table.pages.items.851 = DIVIDER
[global]
');

\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::registerExtDirectComponent(
    'TYPO3.Solr.ContextMenuActionController',
    \ApacheSolrForTypo3\Solr\ContextMenuActionController::class,
    'web',
    'admin'
);

// include JS in backend
$GLOBALS['TYPO3_CONF_VARS']['typo3/backend.php']['additionalBackendItems']['Solr.ContextMenuInitializeSolrConnectionsAction'] = \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::extPath('solr') . 'Classes/BackendItem/ContextMenuActionJavascriptRegistration.php';

# ----- # ----- # ----- # ----- # ----- # ----- # ----- # ----- # ----- #

// replace the built-in search content element
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addPiFlexFormValue(
    '*',
    'FILE:EXT:' . $_EXTKEY . '/Configuration/FlexForms/Results.xml',
    'search'
);

$TCA['tt_content']['types']['search']['showitem'] =
    '--palette--;LLL:EXT:cms/locallang_ttc.xml:palette.general;general,
	--palette--;LLL:EXT:cms/locallang_ttc.xml:palette.header;header,
	--div--;LLL:EXT:cms/locallang_ttc.xml:tabs.plugin,
		pi_flexform;;;;1-1-1,
	--div--;LLL:EXT:cms/locallang_ttc.xml:tabs.access,
		--palette--;LLL:EXT:cms/locallang_ttc.xml:palette.visibility;visibility,
		--palette--;LLL:EXT:cms/locallang_ttc.xml:palette.access;access,
	--div--;LLL:EXT:cms/locallang_ttc.xml:tabs.appearance,
		--palette--;LLL:EXT:cms/locallang_ttc.xml:palette.frames;frames,
	--div--;LLL:EXT:cms/locallang_ttc.xml:tabs.behaviour,
	--div--;LLL:EXT:cms/locallang_ttc.xml:tabs.extended';


@@ -5,12 +5,16 @@ declare(strict_types=1);
 namespace Tests\App\Functional\Controller;

 use App\DataFixtures\Demo\ProductDataFixture;
+use Faker\Provider\Text;
 use Shopsys\FrameworkBundle\Component\Domain\Domain;
-use Tests\App\Test\TransactionFunctionalTestCase;
+use Tests\App\Test\FunctionalTestCase;
+use Zalas\Injector\PHPUnit\Symfony\TestCase\SymfonyTestContainer;

-class ProductRenameRedirectPreviousUrlTest extends TransactionFunctionalTestCase
+class ProductRenameRedirectPreviousUrlTest extends FunctionalTestCase
 {
-    private const TESTED_PRODUCT_ID = 1;
+    use SymfonyTestContainer;
+
+    private const TESTED_PRODUCT_ID = 100;

     /**
      * @var \Shopsys\FrameworkBundle\Model\Product\ProductDataFactoryInterface

<?php

declare(strict_types=1);

namespace Tests\App\Functional\Controller;

use App\DataFixtures\Demo\ProductDataFixture;
use Shopsys\FrameworkBundle\Component\Domain\Domain;
use Tests\App\Test\TransactionFunctionalTestCase;

class ProductRenameRedirectPreviousUrlTest extends TransactionFunctionalTestCase
{
    private const TESTED_PRODUCT_ID = 1;

    /**
     * @var \Shopsys\FrameworkBundle\Model\Product\ProductDataFactoryInterface
     * @inject
     */
    private $productDataFactory;

    /**
     * @var \Shopsys\FrameworkBundle\Model\Product\ProductFacade
     * @inject
     */
    private $productFacade;

    /**
     * @var \Shopsys\FrameworkBundle\Component\Router\FriendlyUrl\FriendlyUrlFacade
     * @inject
     */
    private $friendlyUrlFacade;

    public function testPreviousUrlRedirect(): void
    {
        $product = $this->getReference(ProductDataFixture::PRODUCT_PREFIX . self::TESTED_PRODUCT_ID);

        $previousFriendlyUrlSlug = $this->friendlyUrlFacade->findMainFriendlyUrl(Domain::FIRST_DOMAIN_ID, 'front_product_detail', self::TESTED_PRODUCT_ID)->getSlug();

        /** @var \App\Model\Product\Product $product */
        $productData = $this->productDataFactory->createFromProduct($product);
        $productData->name[$this->domain->getDomainConfigById(Domain::FIRST_DOMAIN_ID)->getLocale()] = 'rename';

        $this->productFacade->edit(self::TESTED_PRODUCT_ID, $productData);

        $client = $this->getClient();
        $client->request('GET', '/' . $previousFriendlyUrlSlug);

        // Should be 301 (moved permanently), because old product urls should be permanently redirected
        $this->assertEquals(301, $client->getResponse()->getStatusCode());
    }
}

Any particular reason to change TransactionalFunctionalTestCase to FunctionalTestCase and injecting entityManager? If so, it would be nice to explain it in commit message


@@ -73,6 +73,10 @@ class CarouselLoop extends Image
     {
         /** @var \Carousel\Model\Carousel $carousel */
         foreach ($loopResult->getResultDataCollection() as $carousel) {
+            if (!file_exists($carousel->getUploadDir() . DS . $carousel->getFile())) {
+                continue;
+            }
+
             $loopResultRow = new LoopResultRow($carousel);

             $event = new ImageEvent();

<?php
/*************************************************************************************/
/*      This file is part of the Thelia package.                                     */
/*                                                                                */
/*      Copyright (c) OpenStudio                                                     */
/*      email : dev@thelia.net                                                       */
/*      web : http://www.thelia.net                                                  */
/*                                                                                */
/*      For the full copyright and license information, please view the LICENSE.txt  */
/*      file that was distributed with this source code.                             */
/*************************************************************************************/

namespace Carousel\Loop;

use Carousel\Model\CarouselQuery;
use Propel\Runtime\ActiveQuery\Criteria;
use Thelia\Core\Event\Image\ImageEvent;
use Thelia\Core\Event\TheliaEvents;
use Thelia\Core\Template\Element\LoopResult;
use Thelia\Core\Template\Element\LoopResultRow;
use Thelia\Core\Template\Loop\Argument\Argument;
use Thelia\Core\Template\Loop\Argument\ArgumentCollection;
use Thelia\Core\Template\Loop\Image;
use Thelia\Type\EnumListType;
use Thelia\Type\EnumType;
use Thelia\Type\TypeCollection;

/**
 * Class CarouselLoop
 * @package Carousel\Loop
 * @author manuel raynaud <mraynaud@openstudio.fr>
 */
class CarouselLoop extends Image
{

    /**
     * @inheritdoc
     */
    protected function getArgDefinitions()
    {
        return new ArgumentCollection(
            Argument::createIntTypeArgument('width'),
            Argument::createIntTypeArgument('height'),
            Argument::createIntTypeArgument('rotation', 0),
            Argument::createAnyTypeArgument('background_color'),
            Argument::createIntTypeArgument('quality'),
            new Argument(
                'resize_mode',
                new TypeCollection(
                    new EnumType(array('crop', 'borders', 'none'))
                ),
                'none'
            ),
            new Argument(
                'order',
                new TypeCollection(
                    new EnumListType(array('alpha', 'alpha-reverse', 'manual', 'manual-reverse', 'random'))
                ),
                'manual'
            ),
            Argument::createAnyTypeArgument('effects'),
            Argument::createBooleanTypeArgument('allow_zoom', false)
        );
    }

    /**
     * @param LoopResult $loopResult
     *
     * @return LoopResult
     */
    public function parseResults(LoopResult $loopResult)
    {
        /** @var \Carousel\Model\Carousel $carousel */
        foreach ($loopResult->getResultDataCollection() as $carousel) {
            $loopResultRow = new LoopResultRow($carousel);

            $event = new ImageEvent();
            $event->setSourceFilepath($carousel->getUploadDir() . DS . $carousel->getFile())
                ->setCacheSubdirectory('carousel');

            switch ($this->getResizeMode()) {
                case 'crop':
                    $resize_mode = \Thelia\Action\Image::EXACT_RATIO_WITH_CROP;
                    break;

                case 'borders':
                    $resize_mode = \Thelia\Action\Image::EXACT_RATIO_WITH_BORDERS;
                    break;

                case 'none':
                default:
                    $resize_mode = \Thelia\Action\Image::KEEP_IMAGE_RATIO;

            }

            // Prepare tranformations
            $width = $this->getWidth();
            $height = $this->getHeight();
            $rotation = $this->getRotation();
            $background_color = $this->getBackgroundColor();
            $quality = $this->getQuality();
            $effects = $this->getEffects();

            if (!is_null($width)) {
                $event->setWidth($width);
            }
            if (!is_null($height)) {
                $event->setHeight($height);
            }
            $event->setResizeMode($resize_mode);
            if (!is_null($rotation)) {
                $event->setRotation($rotation);
            }
            if (!is_null($background_color)) {
                $event->setBackgroundColor($background_color);
            }
            if (!is_null($quality)) {
                $event->setQuality($quality);
            }
            if (!is_null($effects)) {
                $event->setEffects($effects);
            }

            $event->setAllowZoom($this->getAllowZoom());

            // Dispatch image processing event
            $this->dispatcher->dispatch(TheliaEvents::IMAGE_PROCESS, $event);

            $loopResultRow
                ->set('ID', $carousel->getId())
                ->set("LOCALE", $this->locale)
                ->set("IMAGE_URL", $event->getFileUrl())
                ->set("ORIGINAL_IMAGE_URL", $event->getOriginalFileUrl())
                ->set("IMAGE_PATH", $event->getCacheFilepath())
                ->set("ORIGINAL_IMAGE_PATH", $event->getSourceFilepath())
                ->set("TITLE", $carousel->getVirtualColumn('i18n_TITLE'))
                ->set("CHAPO", $carousel->getVirtualColumn('i18n_CHAPO'))
                ->set("DESCRIPTION", $carousel->getVirtualColumn('i18n_DESCRIPTION'))
                ->set("POSTSCRIPTUM", $carousel->getVirtualColumn('i18n_POSTSCRIPTUM'))
                ->set("ALT", $carousel->getVirtualColumn('i18n_ALT'))
                ->set("URL", $carousel->getUrl())
                ->set('POSITION', $carousel->getPosition())
            ;

            $loopResult->addRow($loopResultRow);
        }

        return $loopResult;
    }

    /**
     * this method returns a Propel ModelCriteria
     *
     * @return \Propel\Runtime\ActiveQuery\ModelCriteria
     */
    public function buildModelCriteria()
    {
        $search = CarouselQuery::create();

        $this->configureI18nProcessing($search, [ 'ALT', 'TITLE', 'CHAPO', 'DESCRIPTION', 'POSTSCRIPTUM' ]);

        $orders  = $this->getOrder();

        // Results ordering
        foreach ($orders as $order) {
            switch ($order) {
                case "alpha":
                    $search->addAscendingOrderByColumn('i18n_TITLE');
                    break;
                case "alpha-reverse":
                    $search->addDescendingOrderByColumn('i18n_TITLE');
                    break;
                case "manual-reverse":
                    $search->orderByPosition(Criteria::DESC);
                    break;
                case "manual":
                    $search->orderByPosition(Criteria::ASC);
                    break;
                case "random":
                    $search->clearOrderByColumns();
                    $search->addAscendingOrderByColumn('RAND()');
                    break(2);
                    break;
            }
        }

        return $search;
    }
}

Adding a warning or an error in the Thelia log would be a nice idea.