diff --git a/src/resources/js/app.js b/src/resources/js/app.js index be2af755..390d542f 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,455 +1,455 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Widgets/Menu' import SupportForm from '../vue/Widgets/SupportForm' import { Tab } from 'bootstrap' import { loadLangAsync, i18n } from './locale' import { clearFormValidation, pick, startLoading, stopLoading } from './utils' const routerState = { afterLogin: null, isLoggedIn: !!localStorage.getItem('token') } let loadingRoute // Note: This has to be before the app is created // Note: You cannot use app inside of the function window.router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.meta.requiresAuth && !routerState.isLoggedIn) { // remember the original request, to use after login routerState.afterLogin = to; // redirect to login page next({ name: 'login' }) return } if (to.meta.loading) { startLoading() loadingRoute = to.name } next() }) window.router.afterEach((to, from) => { if (to.name && loadingRoute === to.name) { stopLoading() loadingRoute = null } // When changing a page remove old: // - error page // - modal backdrop $('#error-page,.modal-backdrop.show').remove() $('body').css('padding', 0) // remove padding added by unclosed modal // Close the mobile menu if ($('#header-menu .navbar-collapse.show').length) { $('#header-menu .navbar-toggler').click(); } }) const app = new Vue({ components: { AppComponent, MenuComponent, }, i18n, router: window.router, data() { return { authInfo: null, isUser: !window.isAdmin && !window.isReseller, appName: window.config['app.name'], appUrl: window.config['app.url'], themeDir: '/themes/' + window.config['app.theme'] } }, methods: { clearFormValidation, hasPermission(type) { const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1) return !!(this.authInfo && this.authInfo.statusInfo[key]) }, hasRoute(name) { return this.$router.resolve({ name: name }).resolved.matched.length > 0 }, hasSKU(name) { return this.authInfo.statusInfo.skus && this.authInfo.statusInfo.skus.indexOf(name) != -1 }, isController(wallet_id) { if (wallet_id && this.authInfo) { let i for (i = 0; i < this.authInfo.wallets.length; i++) { if (wallet_id == this.authInfo.wallets[i].id) { return true } } for (i = 0; i < this.authInfo.accounts.length; i++) { if (wallet_id == this.authInfo.accounts[i].id) { return true } } } return false }, isDegraded() { return this.authInfo && this.authInfo.isAccountDegraded }, // Set user state to "logged in" loginUser(response, dashboard, update) { if (!update) { routerState.isLoggedIn = true this.authInfo = null } localStorage.setItem('token', response.access_token) localStorage.setItem('refreshToken', response.refresh_token) axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token if (response.email) { this.authInfo = response } if (dashboard !== false) { this.$router.push(routerState.afterLogin || { name: 'dashboard' }) } routerState.afterLogin = null // Refresh the token before it expires let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires if (timeout > 60) { timeout -= 60 } // TODO: We probably should try a few times in case of an error // TODO: We probably should prevent axios from doing any requests // while the token is being refreshed this.refreshTimeout = setTimeout(() => { axios.post('api/auth/refresh', { refresh_token: response.refresh_token }).then(response => { this.loginUser(response.data, false, true) }) }, timeout * 1000) }, // Set user state to "not logged in" logoutUser(redirect) { routerState.isLoggedIn = true this.authInfo = null localStorage.setItem('token', '') localStorage.setItem('refreshToken', '') delete axios.defaults.headers.common.Authorization if (redirect !== false) { this.$router.push({ name: 'login' }) } clearTimeout(this.refreshTimeout) }, logo(mode) { let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png' return `${this.appName}` }, pick, startLoading, stopLoading, tab(e) { e.preventDefault() new Tab(e.target).show() }, errorPage(code, msg, hint) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". if (!msg) msg = this.$te('error.' + code) ? this.$t('error.' + code) : this.$t('error.unknown') if (!hint) hint = '' const error_page = '
' + `
${code}
${msg}
${hint}
` + '
' $('#error-page').remove() $('#app').append(error_page) app.updateBodyClass('error') }, errorHandler(error) { stopLoading() const status = error.response ? error.response.status : 500 const message = error.response ? error.response.statusText : '' if (status == 401) { // Remember requested route to come back to it after log in if (this.$route.meta.requiresAuth) { routerState.afterLogin = this.$route this.logoutUser() } else { this.logoutUser(false) } } else { this.errorPage(status, message) } }, price(price, currency) { // TODO: Set locale argument according to the currently used locale return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, discount, currency) { let index = '' if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost, currency) + '/' + this.$t('wallet.month') + index }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { $(event.target).closest('tr').find('a').trigger('click') } }, pageName(path) { let page = this.$route.path // check if it is a "menu page", find the page name // otherwise we'll use the real path as page name window.config.menu.every(item => { if (item.location == page && item.page) { page = item.page return false } }) page = page.replace(/^\//, '') return page ? page : '404' }, supportDialog(container) { let dialog = $('#support-dialog')[0] if (!dialog) { // FIXME: Find a nicer way of doing this SupportForm.i18n = i18n let form = new Vue(SupportForm) form.$mount($('
').appendTo(container)[0]) form.$root = this form.$toast = this.$toast dialog = form.$el } - dialog.__vue__.showDialog() + dialog.__vue__.show() }, statusClass(obj) { if (obj.isDeleted) { return 'text-muted' } if (obj.isDegraded || obj.isAccountDegraded || obj.isSuspended) { return 'text-warning' } if (obj.isImapReady === false || obj.isLdapReady === false || obj.isVerified === false || obj.isConfirmed === false) { return 'text-danger' } return 'text-success' }, statusText(obj) { if (obj.isDeleted) { return this.$t('status.deleted') } if (obj.isDegraded || obj.isAccountDegraded) { return this.$t('status.degraded') } if (obj.isSuspended) { return this.$t('status.suspended') } if (obj.isImapReady === false || obj.isLdapReady === false || obj.isVerified === false || obj.isConfirmed === false) { return this.$t('status.notready') } return this.$t('status.active') }, // Append some wallet properties to the object userWalletProps(object) { let wallet = this.authInfo.accounts[0] if (!wallet) { wallet = this.authInfo.wallets[0] } if (wallet) { object.currency = wallet.currency if (wallet.discount) { object.discount = wallet.discount object.discount_description = wallet.discount_description } } }, updateBodyClass(name) { // Add 'class' attribute to the body, different for each page // so, we can apply page-specific styles document.body.className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '') } } }) // Fetch the locale file and the start the app loadLangAsync().then(() => app.$mount('#app')) // Add a axios request interceptor axios.interceptors.request.use( config => { // This is the only way I found to change configuration options // on a running application. We need this for browser testing. config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider let loader = config.loader if (loader) { startLoading(loader) } return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler axios.interceptors.response.use( response => { if (response.config.onFinish) { response.config.onFinish() } let loader = response.config.loader if (loader) { stopLoading(loader) } return response }, error => { let loader = error.config.loader if (loader) { stopLoading(loader) } // Do not display the error in a toast message, pass the error as-is if (axios.isCancel(error) || error.config.ignoreErrors) { return Promise.reject(error) } if (error.config.onFinish) { error.config.onFinish() } let error_msg const status = error.response ? error.response.status : 200 const data = error.response ? error.response.data : {} if (status == 422 && data.errors) { error_msg = app.$t('error.form') const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(data.errors, (idx, msg) => { const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { input = form.find('[name="' + input_name + '"]'); } if (input.length) { // Create an error message // API responses can use a string, array or object let msg_text = '' if (typeof(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget let controls = input.children(':not(:first-child)') if (!controls.length && typeof msg == 'string') { // this is an empty list (the main input only) // and the error message is not an array input.find('.main-input').addClass('is-invalid') } else { controls.each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) } input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // a special case, e.g. the invitation policy widget if (input.is('select') && input.parent().is('.input-group-select.selected')) { input = input.next() } // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.list-input)').first().focus() }) } else if (data.status == 'error') { error_msg = data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || app.$t('error.server')) // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss index 29635e97..e7ce776e 100644 --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -1,507 +1,518 @@ html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; overflow: hidden; & > nav { flex-shrink: 0; z-index: 12; } & > div.container { flex-grow: 1; margin-top: 2rem; margin-bottom: 2rem; } & > .filler { flex-grow: 1; } & > div.container + .filler { display: none; } } .error-page { position: absolute; top: 0; height: 100%; width: 100%; align-content: center; align-items: center; display: flex; flex-wrap: wrap; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } .hint { margin-top: 3em; text-align: center; width: 100%; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; z-index: 8; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } &.small .spinner-border { width: 25px; height: 25px; border-width: 3px; } &.fadeOut { visibility: hidden; opacity: 0; transition: visibility 300ms linear, opacity 300ms linear; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; td { vertical-align: middle; height: 8em; border: 0; } tbody:not(:empty) + & { display: none; } } table { th { white-space: nowrap; } td.email, td.price, td.datetime, td.selection { width: 1%; white-space: nowrap; } td.buttons, th.price, td.price, th.size, td.size { width: 1%; text-align: right; white-space: nowrap; } &.form-list { margin: 0; td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } button { line-height: 1; } } .btn-action { line-height: 1; padding: 0; } &.files { table-layout: fixed; td { white-space: nowrap; } td.name { overflow: hidden; text-overflow: ellipsis; } /* td.size, th.size { width: 80px; } td.mtime, th.mtime { width: 140px; @include media-breakpoint-down(sm) { display: none; } } */ td.buttons, th.buttons { width: 50px; } } } .table > :not(:first-child) { // Remove Bootstrap's 2px border border-width: 0; } .list-details { min-height: 1em; & > ul { margin: 0; padding-left: 1.2em; } } .plan-selector { .plan-header { display: flex; } .plan-ico { margin:auto; font-size: 3.8rem; color: #f1a539; border: 3px solid #f1a539; width: 6rem; height: 6rem; border-radius: 50%; } } .status-message { display: flex; align-items: center; justify-content: center; .app-loader { width: auto; position: initial; .spinner-border { color: $body-color; } } svg { font-size: 1.5em; } :first-child { margin-right: 0.4em; } } .form-separator { position: relative; margin: 1em 0; display: flex; justify-content: center; hr { border-color: #999; margin: 0; position: absolute; top: 0.75em; width: 100%; } span { background: #fff; padding: 0 1em; z-index: 1; } } +.modal { + .modal-dialog, + .modal-content { + max-height: calc(100vh - 3.5rem); + } + + .modal-body { + overflow: auto !important; + } +} + #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } @keyframes blinker { 50% { opacity: 0; } } .blinker { animation: blinker 750ms step-start infinite; } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; &.disabled { pointer-events: none; opacity: 0.6; } // Some icons are too big, scale them down &.link-companionapp, &.link-domains, &.link-resources, &.link-settings, &.link-wallet, &.link-invitations { svg { transform: scale(0.8); } } &.link-distlists, &.link-files, &.link-shared-folders { svg { transform: scale(0.9); } } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } #payment-method-selection { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; } svg { width: 6rem; height: 6rem; margin: auto; } .link-banktransfer svg { transform: scale(.8); } } #logon-form { flex-basis: auto; // Bootstrap issue? See logon page with width < 992 } #logon-form-footer { a:not(:first-child) { margin-left: 2em; } } // Various improvements for mobile @include media-breakpoint-down(sm) { .card, .card-footer { border: 0; } .card-body { padding: 0.5rem 0; } .nav-tabs { flex-wrap: nowrap; .nav-link { white-space: nowrap; padding: 0.5rem 0.75rem; } } #app > div.container { margin-bottom: 1rem; margin-top: 1rem; max-width: 100%; } #header-menu-navbar { padding: 0; } #dashboard-nav > a { width: 135px; } .table-sm:not(.form-list) { tbody td { padding: 0.75rem 0.5rem; svg { vertical-align: -0.175em; } & > svg { font-size: 125%; margin-right: 0.25rem; } } } .table.transactions { thead { display: none; } tbody { tr { position: relative; display: flex; flex-wrap: wrap; } td { width: auto; border: 0; padding: 0.5rem; &.datetime { width: 50%; padding-left: 0; } &.description { order: 3; width: 100%; border-bottom: 1px solid $border-color; color: $secondary; padding: 0 1.5em 0.5rem 0; margin-top: -0.25em; } &.selection { position: absolute; right: 0; border: 0; top: 1.7em; padding-right: 0; } &.price { width: 50%; padding-right: 0; } &.email { display: none; } } } } } @include media-breakpoint-down(sm) { .tab-pane > .card-body { padding: 0.5rem; } } diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue index 2d299e22..75c160f1 100644 --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -1,726 +1,644 @@ diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue index 7916c6c5..1fa54420 100644 --- a/src/resources/vue/Domain/Info.vue +++ b/src/resources/vue/Domain/Info.vue @@ -1,224 +1,207 @@ diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue index bc75a813..2bfd5379 100644 --- a/src/resources/vue/Meet/Room.vue +++ b/src/resources/vue/Meet/Room.vue @@ -1,646 +1,600 @@ diff --git a/src/resources/vue/Meet/RoomOptions.vue b/src/resources/vue/Meet/RoomOptions.vue index bbf5ff9c..0dd81d2c 100644 --- a/src/resources/vue/Meet/RoomOptions.vue +++ b/src/resources/vue/Meet/RoomOptions.vue @@ -1,112 +1,105 @@ diff --git a/src/resources/vue/Meet/RoomStats.vue b/src/resources/vue/Meet/RoomStats.vue index 330951c1..a2fb4f9d 100644 --- a/src/resources/vue/Meet/RoomStats.vue +++ b/src/resources/vue/Meet/RoomStats.vue @@ -1,64 +1,81 @@ diff --git a/src/resources/vue/Reseller/Invitations.vue b/src/resources/vue/Reseller/Invitations.vue index 63cbe7c3..3a32086a 100644 --- a/src/resources/vue/Reseller/Invitations.vue +++ b/src/resources/vue/Reseller/Invitations.vue @@ -1,220 +1,204 @@ diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue index ec989702..1ea85ed6 100644 --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -1,322 +1,308 @@ diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue index 91b8a0fc..abc7ecb5 100644 --- a/src/resources/vue/Wallet.vue +++ b/src/resources/vue/Wallet.vue @@ -1,430 +1,426 @@ diff --git a/src/resources/vue/Widgets/CompanionappList.vue b/src/resources/vue/Widgets/CompanionappList.vue index 12bd4359..6743c533 100644 --- a/src/resources/vue/Widgets/CompanionappList.vue +++ b/src/resources/vue/Widgets/CompanionappList.vue @@ -1,78 +1,62 @@ diff --git a/src/resources/vue/Widgets/ModalDialog.vue b/src/resources/vue/Widgets/ModalDialog.vue new file mode 100644 index 00000000..7381acb0 --- /dev/null +++ b/src/resources/vue/Widgets/ModalDialog.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/resources/vue/Widgets/SupportForm.vue b/src/resources/vue/Widgets/SupportForm.vue index 930d6e1c..bdf0cc92 100644 --- a/src/resources/vue/Widgets/SupportForm.vue +++ b/src/resources/vue/Widgets/SupportForm.vue @@ -1,115 +1,99 @@ diff --git a/src/tests/Browser.php b/src/tests/Browser.php index 2a59a87b..0b067216 100644 --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -1,299 +1,300 @@ resolver->findOrFail($selector); $value = (string) $element->getAttribute($attribute); $error = "No expected text in [$selector][$attribute]. Found: $value"; Assert::assertMatchesRegularExpression($regexp, $value, $error); return $this; } /** * Assert number of (visible) elements */ public function assertElementsCount($selector, $expected_count, $visible = true) { $elements = $this->elements($selector); $count = count($elements); if ($visible) { foreach ($elements as $element) { if (!$element->isDisplayed()) { $count--; } } } Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $expected_count"); return $this; } /** * Assert Tip element content */ public function assertTip($selector, $content) { return $this->click($selector) ->withinBody(function ($browser) use ($content) { $browser->waitFor('div.tooltip .tooltip-inner') ->assertSeeIn('div.tooltip .tooltip-inner', $content); }) ->click($selector); } /** * Assert Toast element content (and close it) */ public function assertToast(string $type, string $message, $title = null) { return $this->withinBody(function ($browser) use ($type, $title, $message) { $browser->with(new Toast($type), function (Browser $browser) use ($title, $message) { $browser->assertToastTitle($title) ->assertToastMessage($message) ->closeToast(); }); }); } /** * Assert specified error page is displayed. */ public function assertErrorPage(int $error_code, string $hint = '') { $this->with(new Error($error_code, $hint), function ($browser) { // empty, assertions will be made by the Error component itself }); return $this; } /** * Assert that the given element has specified class assigned. */ public function assertHasClass($selector, $class_name) { $element = $this->resolver->findOrFail($selector); $classes = explode(' ', (string) $element->getAttribute('class')); Assert::assertContains($class_name, $classes, "[$selector] has no class '{$class_name}'"); return $this; } /** * Assert that the given element is readonly */ public function assertReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value == 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element is not readonly */ public function assertNotReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value != 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element contains specified text, * no matter it's displayed or not. */ public function assertText($selector, $text) { $element = $this->resolver->findOrFail($selector); if ($text === '') { Assert::assertTrue((string) $element->getText() === $text, "Element's text is not empty [$selector]"); } else { Assert::assertTrue(strpos($element->getText(), $text) !== false, "No expected text in [$selector]"); } return $this; } /** * Assert that the given element contains specified text, * no matter it's displayed or not - using a regular expression. */ public function assertTextRegExp($selector, $regexp) { $element = $this->resolver->findOrFail($selector); Assert::assertMatchesRegularExpression($regexp, $element->getText(), "No expected text in [$selector]"); return $this; } /** * Remove all toast messages */ public function clearToasts() { $this->script("\$('.toast-container > *').remove()"); return $this; } /** * Wait until a button becomes enabled and click it */ public function clickWhenEnabled($selector) { return $this->waitFor($selector . ':not([disabled])')->click($selector); } /** * Check if in Phone mode */ public static function isPhone() { return getenv('TESTS_MODE') == 'phone'; } /** * Check if in Tablet mode */ public static function isTablet() { return getenv('TESTS_MODE') == 'tablet'; } /** * Check if in Desktop mode */ public static function isDesktop() { return !self::isPhone() && !self::isTablet(); } /** * Returns content of a downloaded file */ public function readDownloadedFile($filename, $sleep = 5) { $filename = __DIR__ . "/Browser/downloads/$filename"; // Give the browser a chance to finish download if (!file_exists($filename) && $sleep) { sleep($sleep); } Assert::assertFileExists($filename); return file_get_contents($filename); } /** * Removes downloaded file */ public function removeDownloadedFile($filename) { @unlink(__DIR__ . "/Browser/downloads/$filename"); return $this; } /** * Clears the input field and related vue v-model data. */ public function vueClear($selector) { $selector = $this->resolver->format($selector); // The existing clear(), and type() with empty string do not work. // We have to clear the field and dispatch 'input' event programatically. $this->script( "var element = document.querySelector('$selector');" . "element.value = '';" . "element.dispatchEvent(new Event('input'))" ); return $this; } /** * Execute code within body context. * Useful to execute code that selects elements outside of a component context */ public function withinBody($callback) { if ($this->resolver->prefix != 'body') { $orig_prefix = $this->resolver->prefix; $this->resolver->prefix = 'body'; } call_user_func($callback, $this); if (isset($orig_prefix)) { $this->resolver->prefix = $orig_prefix; } return $this; } /** * Store the console output with the given name. Overwrites Dusk's method. * * @param string $name * @return $this */ public function storeConsoleLog($name) { if (in_array($this->driver->getCapabilities()->getBrowserName(), static::$supportsRemoteLogs)) { $console = $this->driver->manage()->getLog('browser'); // Ignore errors/warnings irrelevant for testing foreach ($console as $idx => $entry) { if ( $entry['level'] != 'SEVERE' || strpos($entry['message'], 'Failed to load resource: the server responded with a status of') + || preg_match('/^\S+\.js [0-9]+:[0-9]+\s*$/', $entry['message']) ) { $console[$idx] = null; } } $console = array_values(array_filter($console)); if (!empty($console)) { $file = sprintf('%s/%s.log', rtrim(static::$storeConsoleLogAt, '/'), $name); $content = json_encode($console, JSON_PRETTY_PRINT); file_put_contents($file, $content); } } return $this; } } diff --git a/src/tests/Browser/Meet/RoomOptionsTest.php b/src/tests/Browser/Meet/RoomOptionsTest.php index 19feb4f4..c16b0104 100644 --- a/src/tests/Browser/Meet/RoomOptionsTest.php +++ b/src/tests/Browser/Meet/RoomOptionsTest.php @@ -1,326 +1,326 @@ setupTestRoom(); } public function tearDown(): void { $this->resetTestRoom(); parent::tearDown(); } /** * Test password protected room * * @group meet */ public function testRoomPassword(): void { $this->browse(function (Browser $owner, Browser $guest) { $room = Room::where('name', 'john')->first(); // Join the room as an owner (authenticate) $owner->visit(new RoomPage('john')) ->click('@setup-button') ->submitLogon('john@kolab.org', 'simple123') ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->assertMissing('@setup-password-input') ->clickWhenEnabled('@setup-button') ->waitFor('@session') // Enter room option dialog ->click('@menu button.link-options') ->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) { $browser->assertSeeIn('@title', 'Room options') - ->assertSeeIn('@button-action', 'Close') + ->assertSeeIn('@button-cancel', 'Close') ->assertElementsCount('.modal-footer button', 1) ->assertSeeIn('#password-input .label', 'Password:') ->assertSeeIn('#password-input-text.text-muted', 'none') ->assertVisible('#password-input + small') ->assertSeeIn('#password-set-btn', 'Set password') ->assertElementsCount('#password-input button', 1) ->assertMissing('#password-input input') // Test setting a password ->click('#password-set-btn') ->assertMissing('#password-input-text') ->assertVisible('#password-input input') ->assertValue('#password-input input', '') ->assertSeeIn('#password-input #password-save-btn', 'Save') ->assertElementsCount('#password-input button', 1) ->type('#password-input input', 'pass') ->click('#password-input #password-save-btn') ->assertToast(Toast::TYPE_SUCCESS, 'Room configuration updated successfully.') ->assertMissing('#password-input input') ->assertSeeIn('#password-input-text:not(.text-muted)', 'pass') ->assertSeeIn('#password-clear-btn.btn-outline-danger', 'Clear password') ->assertElementsCount('#password-input button', 1) - ->click('@button-action'); + ->click('@button-cancel'); $this->assertSame('pass', $room->fresh()->getSetting('password')); }); // In another browser act as a guest, expect password required $guest->visit(new RoomPage('john')) ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->assertSeeIn('@setup-status-message', "Please, provide a valid password.") ->assertVisible('@setup-form .input-group:nth-child(4) svg') ->assertAttribute('@setup-form .input-group:nth-child(4) .input-group-text', 'title', 'Password') ->assertAttribute('@setup-password-input', 'placeholder', 'Password') ->assertValue('@setup-password-input', '') ->assertSeeIn('@setup-button', "JOIN") // Try to join w/o password ->clickWhenEnabled('@setup-button') ->waitFor('#setup-password.is-invalid') // Try to join with a valid password ->type('#setup-password', 'pass') ->clickWhenEnabled('@setup-button') ->waitFor('@session'); // Test removing the password $owner->click('@menu button.link-options') ->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) { $browser->assertSeeIn('@title', 'Room options') ->assertSeeIn('#password-input-text:not(.text-muted)', 'pass') ->assertSeeIn('#password-clear-btn.btn-outline-danger', 'Clear password') ->assertElementsCount('#password-input button', 1) ->click('#password-clear-btn') ->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.") ->assertMissing('#password-input input') ->assertSeeIn('#password-input-text.text-muted', 'none') ->assertSeeIn('#password-set-btn', 'Set password') ->assertElementsCount('#password-input button', 1) - ->click('@button-action'); + ->click('@button-cancel'); $this->assertSame(null, $room->fresh()->getSetting('password')); }); }); } /** * Test locked room (denying the join request) * * @group meet */ public function testLockedRoomDeny(): void { $this->browse(function (Browser $owner, Browser $guest) { $room = Room::where('name', 'john')->first(); // Join the room as an owner (authenticate) $owner->visit(new RoomPage('john')) // ->click('@setup-button') // ->submitLogon('john@kolab.org', 'simple123') ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->type('@setup-nickname-input', 'John') ->clickWhenEnabled('@setup-button') ->waitFor('@session') // Enter room option dialog ->click('@menu button.link-options') ->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) { $browser->assertSeeIn('@title', 'Room options') ->assertSeeIn('#room-lock label', 'Locked room:') ->assertVisible('#room-lock input[type=checkbox]:not(:checked)') ->assertVisible('#room-lock + small') // Test setting the lock ->click('#room-lock input') ->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.") - ->click('@button-action'); + ->click('@button-cancel'); $this->assertSame('true', $room->fresh()->getSetting('locked')); }); // In another browser act as a guest $guest->visit(new RoomPage('john')) ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->assertButtonEnabled('@setup-button') ->assertSeeIn('@setup-button.btn-success', 'JOIN NOW') // try without the nickname ->clickWhenEnabled('@setup-button') ->waitFor('@setup-nickname-input.is-invalid') ->assertSeeIn( '@setup-status-message', "The room is locked. Please, enter your name and try again." ) ->assertMissing('@setup-password-input') ->assertButtonEnabled('@setup-button') ->assertSeeIn('@setup-button.btn-success', 'JOIN NOW') ->type('@setup-nickname-input', 'Guest

') ->clickWhenEnabled('@setup-button') ->assertMissing('@setup-nickname-input.is-invalid') ->waitForText("Waiting for permission to join the room.") ->assertButtonDisabled('@setup-button'); // Test denying the request (this will also test custom toasts) $owner ->whenAvailable(new Toast(Toast::TYPE_CUSTOM), function ($browser) { $browser->assertToastTitle('Join request') ->assertVisible('.toast-header svg.fa-user') ->assertSeeIn('@message', 'Guest

requested to join.') ->assertAttributeRegExp('@message img', 'src', '|^data:image|') ->assertSeeIn('@message button.accept.btn-success', 'Accept') ->assertSeeIn('@message button.deny.btn-danger', 'Deny') ->click('@message button.deny'); }) ->waitUntilMissing('.toast') // wait 10 seconds to make sure the request message does not show up again ->pause(10 * 1000) ->assertMissing('.toast'); }); } /** * Test locked room (accepting the join request, and dismissing a user) * * @group meet */ public function testLockedRoomAcceptAndDismiss(): void { $this->browse(function (Browser $owner, Browser $guest) { $room = Room::where('name', 'john')->first(); // Join the room as an owner (authenticate) $owner->visit(new RoomPage('john')) // ->click('@setup-button') // ->submitLogon('john@kolab.org', 'simple123') ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->type('@setup-nickname-input', 'John') ->clickWhenEnabled('@setup-button') ->waitFor('@session') // Enter room option dialog ->click('@menu button.link-options') ->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) { $browser->assertSeeIn('@title', 'Room options') ->assertSeeIn('#room-lock label', 'Locked room:') ->assertVisible('#room-lock input[type=checkbox]:not(:checked)') ->assertVisible('#room-lock + small') // Test setting the lock ->click('#room-lock input') ->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.") - ->click('@button-action'); + ->click('@button-cancel'); $this->assertSame('true', $room->fresh()->getSetting('locked')); }); // In another browser act as a guest $guest->visit(new RoomPage('john')) ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->type('@setup-nickname-input', 'guest') ->clickWhenEnabled('@setup-button') ->waitForText("Waiting for permission to join the room.") ->assertButtonDisabled('@setup-button'); $owner ->whenAvailable(new Toast(Toast::TYPE_CUSTOM), function ($browser) { $browser->assertToastTitle('Join request') ->assertSeeIn('@message', 'guest requested to join.') ->click('@message button.accept'); }); // Guest automatically anters the room $guest->waitFor('@session', 12) // make sure he has no access to the Options menu ->waitFor('@session .meet-video:not(.self)') ->assertSeeIn('@session .meet-video:not(.self) .meet-nickname', 'John') // TODO: Assert title and icon ->click('@session .meet-video:not(.self) .meet-nickname') ->pause(100) ->assertMissing('.dropdown-menu'); // Test dismissing the participant $owner->click('@session .meet-video:not(.self) .meet-nickname') ->waitFor('@session .meet-video:not(.self) .dropdown-menu') ->assertSeeIn('@session .meet-video:not(.self) .dropdown-menu > .action-dismiss', 'Dismiss') ->click('@session .meet-video:not(.self) .dropdown-menu > .action-dismiss') ->waitUntilMissing('.dropdown-menu') ->waitUntilMissing('@session .meet-video:not(.self)'); // Expect a "end of session" dialog on the participant side $guest->with(new Dialog('#leave-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Room closed') ->assertSeeIn('@body', "The session has been closed by the room owner.") - ->assertMissing('@button-cancel') - ->assertSeeIn('@button-action', 'Close'); + ->assertMissing('@button-action') + ->assertSeeIn('@button-cancel', 'Close'); }); }); } /** * Test nomedia (subscribers only) feature * * @group meet */ public function testSubscribersOnly(): void { $this->browse(function (Browser $owner, Browser $guest) { $room = Room::where('name', 'john')->first(); // Join the room as an owner (authenticate) $owner->visit(new RoomPage('john')) // ->click('@setup-button') // ->submitLogon('john@kolab.org', 'simple123') ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->type('@setup-nickname-input', 'John') ->clickWhenEnabled('@setup-button') ->waitFor('@session') // Enter room option dialog ->click('@menu button.link-options') ->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) { $browser->assertSeeIn('@title', 'Room options') ->assertSeeIn('#room-nomedia label', 'Subscribers only:') ->assertVisible('#room-nomedia input[type=checkbox]:not(:checked)') ->assertVisible('#room-nomedia + small') // Test enabling the option ->click('#room-nomedia input') ->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.") - ->click('@button-action'); + ->click('@button-cancel'); $this->assertSame('true', $room->fresh()->getSetting('nomedia')); }); // In another browser act as a guest $guest->visit(new RoomPage('john')) ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->type('@setup-nickname-input', 'John') ->clickWhenEnabled('@setup-button') // expect the owner to have a video, but the guest to have none ->waitFor('@session .meet-video') ->waitFor('@session .meet-subscriber.self'); // Unset the option back $owner->click('@menu button.link-options') ->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) { $browser->assertVisible('#room-nomedia input[type=checkbox]:checked') // Test enabling the option ->click('#room-nomedia input') ->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.") - ->click('@button-action'); + ->click('@button-cancel'); $this->assertSame(null, $room->fresh()->getSetting('nomedia')); }); }); } }