Merge pull request #34696 from nextcloud/fix/custom-color

Allow to remove the background and select a custom colour + cypress
pull/35484/head
Simon L 2022-11-29 12:59:56 +07:00 committed by GitHub
commit 7856820e5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 8725 additions and 1017 deletions

@ -1715,36 +1715,6 @@ trigger:
- pull_request
- push
---
kind: pipeline
name: acceptance-app-theming
steps:
- name: submodules
image: ghcr.io/nextcloud/continuous-integration-alpine-git:latest
commands:
- git submodule update --init
- name: acceptance-app-theming
image: ghcr.io/nextcloud/continuous-integration-acceptance-php7.4:latest
commands:
- tests/acceptance/run-local.sh --timeout-multiplier 10 --nextcloud-server-domain acceptance-app-theming --selenium-server selenium:4444 allow-git-repository-modifications features/app-theming.feature
services:
- name: selenium
image: ghcr.io/nextcloud/continuous-integration-selenium:3.141.59
environment:
# Reduce default log level for Selenium server (INFO) as it is too
# verbose.
JAVA_OPTS: -Dselenium.LOGGER.level=WARNING
trigger:
branch:
- master
- stable*
event:
- pull_request
- push
---
kind: pipeline
name: acceptance-header

@ -0,0 +1,98 @@
name: Cypress
on:
pull_request:
push:
branches:
- master
- stable*
env:
APP_NAME: viewer
BRANCH: ${{ github.base_ref }}
TESTING: true
jobs:
init:
runs-on: ubuntu-latest
steps:
- name: Checkout server
uses: actions/checkout@v3
- name: Read package.json node and npm engines version
uses: skjnldsv/read-package-engines-version-actions@v1.2
id: versions
with:
fallbackNode: "^12"
fallbackNpm: "^6"
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
uses: actions/setup-node@v3
with:
cache: 'npm'
node-version: ${{ steps.versions.outputs.nodeVersion }}
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
run: npm i -g npm@"${{ steps.versions.outputs.npmVersion }}"
- name: Install dependencies & build app
run: |
npm ci
TESTING=true npm run build --if-present
- name: Save context
uses: actions/cache@v3
with:
key: cypress-context-${{ github.run_id }}
path: /home/runner/work/server
cypress:
runs-on: ubuntu-latest
needs: init
strategy:
fail-fast: false
matrix:
# run multiple copies of the current job in parallel
containers: [1]
name: runner ${{ matrix.containers }}
steps:
- name: Restore context
uses: actions/cache@v3
with:
key: cypress-context-${{ github.run_id }}
path: /home/runner/work/server
- name: Run E2E cypress tests
uses: cypress-io/github-action@v4
with:
record: true
parallel: true
# cypress env
ci-build-id: ${{ github.sha }}-${{ github.run_number }}
tag: ${{ github.event_name }}
env:
# Needs to be prefixed with CYPRESS_
CYPRESS_BRANCH: ${{ env.BRANCH }}
CYPRESS_GH: true
# https://github.com/cypress-io/github-action/issues/124
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
# Needed for some specific code workarounds
TESTING: true
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
summary:
runs-on: ubuntu-latest
needs: [init, cypress]
if: always()
name: cypress-summary
steps:
- name: Summary status
run: if ${{ needs.init.result != 'success' || ( needs.cypress.result != 'success' && needs.cypress.result != 'skipped' ) }}; then exit 1; fi

4
.gitignore vendored

@ -163,3 +163,7 @@ composer.phar
./.htaccess
core/js/mimetypelist.js
# Tests - cypress
cypress/snapshots
cypress/videos

@ -88,6 +88,11 @@ return [
'url' => '/background/{type}',
'verb' => 'POST',
],
[
'name' => 'userTheme#deleteBackground',
'url' => '/background/custom',
'verb' => 'DELETE',
],
],
'ocs' => [
[

@ -54,9 +54,6 @@
--background-invert-if-dark: no;
--background-invert-if-bright: invert(100%);
--background-image-invert-if-bright: no;
--image-background: url('/core/img/app-background.jpg');
--image-background-default: url('/core/img/app-background.jpg');
--color-background-plain: #0082c9;
--primary-invert-if-bright: no;
--color-primary: #006aa3;
--color-primary-default: #0082c9;
@ -75,4 +72,6 @@
--color-primary-element-light-hover: #dbe5ea;
--color-primary-element-text-dark: #ededed;
--gradient-primary-background: linear-gradient(40deg, var(--color-primary) 0%, var(--color-primary-hover) 100%);
--image-background-default: url('/apps/theming/img/background/kamil-porembinski-clouds.jpg');
--color-background-plain: #0082c9;
}

@ -36,10 +36,6 @@ class UpdateConfig extends Command {
'name', 'url', 'imprintUrl', 'privacyUrl', 'slogan', 'color', 'disable-user-theming'
];
public const SUPPORTED_IMAGE_KEYS = [
'background', 'logo', 'favicon', 'logoheader'
];
private $themingDefaults;
private $imageManager;
private $config;
@ -87,14 +83,14 @@ class UpdateConfig extends Command {
$value = $this->config->getAppValue('theming', $key, '');
$output->writeln('- ' . $key . ': ' . $value . '');
}
foreach (self::SUPPORTED_IMAGE_KEYS as $key) {
foreach (ImageManager::SUPPORTED_IMAGE_KEYS as $key) {
$value = $this->config->getAppValue('theming', $key . 'Mime', '');
$output->writeln('- ' . $key . ': ' . $value . '');
}
return 0;
}
if (!in_array($key, self::SUPPORTED_KEYS, true) && !in_array($key, self::SUPPORTED_IMAGE_KEYS, true)) {
if (!in_array($key, self::SUPPORTED_KEYS, true) && !in_array($key, ImageManager::SUPPORTED_IMAGE_KEYS, true)) {
$output->writeln('<error>Invalid config key provided</error>');
return 1;
}
@ -115,7 +111,7 @@ class UpdateConfig extends Command {
return 0;
}
if (in_array($key, self::SUPPORTED_IMAGE_KEYS, true)) {
if (in_array($key, ImageManager::SUPPORTED_IMAGE_KEYS, true)) {
if (strpos($value, '/') !== 0) {
$output->writeln('<error>The image file needs to be provided as an absolute path: ' . $value . '.</error>');
return 1;

@ -155,25 +155,43 @@ class UserThemeController extends OCSController {
/**
* @NoAdminRequired
*/
public function setBackground(string $type = 'default', string $value = ''): JSONResponse {
public function deleteBackground(): JSONResponse {
$currentVersion = (int)$this->config->getUserValue($this->userId, Application::APP_ID, 'userCacheBuster', '0');
$this->backgroundService->deleteBackgroundImage();
return new JSONResponse([
'backgroundImage' => null,
'backgroundColor' => $this->themingDefaults->getColorPrimary(),
'version' => $currentVersion,
]);
}
/**
* @NoAdminRequired
*/
public function setBackground(string $type = BackgroundService::BACKGROUND_DEFAULT, string $value = '', string $color = null): JSONResponse {
$currentVersion = (int)$this->config->getUserValue($this->userId, Application::APP_ID, 'userCacheBuster', '0');
// Set color if provided
if ($color) {
$this->backgroundService->setColorBackground($color);
}
// Set background image if provided
try {
switch ($type) {
case 'shipped':
case BackgroundService::BACKGROUND_SHIPPED:
$this->backgroundService->setShippedBackground($value);
break;
case 'custom':
case BackgroundService::BACKGROUND_CUSTOM:
$this->backgroundService->setFileBackground($value);
break;
case 'color':
$this->backgroundService->setColorBackground($value);
break;
case 'default':
case BackgroundService::BACKGROUND_DEFAULT:
$this->backgroundService->setDefaultBackground();
break;
default:
return new JSONResponse(['error' => 'Invalid type provided'], Http::STATUS_BAD_REQUEST);
if (!$color) {
return new JSONResponse(['error' => 'Invalid type provided'], Http::STATUS_BAD_REQUEST);
}
}
} catch (\InvalidArgumentException $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
@ -185,8 +203,8 @@ class UserThemeController extends OCSController {
$this->config->setUserValue($this->userId, Application::APP_ID, 'userCacheBuster', (string)$currentVersion);
return new JSONResponse([
'type' => $type,
'value' => $value,
'backgroundImage' => $this->config->getUserValue($this->userId, Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT),
'backgroundColor' => $this->themingDefaults->getColorPrimary(),
'version' => $currentVersion,
]);
}

@ -33,6 +33,8 @@
*/
namespace OCA\Theming;
use OCA\Theming\AppInfo\Application;
use OCA\Theming\Service\BackgroundService;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
@ -45,7 +47,7 @@ use OCP\ITempManager;
use OCP\IURLGenerator;
class ImageManager {
public const SupportedImageKeys = ['background', 'logo', 'logoheader', 'favicon'];
public const SUPPORTED_IMAGE_KEYS = ['background', 'logo', 'logoheader', 'favicon'];
/** @var IConfig */
private $config;
@ -74,8 +76,14 @@ class ImageManager {
$this->appData = $appData;
}
public function getImageUrl(string $key, bool $useSvg = true): string {
$cacheBusterCounter = $this->config->getAppValue('theming', 'cachebuster', '0');
/**
* Get a globally defined image (admin theming settings)
*
* @param string $key the image key
* @return string the image url
*/
public function getImageUrl(string $key): string {
$cacheBusterCounter = $this->config->getAppValue(Application::APP_ID, 'cachebuster', '0');
if ($this->hasImage($key)) {
return $this->urlGenerator->linkToRoute('theming.Theming.getImage', [ 'key' => $key ]) . '?v=' . $cacheBusterCounter;
}
@ -86,13 +94,16 @@ class ImageManager {
case 'favicon':
return $this->urlGenerator->imagePath('core', 'logo/logo.png') . '?v=' . $cacheBusterCounter;
case 'background':
return $this->urlGenerator->imagePath('core', 'background.png') . '?v=' . $cacheBusterCounter;
return $this->urlGenerator->linkTo(Application::APP_ID, 'img/background/' . BackgroundService::DEFAULT_BACKGROUND_IMAGE);
}
return '';
}
public function getImageUrlAbsolute(string $key, bool $useSvg = true): string {
return $this->urlGenerator->getAbsoluteURL($this->getImageUrl($key, $useSvg));
/**
* Get the absolute url. See getImageUrl
*/
public function getImageUrlAbsolute(string $key): string {
return $this->urlGenerator->getAbsoluteURL($this->getImageUrl($key));
}
/**
@ -136,6 +147,20 @@ class ImageManager {
return $mimeSetting !== '';
}
/**
* @return array<string, array{mime: string, url: string}>
*/
public function getCustomImages(): array {
$images = [];
foreach (self::SUPPORTED_IMAGE_KEYS as $key) {
$images[$key] = [
'mime' => $this->config->getAppValue('theming', $key . 'Mime', ''),
'url' => $this->getImageUrl($key),
];
}
return $images;
}
/**
* Get folder for current theming files
*

@ -84,16 +84,32 @@ class BeforeTemplateRenderedListener implements IEventListener {
if (!empty($user)) {
$userId = $user->getUID();
/** User background */
$this->initialState->provideInitialState(
'background',
$this->config->getUserValue($userId, Application::APP_ID, 'background', 'default'),
'backgroundImage',
$this->config->getUserValue($userId, Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT),
);
/** User color */
$this->initialState->provideInitialState(
'backgroundColor',
$this->config->getUserValue($userId, Application::APP_ID, 'background_color', BackgroundService::DEFAULT_COLOR),
);
/**
* Admin background. `backgroundColor` if disabled,
* mime type if defined and empty by default
*/
$this->initialState->provideInitialState(
'themingDefaultBackground',
$this->config->getAppValue('theming', 'backgroundMime', ''),
);
$this->initialState->provideInitialState(
'defaultShippedBackground',
BackgroundService::DEFAULT_BACKGROUND_IMAGE,
);
/** List of all shipped backgrounds */
$this->initialState->provideInitialState(
'shippedBackgrounds',
BackgroundService::SHIPPED_BACKGROUNDS,

@ -30,7 +30,7 @@ namespace OCA\Theming\Service;
use InvalidArgumentException;
use OC\User\NoUserException;
use OCA\Theming\AppInfo\Application;
use OCP\Files\AppData\IAppDataFactory;
use OCA\Theming\ThemingDefaults;
use OCP\Files\File;
use OCP\Files\IAppData;
use OCP\Files\IRootFolder;
@ -48,6 +48,12 @@ class BackgroundService {
public const DEFAULT_COLOR = '#0082c9';
public const DEFAULT_ACCESSIBLE_COLOR = '#006aa3';
public const BACKGROUND_SHIPPED = 'shipped';
public const BACKGROUND_CUSTOM = 'custom';
public const BACKGROUND_DEFAULT = 'default';
public const BACKGROUND_DISABLED = 'disabled';
public const DEFAULT_BACKGROUND_IMAGE = 'kamil-porembinski-clouds.jpg';
public const SHIPPED_BACKGROUNDS = [
'anatoly-mikhaltsov-butterfly-wing-scale.jpg' => [
'attribution' => 'Butterfly wing scale (Anatoly Mikhaltsov, CC BY-SA)',
@ -134,13 +140,13 @@ class BackgroundService {
private IAppData $appData;
private IConfig $config;
private string $userId;
private IAppDataFactory $appDataFactory;
private ThemingDefaults $themingDefaults;
public function __construct(IRootFolder $rootFolder,
IAppData $appData,
IConfig $config,
?string $userId,
IAppDataFactory $appDataFactory) {
ThemingDefaults $themingDefaults) {
if ($userId === null) {
return;
}
@ -149,11 +155,12 @@ class BackgroundService {
$this->config = $config;
$this->userId = $userId;
$this->appData = $appData;
$this->appDataFactory = $appDataFactory;
$this->themingDefaults = $themingDefaults;
}
public function setDefaultBackground(): void {
$this->config->deleteUserValue($this->userId, Application::APP_ID, 'background');
$this->config->deleteUserValue($this->userId, Application::APP_ID, 'background_image');
$this->config->setUserValue($this->userId, Application::APP_ID, 'background_color', $this->themingDefaults->getDefaultColorPrimary());
}
/**
@ -165,7 +172,7 @@ class BackgroundService {
* @throws NoUserException
*/
public function setFileBackground($path): void {
$this->config->setUserValue($this->userId, Application::APP_ID, 'background', 'custom');
$this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_CUSTOM);
$userFolder = $this->rootFolder->getUserFolder($this->userId);
/** @var File $file */
@ -183,27 +190,28 @@ class BackgroundService {
if (!array_key_exists($fileName, self::SHIPPED_BACKGROUNDS)) {
throw new InvalidArgumentException('The given file name is invalid');
}
$this->config->setUserValue($this->userId, Application::APP_ID, 'background', $fileName);
$this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', $fileName);
$this->setColorBackground(self::SHIPPED_BACKGROUNDS[$fileName]['primary_color']);
}
public function setColorBackground(string $color): void {
if (!preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $color)) {
throw new InvalidArgumentException('The given color is invalid');
}
$this->config->setUserValue($this->userId, Application::APP_ID, 'background', $color);
$this->config->setUserValue($this->userId, Application::APP_ID, 'background_color', $color);
}
public function deleteBackgroundImage(): void {
$this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_DISABLED);
}
public function getBackground(): ?ISimpleFile {
$background = $this->config->getUserValue($this->userId, Application::APP_ID, 'background', 'default');
if ($background === 'custom') {
$background = $this->config->getUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_DEFAULT);
if ($background === self::BACKGROUND_CUSTOM) {
try {
return $this->getAppDataFolder()->getFile('background.jpg');
} catch (NotFoundException | NotPermittedException $e) {
try {
// Fallback can be removed in 26
$dashboardFolder = $this->appDataFactory->get('dashboard');
return $dashboardFolder->getFolder($this->userId)->getFile('background.jpg');
} catch (\Throwable $t) {}
return null;
}
}
return null;

@ -88,28 +88,29 @@ trait CommonThemeTrait {
$variables = [];
// Default last fallback values
$variables['--image-background-default'] = "url('" . $this->themingDefaults->getBackground() . "')";
$variables['--color-background-plain'] = $this->defaultPrimaryColor;
// If primary as background has been request or if we have a custom primary colour
// let's not define the background image
if ($backgroundDeleted) {
$variables['--color-background-plain'] = $this->themingDefaults->getColorPrimary();
if ($this->themingDefaults->isUserThemingDisabled() || $user === null) {
$variables['--image-background-plain'] = 'true';
}
$variables['--image-background-plain'] = 'yes';
}
// Register image variables only if custom-defined
foreach (ImageManager::SupportedImageKeys as $image) {
foreach (ImageManager::SUPPORTED_IMAGE_KEYS as $image) {
if ($this->imageManager->hasImage($image)) {
$imageUrl = $this->imageManager->getImageUrl($image);
if ($image === 'background') {
// If background deleted is set, ignoring variable
if ($backgroundDeleted) {
$variables['--image-background-default'] = 'no';
continue;
}
$variables['--image-background-size'] = 'cover';
$variables['--image-background-default'] = "url('" . $imageUrl . "')";
}
// --image-background is overridden by user theming
$variables["--image-$image"] = "url('" . $imageUrl . "')";
}
}
@ -129,32 +130,32 @@ trait CommonThemeTrait {
if ($user !== null
&& !$this->themingDefaults->isUserThemingDisabled()
&& $this->appManager->isEnabledForUser(Application::APP_ID)) {
$themingBackground = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background', 'default');
$backgroundImage = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT);
$currentVersion = (int)$this->config->getUserValue($user->getUID(), Application::APP_ID, 'userCacheBuster', '0');
// The user uploaded a custom background
if ($themingBackground === 'custom') {
$cacheBuster = substr(sha1($user->getUID() . '_' . $currentVersion), 0, 8);
// The user removed the background
if ($backgroundImage === BackgroundService::BACKGROUND_DISABLED) {
return [
'--image-background' => "url('" . $this->urlGenerator->linkToRouteAbsolute('theming.userTheme.getBackground') . "?v=$cacheBuster')",
// TODO: implement primary color from custom background --color-background-plain
'--image-background' => 'no',
'--color-background-plain' => $this->themingDefaults->getColorPrimary(),
];
}
// The user picked a shipped background
if (isset(BackgroundService::SHIPPED_BACKGROUNDS[$themingBackground])) {
// The user uploaded a custom background
if ($backgroundImage === BackgroundService::BACKGROUND_CUSTOM) {
$cacheBuster = substr(sha1($user->getUID() . '_' . $currentVersion), 0, 8);
return [
'--image-background' => "url('" . $this->urlGenerator->linkTo(Application::APP_ID, "/img/background/$themingBackground") . "')",
'--image-background' => "url('" . $this->urlGenerator->linkToRouteAbsolute('theming.userTheme.getBackground') . "?v=$cacheBuster')",
'--color-background-plain' => $this->themingDefaults->getColorPrimary(),
'--background-image-invert-if-bright' => BackgroundService::SHIPPED_BACKGROUNDS[$themingBackground]['theming'] ?? null === BackgroundService::THEMING_MODE_DARK ? 'invert(100%)' : 'no',
];
}
// The user picked a static colour
if (substr($themingBackground, 0, 1) === '#') {
// The user picked a shipped background
if (isset(BackgroundService::SHIPPED_BACKGROUNDS[$backgroundImage])) {
return [
'--image-background' => 'no',
'--image-background' => "url('" . $this->urlGenerator->linkTo(Application::APP_ID, "img/background/$backgroundImage") . "')",
'--color-background-plain' => $this->themingDefaults->getColorPrimary(),
'--background-image-invert-if-bright' => BackgroundService::SHIPPED_BACKGROUNDS[$backgroundImage]['theming'] ?? null === BackgroundService::THEMING_MODE_DARK ? 'invert(100%)' : 'no',
];
}
}

@ -190,11 +190,6 @@ class DefaultTheme implements ITheme {
'--background-invert-if-dark' => 'no',
'--background-invert-if-bright' => 'invert(100%)',
'--background-image-invert-if-bright' => 'no',
// Default last fallback values
'--image-background' => "url('" . $this->urlGenerator->imagePath('core', 'app-background.jpg') . "')",
'--image-background-default' => "url('" . $this->urlGenerator->imagePath('core', 'app-background.jpg') . "')",
'--color-background-plain' => $this->defaultPrimaryColor,
];
// Primary variables

@ -226,22 +226,11 @@ class ThemingDefaults extends \OC_Defaults {
}
// user-defined primary color
$themingBackground = '';
if (!empty($user)) {
$themingBackground = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background', '');
// If the user selected the default background
if ($themingBackground === '') {
return BackgroundService::DEFAULT_COLOR;
}
$themingBackgroundColor = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_color', '');
// If the user selected a specific colour
if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $themingBackground)) {
return $themingBackground;
}
// if the user-selected background is a background reference
if (isset(BackgroundService::SHIPPED_BACKGROUNDS[$themingBackground]['primary_color'])) {
return BackgroundService::SHIPPED_BACKGROUNDS[$themingBackground]['primary_color'];
if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $themingBackgroundColor)) {
return $themingBackgroundColor;
}
}
@ -258,7 +247,7 @@ class ThemingDefaults extends \OC_Defaults {
* Return the default color primary
*/
public function getDefaultColorPrimary(): string {
$color = $this->config->getAppValue(Application::APP_ID, 'color');
$color = $this->config->getAppValue(Application::APP_ID, 'color', '');
if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $color)) {
$color = '#0082c9';
}
@ -477,7 +466,7 @@ class ThemingDefaults extends \OC_Defaults {
$returnValue = $this->getSlogan();
break;
case 'color':
$returnValue = $this->getColorPrimary();
$returnValue = $this->getDefaultColorPrimary();
break;
case 'logo':
case 'logoheader':

@ -24,36 +24,46 @@
<section>
<NcSettingsSection :title="t('theming', 'Theming')"
:description="t('theming', 'Theming makes it possible to easily customize the look and feel of your instance and supported clients. This will be visible for all users.')"
:doc-url="docUrl">
:doc-url="docUrl"
data-admin-theming-settings>
<div class="admin-theming">
<NcNoteCard v-if="!isThemable"
type="error"
:show-alert="true">
<p>{{ notThemableErrorMessage }}</p>
</NcNoteCard>
<!-- Name, web link, slogan... fields -->
<TextField v-for="field in textFields"
:key="field.name"
:name="field.name"
:value.sync="field.value"
:data-admin-theming-setting-field="field.name"
:default-value="field.defaultValue"
:type="field.type"
:display-name="field.displayName"
:placeholder="field.placeholder"
:maxlength="field.maxlength"
:name="field.name"
:placeholder="field.placeholder"
:type="field.type"
:value.sync="field.value"
@update:theming="$emit('update:theming')" />
<!-- Primary color picker -->
<ColorPickerField :name="colorPickerField.name"
:value.sync="colorPickerField.value"
:default-value="colorPickerField.defaultValue"
:display-name="colorPickerField.displayName"
:value.sync="colorPickerField.value"
data-admin-theming-setting-primary-color
@update:theming="$emit('update:theming')" />
<!-- Default background picker -->
<FileInputField v-for="field in fileInputFields"
:key="field.name"
:name="field.name"
:mime-name="field.mimeName"
:mime-value.sync="field.mimeValue"
:aria-label="field.ariaLabel"
:default-mime-value="field.defaultMimeValue"
:display-name="field.displayName"
:aria-label="field.ariaLabel"
:mime-name="field.mimeName"
:mime-value.sync="field.mimeValue"
:name="field.name"
data-admin-theming-setting-background
@update:theming="$emit('update:theming')" />
<div class="admin-theming__preview">
<div class="admin-theming__preview-logo" />
@ -87,6 +97,7 @@
:display-name="userThemingField.displayName"
:label="userThemingField.label"
:description="userThemingField.description"
data-admin-theming-setting-disable-user-theming
@update:theming="$emit('update:theming')" />
<a v-if="!canThemeIcons"
:href="docUrlIcons"
@ -285,8 +296,15 @@ export default {
background-position: center;
text-align: center;
margin-top: 10px;
background-color: var(--color-primary-default);
background-image: var(--image-background-default, var(--image-background-plain, url('../../../core/img/app-background.jpg'), linear-gradient(40deg, #0082c9 0%, #30b6ff 100%)));
/* This is basically https://github.com/nextcloud/server/blob/master/core/css/guest.css
But without the user variables. That way the admin can preview the render as guest*/
/* As guest, there is no user color color-background-plain */
background-color: var(--color-primary-default, #0082c9);
/* As guest, there is no user background (--image-background)
1. Empty background if defined
2. Else default background
3. Finally default gradient (should not happened, the background is always defined anyway) */
background-image: var(--image-background-plain, var(--image-background-default, linear-gradient(40deg, #0082c9 0%, #30b6ff 100%)));
&-logo {
width: 20%;

@ -63,16 +63,14 @@
</NcSettingsSection>
<NcSettingsSection :title="t('theming', 'Background')"
class="background">
class="background"
data-user-theming-background-disabled>
<template v-if="isUserThemingDisabled">
<p>{{ t('theming', 'Customization has been disabled by your administrator') }}</p>
</template>
<template v-else>
<p>{{ t('theming', 'Set a custom background') }}</p>
<BackgroundSettings class="background__grid"
:background="background"
:theming-default-background="themingDefaultBackground"
@update:background="updateBackground" />
<BackgroundSettings class="background__grid" @update:background="refreshGlobalStyles" />
</template>
</NcSettingsSection>
</section>
@ -92,8 +90,6 @@ const availableThemes = loadState('theming', 'themes', [])
const enforceTheme = loadState('theming', 'enforceTheme', '')
const shortcutsDisabled = loadState('theming', 'shortcutsDisabled', false)
const background = loadState('theming', 'background')
const themingDefaultBackground = loadState('theming', 'themingDefaultBackground')
const isUserThemingDisabled = loadState('theming', 'isUserThemingDisabled')
console.debug('Available themes', availableThemes)
@ -111,10 +107,10 @@ export default {
data() {
return {
availableThemes,
// Admin defined configs
enforceTheme,
shortcutsDisabled,
background,
themingDefaultBackground,
isUserThemingDisabled,
}
},
@ -173,9 +169,21 @@ export default {
},
methods: {
// Refresh server-side generated theming CSS
refreshGlobalStyles() {
[...document.head.querySelectorAll('link.theme')].forEach(theme => {
const url = new URL(theme.href)
url.searchParams.set('v', Date.now())
const newTheme = theme.cloneNode()
newTheme.href = url.toString()
newTheme.onload = () => theme.remove()
document.head.append(newTheme)
})
},
updateBackground(data) {
this.background = (data.type === 'custom' || data.type === 'default') ? data.type : data.value
this.$emit('update:background')
this.refreshGlobalStyles()
},
changeTheme({ enabled, id }) {

@ -1,10 +1,10 @@
<!--
- @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
- @copyright Copyright (c) 2022 Greta Doci <gretadoci@gmail.com>
-
- @author Julius Härtl <jus@bitgrid.net>
- @author Greta Doci <gretadoci@gmail.com>
- @author Christopher Ng <chrng8@gmail.com>
- @author Greta Doci <gretadoci@gmail.com>
- @author John Molakvoæ <skjnldsv@protonmail.com>
- @author Julius Härtl <jus@bitgrid.net>
-
- @license GNU AGPL version 3 or any later version
-
@ -24,70 +24,93 @@
-->
<template>
<div class="background-selector">
<div class="background-selector" data-user-theming-background-settings>
<!-- Custom background -->
<button class="background filepicker"
:class="{ active: background === 'custom' }"
<button class="background background__filepicker"
:class="{ 'icon-loading': loading === 'custom', 'background--active': backgroundImage === 'custom' }"
:data-color-bright="invertTextColor(Theming.color)"
data-user-theming-background-custom
tabindex="0"
@click="pickFile">
{{ t('theming', 'Pick from Files') }}
{{ t('theming', 'Custom background') }}
<Check :size="44" />
</button>
<!-- Default background -->
<button class="background default"
<button class="background background__default"
:class="{ 'icon-loading': loading === 'default', 'background--active': backgroundImage === 'default' }"
:data-color-bright="invertTextColor(Theming.defaultColor)"
:style="{ '--border-color': Theming.defaultColor }"
data-user-theming-background-default
tabindex="0"
:class="{ 'icon-loading': loading === 'default', active: background === 'default' }"
@click="setDefault">
{{ t('theming', 'Default image') }}
{{ t('theming', 'Default background') }}
<Check :size="44" />
</button>
<!-- Custom color picker -->
<NcColorPicker v-model="Theming.color" @input="debouncePickColor">
<button class="background color"
:class="{ active: background === Theming.color}"
tabindex="0"
<button class="background background__color"
:data-color="Theming.color"
:data-color-bright="invertTextColor(Theming.color)"
:style="{ backgroundColor: Theming.color, color: invertTextColor(Theming.color) ? '#000000' : '#ffffff'}">
{{ t('theming', 'Custom color') }}
:style="{ backgroundColor: Theming.color, '--border-color': Theming.color}"
data-user-theming-background-color
tabindex="0">
{{ t('theming', 'Change color') }}
</button>
</NcColorPicker>
<!-- Default admin primary color -->
<button class="background color"
:class="{ active: background === Theming.defaultColor }"
tabindex="0"
:data-color="Theming.defaultColor"
:data-color-bright="invertTextColor(Theming.defaultColor)"
:style="{ color: invertTextColor(Theming.defaultColor) ? '#000000' : '#ffffff'}"
@click="debouncePickColor">
{{ t('theming', 'Plain background') }}
</button>
<!-- Background set selection -->
<button v-for="shippedBackground in shippedBackgrounds"
:key="shippedBackground.name"
v-tooltip="shippedBackground.details.attribution"
:class="{ 'icon-loading': loading === shippedBackground.name, active: background === shippedBackground.name }"
tabindex="0"
class="background"
:class="{ 'icon-loading': loading === shippedBackground.name, 'background--active': backgroundImage === shippedBackground.name }"
:data-color-bright="shippedBackground.details.theming === 'dark'"
:style="{ 'background-image': 'url(' + shippedBackground.preview + ')' }"
@click="setShipped(shippedBackground.name)" />
:data-user-theming-background-shipped="shippedBackground.name"
:style="{ backgroundImage: 'url(' + shippedBackground.preview + ')', '--border-color': shippedBackground.details.primary_color }"
class="background background__shipped"
tabindex="0"
@click="setShipped(shippedBackground.name)">
<Check :size="44" />
</button>
<!-- Remove background -->
<button class="background background__delete"
data-user-theming-background-clear
tabindex="0"
@click="removeBackground">
{{ t('theming', 'Remove background') }}
<Close :size="32" />
</button>
</div>
</template>
<script>
import { generateUrl } from '@nextcloud/router'
import { getBackgroundUrl } from '../helpers/getBackgroundUrl.js'
import { generateFilePath, generateRemoteUrl, generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import { prefixWithBaseUrl } from '../helpers/prefixWithBaseUrl.js'
import axios from '@nextcloud/axios'
import Check from 'vue-material-design-icons/Check.vue'
import Close from 'vue-material-design-icons/Close.vue'
import debounce from 'debounce'
import NcColorPicker from '@nextcloud/vue/dist/Components/NcColorPicker'
import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip'
import Vibrant from 'node-vibrant'
import { Palette } from 'node-vibrant/lib/color'
import { getFilePickerBuilder } from '@nextcloud/dialogs'
import { getCurrentUser } from '@nextcloud/auth'
const backgroundImage = loadState('theming', 'backgroundImage')
const shippedBackgroundList = loadState('theming', 'shippedBackgrounds')
const themingDefaultBackground = loadState('theming', 'themingDefaultBackground')
const defaultShippedBackground = loadState('theming', 'defaultShippedBackground')
const prefixWithBaseUrl = (url) => generateFilePath('theming', '', 'img/background/') + url
const picker = getFilePickerBuilder(t('theming', 'Select a background from your files'))
.setMultiSelect(false)
.setModal(true)
.setType(1)
.setMimeTypeFilter(['image/png', 'image/gif', 'image/jpeg', 'image/svg+xml', 'image/svg'])
.build()
export default {
name: 'BackgroundSettings',
@ -96,38 +119,48 @@ export default {
},
components: {
Check,
Close,
NcColorPicker,
},
props: {
background: {
type: String,
default: 'default',
},
themingDefaultBackground: {
type: String,
default: '',
},
},
data() {
return {
backgroundImage: generateUrl('/apps/theming/background') + '?v=' + Date.now(),
loading: false,
Theming: loadState('theming', 'data', {}),
// User background image and color settings
backgroundImage,
}
},
computed: {
shippedBackgrounds() {
return Object.keys(shippedBackgroundList).map(fileName => {
return {
name: fileName,
url: prefixWithBaseUrl(fileName),
preview: prefixWithBaseUrl('preview/' + fileName),
details: shippedBackgroundList[fileName],
}
})
return Object.keys(shippedBackgroundList)
.map(fileName => {
return {
name: fileName,
url: prefixWithBaseUrl(fileName),
preview: prefixWithBaseUrl('preview/' + fileName),
details: shippedBackgroundList[fileName],
}
})
.filter(background => {
// If the admin did not changed the global background
// let's hide the default background to not show it twice
if (!this.isGlobalBackgroundDeleted && !this.isGlobalBackgroundDefault) {
return background.name !== defaultShippedBackground
}
return true
})
},
isGlobalBackgroundDefault() {
return !!themingDefaultBackground
},
isGlobalBackgroundDeleted() {
return themingDefaultBackground === 'backgroundColor'
},
},
@ -163,20 +196,23 @@ export default {
: null
},
/**
* Update local state
*
* @param {object} data destructuring object
* @param {string} data.backgroundColor background color value
* @param {string} data.backgroundImage background image value
* @param {string} data.version cache buster number
* @see https://github.com/nextcloud/server/blob/c78bd45c64d9695724fc44fe8453a88824b85f2f/apps/theming/lib/Controller/UserThemeController.php#L187-L191
*/
async update(data) {
const background = data.type === 'custom' || data.type === 'default' ? data.type : data.value
this.backgroundImage = getBackgroundUrl(background, data.version, this.themingDefaultBackground)
if (data.type === 'color' || (data.type === 'default' && this.themingDefaultBackground === 'backgroundColor')) {
this.$emit('update:background', data)
this.loading = false
return
}
const image = new Image()
image.onload = () => {
this.$emit('update:background', data)
this.loading = false
}
image.src = this.backgroundImage
// Update state
this.backgroundImage = data.backgroundImage
this.Theming.color = data.backgroundColor
// Notify parent and reload style
this.$emit('update:background')
this.loading = false
},
async setDefault() {
@ -191,28 +227,70 @@ export default {
this.update(result.data)
},
async setFile(path) {
async setFile(path, color = null) {
this.loading = 'custom'
const result = await axios.post(generateUrl('/apps/theming/background/custom'), { value: path })
const result = await axios.post(generateUrl('/apps/theming/background/custom'), { value: path, color })
this.update(result.data)
},
async removeBackground() {
this.loading = 'remove'
const result = await axios.delete(generateUrl('/apps/theming/background/custom'))
this.update(result.data)
},
debouncePickColor: debounce(function() {
this.pickColor(...arguments)
}, 200),
async pickColor(event) {
this.loading = 'color'
const color = event?.target?.dataset?.color || this.Theming?.color || '#0082c9'
const result = await axios.post(generateUrl('/apps/theming/background/color'), { value: color })
const result = await axios.post(generateUrl('/apps/theming/background/color'), { color })
this.update(result.data)
},
debouncePickColor: debounce(function() {
this.pickColor(...arguments)
}, 200),
async pickFile() {
const path = await picker.pick()
this.loading = 'custom'
pickFile() {
window.OC.dialogs.filepicker(t('theming', 'Select a background from your files'), (path, type) => {
if (type === OC.dialogs.FILEPICKER_TYPE_CHOOSE) {
this.setFile(path)
}
}, false, ['image/png', 'image/gif', 'image/jpeg', 'image/svg'], true, OC.dialogs.FILEPICKER_TYPE_CHOOSE)
// Extract primary color from image
let response = null
let color = null
try {
const fileUrl = generateRemoteUrl('dav/files/' + getCurrentUser().uid + path)
response = await axios.get(fileUrl, { responseType: 'blob' })
const blobUrl = URL.createObjectURL(response.data)
const palette = await this.getColorPaletteFromBlob(blobUrl)
// DarkVibrant is accessible AND visually pleasing
// Vibrant is not accessible enough and others are boring
color = palette?.DarkVibrant?.hex
this.setFile(path, color)
// Log data
console.debug('Extracted colour', color, 'from custom image', path, palette)
} catch (error) {
this.setFile(path)
console.error('Unable to extract colour from custom image', { error, path, response, color })
}
},
/**
* Extract a Vibrant color palette from a blob URL
*
* @param {string} blobUrl the blob URL
* @return {Promise<Palette>}
*/
getColorPaletteFromBlob(blobUrl) {
return new Promise((resolve, reject) => {
const vibrant = new Vibrant(blobUrl)
vibrant.getPalette((error, palette) => {
if (error) {
reject(error)
}
resolve(palette)
})
})
},
},
}
@ -225,50 +303,70 @@ export default {
justify-content: center;
.background {
overflow: hidden;
width: 176px;
height: 96px;
margin: 8px;
background-size: cover;
background-position: center center;
text-align: center;
border-radius: var(--border-radius-large);
border: 2px solid var(--color-main-background);
overflow: hidden;
border-radius: var(--border-radius-large);
background-position: center center;
background-size: cover;
&__filepicker {
&.background--active {
color: white;
background-image: var(--image-background);
}
}
&.current {
background-image: var(--color-background-dark);
&__default {
background-color: var(--color-primary-default);
background-image: var(--image-background-default);
}
&.filepicker, &.default, &.color {
&__filepicker, &__default, &__color {
border-color: var(--color-border);
}
&.color {
background-color: var(--color-primary-default);
&__color {
color: var(--color-primary-text);
background-color: var(--color-primary-default);
}
&.active,
// Over a background image
&__default,
&__shipped {
color: white;
}
// Text and svg icon dark on bright background
&[data-color-bright] {
color: black;
}
&--active,
&:hover,
&:focus {
border: 2px solid var(--color-primary);
// Use theme color primary, see inline css variable in template
border: 2px solid var(--border-color, var(--color-primary)) !important;
}
&.active:not(.icon-loading) {
&:after {
background-image: var(--original-icon-checkmark-white);
background-repeat: no-repeat;
background-position: center;
background-size: 44px;
content: '';
display: block;
height: 100%;
}
// Icon
span {
margin: 4px;
}
&[data-color-bright]:after {
background-image: var(--original-icon-checkmark-dark);
}
&__filepicker span,
&__default span,
&__shipped span {
display: none;
}
&--active:not(.icon-loading) span {
display: block !important;
}
}
}
</style>

@ -30,13 +30,15 @@
<NcButton class="field__button"
type="primary"
:id="id"
:aria-label="t('theming', 'Select a custom color')">
:aria-label="t('theming', 'Select a custom color')"
data-admin-theming-setting-primary-color-picker>
{{ value }}
</NcButton>
</NcColorPicker>
<NcButton v-if="value !== defaultValue"
type="tertiary"
:aria-label="t('theming', 'Reset to default')"
data-admin-theming-setting-primary-color-reset
@click="undo">
<template #icon>
<Undo :size="20" />

@ -27,6 +27,7 @@
<NcButton type="secondary"
:id="id"
:aria-label="ariaLabel"
data-admin-theming-setting-background-picker
@click="activateLocalFilePicker">
<template #icon>
<Upload :size="20" />
@ -36,6 +37,7 @@
<NcButton v-if="showReset"
type="tertiary"
:aria-label="t('theming', 'Reset to default')"
data-admin-theming-setting-background-reset
@click="undo">
<template #icon>
<Undo :size="20" />
@ -44,6 +46,7 @@
<NcButton v-if="showRemove"
type="tertiary"
:aria-label="t('theming', 'Remove background image')"
data-admin-theming-setting-background-remove
@click="removeBackground">
<template #icon>
<Delete :size="20" />

@ -1,49 +0,0 @@
/**
* @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
*
* @author Avior <florian.bouillon@delta-wings.net>
* @author Julien Veyssier <eneiluj@posteo.net>
* @author Julius Härtl <jus@bitgrid.net>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { generateUrl } from '@nextcloud/router'
import { prefixWithBaseUrl } from './prefixWithBaseUrl.js'
export const getBackgroundUrl = (background, time = 0, themingDefaultBackground = '') => {
const enabledThemes = window.OCA?.Theming?.enabledThemes || []
const isDarkTheme = (enabledThemes.length === 0 || enabledThemes[0] === 'default')
? window.matchMedia('(prefers-color-scheme: dark)').matches
: enabledThemes.join('').indexOf('dark') !== -1
if (background === 'default') {
if (themingDefaultBackground && themingDefaultBackground !== 'backgroundColor') {
return generateUrl('/apps/theming/image/background') + '?v=' + window.OCA.Theming.cacheBuster
}
if (isDarkTheme) {
return prefixWithBaseUrl('eduardo-neves-pedra-azul.jpg')
}
return prefixWithBaseUrl('kamil-porembinski-clouds.jpg')
} else if (background === 'custom') {
return generateUrl('/apps/theming/background') + '?v=' + time
}
return prefixWithBaseUrl(background)
}

@ -22,8 +22,10 @@
*/
namespace OCA\Theming\Tests\Service;
use OCA\Theming\AppInfo\Application;
use OCA\Theming\ImageManager;
use OCA\Theming\ITheme;
use OCA\Theming\Service\BackgroundService;
use OCA\Theming\Themes\DefaultTheme;
use OCA\Theming\ThemingDefaults;
use OCA\Theming\Util;
@ -80,6 +82,11 @@ class DefaultThemeTest extends TestCase {
->method('getDefaultColorPrimary')
->willReturn('#0082c9');
$this->themingDefaults
->expects($this->any())
->method('getBackground')
->willReturn('/apps/' . Application::APP_ID . '/img/background/' . BackgroundService::DEFAULT_BACKGROUND_IMAGE);
$this->l10n
->expects($this->any())
->method('t')

@ -464,7 +464,7 @@ class ThemingDefaultsTest extends TestCase {
$this->config
->expects($this->once())
->method('getUserValue')
->with('user', 'theming', 'background')
->with('user', 'theming', 'background_color')
->willReturn('');
$this->assertEquals(BackgroundService::DEFAULT_COLOR, $this->template->getColorPrimary());
@ -473,6 +473,7 @@ class ThemingDefaultsTest extends TestCase {
public function testGetColorPrimaryWithCustomBackground() {
$backgroundIndex = 2;
$background = array_values(BackgroundService::SHIPPED_BACKGROUNDS)[$backgroundIndex];
$user = $this->createMock(IUser::class);
$this->userSession->expects($this->any())
->method('getUser')
@ -484,14 +485,15 @@ class ThemingDefaultsTest extends TestCase {
$this->config
->expects($this->once())
->method('getUserValue')
->with('user', 'theming', 'background', '')
->willReturn(array_keys(BackgroundService::SHIPPED_BACKGROUNDS)[$backgroundIndex]);
->with('user', 'theming', 'background_color', '')
->willReturn($background['primary_color']);
$this->config
->expects($this->exactly(2))
->method('getAppValue')
->willReturnMap([
['theming', 'disable-user-theming', 'no', 'no'],
['theming', 'color', '', ''],
['theming', 'disable-user-theming', 'no', 'no'],
]);
$this->assertEquals($background['primary_color'], $this->template->getColorPrimary());
@ -509,14 +511,14 @@ class ThemingDefaultsTest extends TestCase {
$this->config
->expects($this->once())
->method('getUserValue')
->with('user', 'theming', 'background', '')
->with('user', 'theming', 'background_color', '')
->willReturn('#fff');
$this->config
->expects($this->exactly(2))
->method('getAppValue')
->willReturnMap([
['theming', 'disable-user-theming', 'no', 'no'],
['theming', 'color', '', ''],
['theming', 'disable-user-theming', 'no', 'no'],
]);
$this->assertEquals('#fff', $this->template->getColorPrimary());
@ -534,14 +536,14 @@ class ThemingDefaultsTest extends TestCase {
$this->config
->expects($this->once())
->method('getUserValue')
->with('user', 'theming', 'background', '')
->with('user', 'theming', 'background_color', '')
->willReturn('nextcloud');
$this->config
->expects($this->exactly(3))
->method('getAppValue')
->willReturnMap([
['theming', 'disable-user-theming', 'no', 'no'],
['theming', 'color', '', ''],
['theming', 'disable-user-theming', 'no', 'no'],
]);
$this->assertEquals($this->template->getDefaultColorPrimary(), $this->template->getColorPrimary());
@ -650,16 +652,14 @@ class ThemingDefaultsTest extends TestCase {
->method('deleteAppValue')
->with('theming', 'color');
$this->config
->expects($this->exactly(3))
->expects($this->exactly(2))
->method('getAppValue')
->withConsecutive(
['theming', 'cachebuster', '0'],
['theming', 'color', null],
['theming', 'disable-user-theming', 'no'],
)->willReturnOnConsecutiveCalls(
'15',
$this->defaults->getColorPrimary(),
'no',
);
$this->config
->expects($this->once())
@ -778,10 +778,10 @@ class ThemingDefaultsTest extends TestCase {
$this->imageManager->expects($this->exactly(4))
->method('getImageUrl')
->willReturnMap([
['logo', true, 'custom-logo?v=0'],
['logoheader', true, 'custom-logoheader?v=0'],
['favicon', true, 'custom-favicon?v=0'],
['background', true, 'custom-background?v=0'],
['logo', 'custom-logo?v=0'],
['logoheader', 'custom-logoheader?v=0'],
['favicon', 'custom-favicon?v=0'],
['background', 'custom-background?v=0'],
]);
$expected = [

@ -64,6 +64,8 @@ $expectedFiles = [
'COPYING',
'core',
'cron.php',
'cypress.config.ts',
'cypress',
'dist',
'index.html',
'index.php',
@ -87,6 +89,7 @@ $expectedFiles = [
'status.php',
'tests',
'themes',
'tsconfig.json',
'vendor-bin',
'version.php',
'webpack.common.js',

@ -90,14 +90,11 @@ html {
height: 100%;
position: absolute;
background-color: var(--color-background-plain, var(--color-main-background));
background-image: var(--image-background);
background-size: cover;
background-position: center;
}
body {
background-color: var(--color-background-plain, var(--color-main-background));
background-image: var(--image-background-plain, var(--image-background));
background-image: var(--image-background, var(--image-background-default));
background-size: cover;
background-position: center;
position: fixed;

File diff suppressed because one or more lines are too long

@ -39,15 +39,15 @@ html {
width: 100%;
height: 100%;
position: absolute;
// color-background-plain should always be defined. It is the primary user colour
background-color: var(--color-background-plain, var(--color-main-background));
background-image: var(--image-background);
background-size: cover;
background-position: center;
}
body {
// color-background-plain should always be defined. It is the primary user colour
background-color: var(--color-background-plain, var(--color-main-background));
background-image: var(--image-background-plain, var(--image-background));
// color-background-plain should always be defined. It is the primary user colour
background-image: var(--image-background, var(--image-background-default));
background-size: cover;
background-position: center;
position: fixed;

@ -23,8 +23,14 @@ body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
color: var(--color-text);
text-align: center;
background-color: var(--color-primary-default, var(--color-primary));
background-image: var(--image-background-plain, var(--image-background, url('../../../core/img/app-background.jpg'), linear-gradient(40deg, #0082c9 0%, #30b6ff 100%)));
/* As guest, there is no color-background-plain */
background-color: var(--color-background-plain, var(--color-primary-default, #0082c9));
/* As guest, there is no user background (--image-background)
1. User background if logged in ('no' if removed, that way the variable is _defined_)
2. Empty background if enabled ('yes' is used, that way the variable is _defined_)
3. Else default background
4. Finally default gradient (should not happened, the background is always defined anyway) */
background-image: var(--image-background, var(--image-background-plain, var(--image-background-default, linear-gradient(40deg, #0082c9 0%, #30b6ff 100%))));
background-attachment: fixed;
min-height: 100%; /* fix sticky footer */
height: auto;

@ -2672,14 +2672,11 @@ html {
height: 100%;
position: absolute;
background-color: var(--color-background-plain, var(--color-main-background));
background-image: var(--image-background);
background-size: cover;
background-position: center;
}
body {
background-color: var(--color-background-plain, var(--color-main-background));
background-image: var(--image-background-plain, var(--image-background));
background-image: var(--image-background, var(--image-background-default));
background-size: cover;
background-position: center;
position: fixed;

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 76 KiB

@ -26,7 +26,7 @@
name="login"
:action="loginActionUrl"
@submit="submit">
<fieldset class="login-form__fieldset">
<fieldset class="login-form__fieldset" data-login-form>
<NcNoteCard v-if="apacheAuthFailed"
:title="t('core', 'Server side authentication failed!')"
type="warning">
@ -52,7 +52,7 @@
<!-- the following div ensures that the spinner is always inside the #message div -->
<div style="clear: both;" />
</div>
<h2 class="login-form__headline" v-html="headline" />
<h2 class="login-form__headline" data-login-form-headline v-html="headline" />
<NcTextField id="user"
ref="user"
:label="t('core', 'Account name or email')"
@ -64,6 +64,7 @@
:spellchecking="false"
:autocomplete="autoCompleteAllowed ? 'username' : 'off'"
required
data-login-form-input-user
@change="updateUsername" />
<NcPasswordField id="password"
@ -78,9 +79,10 @@
:label="t('core', 'Password')"
:helper-text="errorLabel"
:error="isError"
data-login-form-input-password
required />
<LoginButton :loading="loading" />
<LoginButton data-login-form-submit :loading="loading" />
<input v-if="redirectUrl"
type="hidden"

@ -0,0 +1,85 @@
/* eslint-disable node/no-unpublished-import */
import { applyChangesToNextcloud, configureNextcloud, preppingNextcloud, startNextcloud, stopNextcloud, waitOnNextcloud } from './cypress/dockerNode'
import { defineConfig } from 'cypress'
import browserify from '@cypress/browserify-preprocessor'
export default defineConfig({
projectId: '37xpdh',
// 16/9 screen ratio
viewportWidth: 1280,
viewportHeight: 720,
// Tries again 2 more times on failure
retries: {
runMode: 2,
// do not retry in `cypress open`
openMode: 0,
},
// Needed to trigger `after:run` events with cypress open
experimentalInteractiveRunEvents: true,
// faster video processing
videoCompression: false,
// Visual regression testing
env: {
failSilently: false,
type: 'actual',
},
screenshotsFolder: 'cypress/snapshots/actual',
trashAssetsBeforeRuns: true,
e2e: {
// Enable session management and disable isolation
experimentalSessionAndOrigin: true,
testIsolation: 'off',
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
async setupNodeEvents(on, config) {
// Fix browserslist extend https://github.com/cypress-io/cypress/issues/2983#issuecomment-570616682
on('file:preprocessor', browserify({ typescript: require.resolve('typescript') }))
// Disable spell checking to prevent rendering differences
on('before:browser:launch', (browser, launchOptions) => {
if (browser.family === 'chromium' && browser.name !== 'electron') {
launchOptions.preferences.default['browser.enable_spellchecking'] = false
return launchOptions
}
if (browser.family === 'firefox') {
launchOptions.preferences['layout.spellcheckDefault'] = 0
return launchOptions
}
if (browser.name === 'electron') {
launchOptions.preferences.spellcheck = false
return launchOptions
}
})
// Remove container after run
on('after:run', () => {
stopNextcloud()
})
// Before the browser launches
// starting Nextcloud testing container
return startNextcloud(process.env.BRANCH)
.then((ip) => {
// Setting container's IP as base Url
config.baseUrl = `http://${ip}/index.php`
return ip
})
.then(waitOnNextcloud)
.then(configureNextcloud)
.then(applyChangesToNextcloud)
.then(() => {
return config
})
},
},
})

@ -0,0 +1,243 @@
/**
* @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/* eslint-disable no-console */
/* eslint-disable node/no-unpublished-import */
import Docker from 'dockerode'
import waitOn from 'wait-on'
import tar from 'tar'
export const docker = new Docker()
const CONTAINER_NAME = 'nextcloud-cypress-tests-server'
const SERVER_IMAGE = 'ghcr.io/nextcloud/continuous-integration-shallow-server'
/**
* Start the testing container
*
* @param {string} branch the branch of your current work
*/
export const startNextcloud = async function(branch: string = 'master'): Promise<any> {
try {
// Pulling images
console.log('\nPulling images... ⏳')
await new Promise((resolve, reject): any => docker.pull(SERVER_IMAGE, (err, stream) => {
if (err) {
reject(err)
}
// https://github.com/apocas/dockerode/issues/357
docker.modem.followProgress(stream, onFinished)
function onFinished(err) {
if (!err) {
resolve(true)
return
}
reject(err)
}
}))
console.log('└─ Done')
// Remove old container if exists
console.log('\nChecking running containers... 🔍')
try {
const oldContainer = docker.getContainer(CONTAINER_NAME)
const oldContainerData = await oldContainer.inspect()
if (oldContainerData) {
console.log('├─ Existing running container found')
console.log('├─ Removing... ⏳')
// Forcing any remnants to be removed just in case
await oldContainer.remove({ force: true })
console.log('└─ Done')
}
} catch (error) {
console.log('└─ None found!')
}
// Starting container
console.log('\nStarting Nextcloud container... 🚀')
console.log(`├─ Using branch '${branch}'`)
const container = await docker.createContainer({
Image: SERVER_IMAGE,
name: CONTAINER_NAME,
HostConfig: {
Binds: [],
},
})
await container.start()
// Get container's IP
const ip = await getContainerIP(container)
console.log(`├─ Nextcloud container's IP is ${ip} 🌏`)
return ip
} catch (err) {
console.log('└─ Unable to start the container 🛑')
console.log(err)
stopNextcloud()
throw new Error('Unable to start the container')
}
}
/**
* Configure Nextcloud
*/
export const configureNextcloud = async function() {
console.log('\nConfiguring nextcloud...')
const container = docker.getContainer(CONTAINER_NAME)
await runExec(container, ['php', 'occ', '--version'], true)
// Be consistent for screenshots
await runExec(container, ['php', 'occ', 'config:system:set', 'default_language', '--value', 'en'], true)
await runExec(container, ['php', 'occ', 'config:system:set', 'force_language', '--value', 'en'], true)
await runExec(container, ['php', 'occ', 'config:system:set', 'default_locale', '--value', 'en_US'], true)
await runExec(container, ['php', 'occ', 'config:system:set', 'force_locale', '--value', 'en_US'], true)
await runExec(container, ['php', 'occ', 'config:system:set', 'enforce_theme', '--value', 'light'], true)
// Enable the app and give status
await runExec(container, ['php', 'occ', 'app:enable', '--force', 'viewer'], true)
// await runExec(container, ['php', 'occ', 'app:list'], true)
console.log('└─ Nextcloud is now ready to use 🎉')
}
/**
* Applying local changes to the container
* Only triggered if we're not in CI. Otherwise the
* continuous-integration-shallow-server image will
* already fetch the proper branch.
*/
export const applyChangesToNextcloud = async function() {
console.log('\nApply local changes to nextcloud...')
const container = docker.getContainer(CONTAINER_NAME)
const htmlPath = '/var/www/html'
const folderPaths = [
'./apps',
'./core',
'./dist',
'./lib',
'./ocs',
]
// Tar-streaming the above folder sinto the container
const serverTar = tar.c({ gzip: false }, folderPaths)
await container.putArchive(serverTar, {
path: htmlPath,
})
// Making sure we have the proper permissions
await runExec(container, ['chown', '-R', 'www-data:www-data', htmlPath], false, 'root')
console.log('└─ Changes applied successfully 🎉')
}
/**
* Force stop the testing container
*/
export const stopNextcloud = async function() {
try {
const container = docker.getContainer(CONTAINER_NAME)
console.log('Stopping Nextcloud container...')
container.remove({ force: true })
console.log('└─ Nextcloud container removed 🥀')
} catch (err) {
console.log(err)
}
}
/**
* Get the testing container's IP
*
* @param {Docker.Container} container the container to get the IP from
*/
export const getContainerIP = async function(
container = docker.getContainer(CONTAINER_NAME)
): Promise<string> {
let ip = ''
let tries = 0
while (ip === '' && tries < 10) {
tries++
await container.inspect(function(err, data) {
if (err) {
throw err
}
ip = data?.NetworkSettings?.IPAddress || ''
})
if (ip !== '') {
break
}
await sleep(1000 * tries)
}
return ip
}
// Would be simpler to start the container from cypress.config.ts,
// but when checking out different branches, it can take a few seconds
// Until we can properly configure the baseUrl retry intervals,
// We need to make sure the server is already running before cypress
// https://github.com/cypress-io/cypress/issues/22676
export const waitOnNextcloud = async function(ip: string) {
console.log('├─ Waiting for Nextcloud to be ready... ⏳')
await waitOn({ resources: [`http://${ip}/index.php`] })
console.log('└─ Done')
}
const runExec = async function(
container: Docker.Container,
command: string[],
verbose = false,
user = 'www-data'
) {
const exec = await container.exec({
Cmd: command,
AttachStdout: true,
AttachStderr: true,
User: user,
})
return new Promise((resolve, reject) => {
exec.start({}, (err, stream) => {
if (err) {
reject(err)
}
if (stream) {
stream.setEncoding('utf-8')
stream.on('data', str => {
if (verbose && str.trim() !== '') {
console.log(`├─ ${str.trim().replace(/\n/gi, '\n├─ ')}`)
}
})
stream.on('end', resolve)
}
})
})
}
const sleep = function(milliseconds: number) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}

@ -0,0 +1,37 @@
/**
* @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
describe('Login with a new user and open the files app', function() {
before(function() {
cy.createRandomUser().then((user) => {
cy.login(user)
})
})
after(function() {
cy.logout()
})
it('See the default file welcome.txt in the files list', function() {
cy.visit('/apps/files')
cy.get('.files-fileList tr').should('contain', 'welcome.txt')
})
})

@ -0,0 +1,302 @@
/**
* @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { User } from '@nextcloud/cypress'
const admin = new User('admin', 'admin')
const defaultPrimary = '#0082c9'
const defaultBackground = 'kamil-porembinski-clouds.jpg'
describe('Admin theming settings', function() {
before(function() {
cy.login(admin)
})
it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
cy.get('[data-admin-theming-settings]').scrollIntoView().should('be.visible')
})
it('See the default settings', function() {
cy.get('[data-admin-theming-setting-primary-color-picker]').should('contain.text', defaultPrimary)
cy.get('[data-admin-theming-setting-primary-color-reset]').should('not.exist')
cy.get('[data-admin-theming-setting-background-reset]').should('not.exist')
cy.get('[data-admin-theming-setting-background-remove]').should('be.visible')
})
})
describe('Change the primary colour', function() {
before(function() {
cy.login(admin)
})
it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
cy.get('[data-admin-theming-settings]').scrollIntoView().should('be.visible')
})
it('Change the primary colour', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')
cy.get('[data-admin-theming-setting-primary-color-picker]').click()
cy.get('.color-picker__simple-color-circle:eq(3)').click()
cy.wait('@setColor')
cy.waitUntil(() => cy.window().then((win) => {
const primary = getComputedStyle(win.document.body).getPropertyValue('--color-primary-default')
return primary !== defaultPrimary
}))
})
it('Screenshot the login page', function() {
cy.logout()
cy.visit('/')
cy.screenshot()
})
it('Login again and go to the admin theming section', function() {
cy.login(admin)
cy.visit('/settings/admin/theming')
})
it('Reset the primary colour', function() {
cy.intercept('*/apps/theming/ajax/undoChanges').as('undoChanges')
cy.get('[data-admin-theming-setting-primary-color-reset]').click()
cy.wait('@undoChanges')
cy.waitUntil(() => cy.window().then((win) => {
const primary = getComputedStyle(win.document.body).getPropertyValue('--color-primary-default')
return primary === defaultPrimary
}))
})
it('Screenshot the login page', function() {
cy.logout()
cy.visit('/')
cy.screenshot()
})
})
describe('Remove the default background', function() {
before(function() {
cy.login(admin)
})
it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
cy.get('[data-admin-theming-settings]').scrollIntoView().should('be.visible')
})
it('Remove the default background', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground')
cy.get('[data-admin-theming-setting-background-remove]').click()
cy.wait('@removeBackground')
cy.waitUntil(() => cy.window().then((win) => {
const backgroundDefault = getComputedStyle(win.document.body).getPropertyValue('--image-background-default')
const backgroundPlain = getComputedStyle(win.document.body).getPropertyValue('--image-background-plain')
return !backgroundDefault.includes(defaultBackground)
&& backgroundPlain !== ''
}))
})
it('Screenshot the login page', function() {
cy.logout()
cy.visit('/')
cy.screenshot()
})
it('Login again and go to the admin theming section', function() {
cy.login(admin)
cy.visit('/settings/admin/theming')
})
it('Restore the default background', function() {
cy.intercept('*/apps/theming/ajax/undoChanges').as('undoChanges')
cy.get('[data-admin-theming-setting-background-reset]').click()
cy.wait('@undoChanges')
cy.waitUntil(() => cy.window().then((win) => {
const backgroundDefault = getComputedStyle(win.document.body).getPropertyValue('--image-background-default')
const backgroundPlain = getComputedStyle(win.document.body).getPropertyValue('--image-background-plain')
return backgroundDefault.includes(defaultBackground)
&& backgroundPlain === ''
}))
})
it('Screenshot the login page', function() {
cy.logout()
cy.visit('/')
cy.screenshot()
})
})
describe('Change the login fields', function() {
const name = 'ABCdef123'
const url = 'https://example.com'
const slogan = 'Testing is fun'
before(function() {
cy.login(admin)
})
it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
cy.get('[data-admin-theming-settings]').scrollIntoView().should('be.visible')
})
it('Change the name field', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('updateFields')
// Name
cy.get('[data-admin-theming-setting-field="name"] input[type="text"]')
.scrollIntoView()
.type('{selectall}')
.type(name)
.type('{enter}')
cy.wait('@updateFields')
// Url
cy.get('[data-admin-theming-setting-field="url"] input[type="url"]')
.scrollIntoView()
.type('{selectall}')
.type(url)
.type('{enter}')
cy.wait('@updateFields')
// Slogan
cy.get('[data-admin-theming-setting-field="slogan"] input[type="text"]')
.scrollIntoView()
.type('{selectall}')
.type(slogan)
.type('{enter}')
cy.wait('@updateFields')
})
it('Ensure undo button presence', function() {
cy.get('[data-admin-theming-setting-field="name"] .input-field__clear-button')
.scrollIntoView().should('be.visible')
cy.get('[data-admin-theming-setting-field="url"] .input-field__clear-button')
.scrollIntoView().should('be.visible')
cy.get('[data-admin-theming-setting-field="slogan"] .input-field__clear-button')
.scrollIntoView().should('be.visible')
})
it('Check login screen changes', function() {
cy.logout()
cy.visit('/')
cy.get('[data-login-form-headline]').should('contain.text', name)
cy.get('footer p a').should('have.text', name)
cy.get('footer p a').should('have.attr', 'href', url)
cy.get('footer p').should('contain.text', ` ${slogan}`)
})
it('Login again and go to the admin theming section', function() {
cy.login(admin)
cy.visit('/settings/admin/theming')
})
it('Undo changes', function() {
cy.intercept('*/apps/theming/ajax/undoChanges').as('undoChanges')
cy.get('[data-admin-theming-setting-field="name"] .input-field__clear-button')
.scrollIntoView().click()
cy.wait('@undoChanges')
cy.get('[data-admin-theming-setting-field="url"] .input-field__clear-button')
.scrollIntoView().click()
cy.wait('@undoChanges')
cy.get('[data-admin-theming-setting-field="slogan"] .input-field__clear-button')
.scrollIntoView().click()
cy.wait('@undoChanges')
})
it('Check login screen changes', function() {
cy.logout()
cy.visit('/')
cy.get('[data-login-form-headline]').should('not.contain.text', name)
cy.get('footer p a').should('not.have.text', name)
cy.get('footer p a').should('not.have.attr', 'href', url)
cy.get('footer p').should('not.contain.text', ` ${slogan}`)
})
})
describe('Disable user theming', function() {
before(function() {
cy.login(admin)
})
it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
cy.get('[data-admin-theming-settings]').scrollIntoView().should('be.visible')
})
it('Disable user theming', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('disableUserTheming')
cy.get('[data-admin-theming-setting-disable-user-theming]')
.scrollIntoView().should('be.visible')
cy.get('[data-admin-theming-setting-disable-user-theming] input[type="checkbox"]').check({ force: true })
cy.get('[data-admin-theming-setting-disable-user-theming] input[type="checkbox"]').should('be.checked')
cy.wait('@disableUserTheming')
})
it('Login as user', function() {
cy.logout()
cy.createRandomUser().then((user) => {
cy.login(user)
})
})
it('See the user disabled background settings', function() {
cy.visit('/settings/user/theming')
cy.get('[data-user-theming-background-disabled]').scrollIntoView().should('be.visible')
})
it('Login back as admin', function() {
cy.logout()
cy.login(admin)
})
it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
cy.get('[data-admin-theming-settings]').scrollIntoView().should('be.visible')
})
it('Enable back user theming', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('enableUserTheming')
cy.get('[data-admin-theming-setting-disable-user-theming]')
.scrollIntoView().should('be.visible')
cy.get('[data-admin-theming-setting-disable-user-theming] input[type="checkbox"]').uncheck({ force: true })
cy.get('[data-admin-theming-setting-disable-user-theming] input[type="checkbox"]').should('not.be.checked')
cy.wait('@enableUserTheming')
})
})

@ -0,0 +1,216 @@
/**
* @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type { User } from '@nextcloud/cypress'
const defaultPrimary = '#006aa3'
const defaultBackground = 'kamil-porembinski-clouds.jpg'
const validateThemingCss = function(expectedPrimary = '#0082c9', expectedBackground = 'kamil-porembinski-clouds.jpg', bright = false) {
return cy.window().then((win) => {
const primary = getComputedStyle(win.document.body).getPropertyValue('--color-primary')
const background = getComputedStyle(win.document.body).getPropertyValue('--image-background')
const invertIfBright = getComputedStyle(win.document.body).getPropertyValue('--background-image-invert-if-bright')
// Returning boolean for cy.waitUntil usage
return primary === expectedPrimary
&& background.includes(expectedBackground)
&& invertIfBright === (bright ? 'invert(100%)' : 'no')
})
}
describe('User default background settings', function() {
before(function() {
cy.createRandomUser().then((user: User) => {
cy.login(user)
})
})
it('See the user background settings', function() {
cy.visit('/settings/user/theming')
cy.get('[data-user-theming-background-settings]').scrollIntoView().should('be.visible')
})
// Default cloud background is not rendered if admin theming background remains unchanged
it('Default cloud background is not rendered', function() {
cy.get(`[data-user-theming-background-shipped="${defaultBackground}"]`).should('not.exist')
})
it('Default is selected on new users', function() {
cy.get('[data-user-theming-background-default]').should('be.visible')
cy.get('[data-user-theming-background-default]').should('have.class', 'background--active')
})
})
describe('User select shipped backgrounds', function() {
before(function() {
cy.createRandomUser().then((user: User) => {
cy.login(user)
})
})
it('See the user background settings', function() {
cy.visit('/settings/user/theming')
cy.get('[data-user-theming-background-settings]').scrollIntoView().should('be.visible')
})
it('Select a shipped background', function() {
const background = 'anatoly-mikhaltsov-butterfly-wing-scale.jpg'
cy.intercept('*/apps/theming/background/shipped').as('setBackground')
// Select background
cy.get(`[data-user-theming-background-shipped="${background}"]`).click()
// Validate changed background and primary
cy.wait('@setBackground')
cy.waitUntil(() => validateThemingCss('#a53c17', background))
})
it('Select a bright shipped background', function() {
const background = 'bernie-cetonia-aurata-take-off-composition.jpg'
cy.intercept('*/apps/theming/background/shipped').as('setBackground')
// Select background
cy.get(`[data-user-theming-background-shipped="${background}"]`).click()
// Validate changed background and primary
cy.wait('@setBackground')
cy.waitUntil(() => validateThemingCss('#56633d', background, true))
})
it('Remove background', function() {
cy.intercept('*/apps/theming/background/custom').as('clearBackground')
// Clear background
cy.get('[data-user-theming-background-clear]').click()
// Validate clear background
cy.wait('@clearBackground')
cy.waitUntil(() => validateThemingCss('#56633d', ''))
})
})
describe('User select a custom color', function() {
before(function() {
cy.createRandomUser().then((user: User) => {
cy.login(user)
})
})
it('See the user background settings', function() {
cy.visit('/settings/user/theming')
cy.get('[data-user-theming-background-settings]').scrollIntoView().should('be.visible')
})
it('Select a custom color', function() {
cy.intercept('*/apps/theming/background/color').as('setColor')
cy.get('[data-user-theming-background-color]').click()
cy.get('.color-picker__simple-color-circle:eq(3)').click()
// Validate clear background
cy.wait('@setColor')
cy.waitUntil(() => cy.window().then((win) => {
const primary = getComputedStyle(win.document.body).getPropertyValue('--color-primary')
return primary !== defaultPrimary
}))
})
})
describe('User select a custom background', function() {
const image = 'image.jpg'
before(function() {
cy.createRandomUser().then((user: User) => {
cy.uploadFile(user, image, 'image/jpeg')
cy.login(user)
})
})
it('See the user background settings', function() {
cy.visit('/settings/user/theming')
cy.get('[data-user-theming-background-settings]').scrollIntoView().should('be.visible')
})
it('Select a custom background', function() {
cy.intercept('*/apps/theming/background/custom').as('setBackground')
// Pick background
cy.get('[data-user-theming-background-custom]').click()
cy.get(`#picker-filestable tr[data-entryname="${image}"]`).click()
cy.get('#oc-dialog-filepicker-content ~ .oc-dialog-buttonrow button.primary').click()
// Wait for background to be set
cy.wait('@setBackground')
cy.waitUntil(() => validateThemingCss('#4c0c04', 'apps/theming/background?v='))
})
})
describe('User changes settings and reload the page', function() {
const image = 'image.jpg'
const primaryFromImage = '#4c0c04'
let selectedColor = ''
before(function() {
cy.createRandomUser().then((user: User) => {
cy.uploadFile(user, image, 'image/jpeg')
cy.login(user)
})
})
it('See the user background settings', function() {
cy.visit('/settings/user/theming')
cy.get('[data-user-theming-background-settings]').scrollIntoView().should('be.visible')
})
it('Select a custom background', function() {
cy.intercept('*/apps/theming/background/custom').as('setBackground')
// Pick background
cy.get('[data-user-theming-background-custom]').click()
cy.get(`#picker-filestable tr[data-entryname="${image}"]`).click()
cy.get('#oc-dialog-filepicker-content ~ .oc-dialog-buttonrow button.primary').click()
// Wait for background to be set
cy.wait('@setBackground')
cy.waitUntil(() => validateThemingCss(primaryFromImage, 'apps/theming/background?v='))
})
it('Select a custom color', function() {
cy.intercept('*/apps/theming/background/color').as('setColor')
cy.get('[data-user-theming-background-color]').click()
cy.get('.color-picker__simple-color-circle:eq(5)').click()
// Validate clear background
cy.wait('@setColor')
cy.waitUntil(() => cy.window().then((win) => {
selectedColor = getComputedStyle(win.document.body).getPropertyValue('--color-primary')
return selectedColor !== primaryFromImage
}))
})
it('Reload the page and validate persistent changes', function() {
cy.reload()
cy.waitUntil(() => validateThemingCss(selectedColor, 'apps/theming/background?v='))
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

@ -0,0 +1,86 @@
/**
* @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/* eslint-disable node/no-unpublished-import */
import axios from '@nextcloud/axios'
import { addCommands, type User} from '@nextcloud/cypress'
import { basename } from 'path'
// Add custom commands
import 'cypress-wait-until'
addCommands()
// Register this file's custom commands types
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable<Subject = any> {
uploadFile(user: User, fixture: string, mimeType: string, target ?: string): Cypress.Chainable<void>
}
}
}
const url = (Cypress.config('baseUrl') || '').replace(/\/index.php\/?$/g, '')
Cypress.env('baseUrl', url)
/**
* cy.uploadedFile - uploads a file from the fixtures folder
* TODO: standardise in @nextcloud/cypress
*
* @param {User} user the owner of the file, e.g. admin
* @param {string} fixture the fixture file name, e.g. image1.jpg
* @param {string} mimeType e.g. image/png
* @param {string} [target] the target of the file relative to the user root
*/
Cypress.Commands.add('uploadFile', (user, fixture, mimeType, target = `/${fixture}`) => {
cy.clearCookies()
const fileName = basename(target)
// get fixture
return cy.fixture(fixture, 'base64').then(async file => {
// convert the base64 string to a blob
const blob = Cypress.Blob.base64StringToBlob(file, mimeType)
// Process paths
const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}`
const filePath = target.split('/').map(encodeURIComponent).join('/')
try {
const file = new File([blob], fileName, { type: mimeType })
await axios({
url: `${rootPath}${filePath}`,
method: 'PUT',
data: file,
headers: {
'Content-Type': mimeType,
},
auth: {
username: user.userId,
password: user.password,
},
}).then(response => {
cy.log(`Uploaded ${fixture} as ${fileName}`, response)
})
} catch (error) {
cy.log('error', error)
throw new Error(`Unable to process fixture ${fixture}`)
}
})
})

@ -1,7 +1,7 @@
/**
* @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
* @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author Julius Härtl <jus@bitgrid.net>
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
@ -12,14 +12,11 @@
*
* 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
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { generateFilePath } from '@nextcloud/router'
export const prefixWithBaseUrl = (url) => generateFilePath('theming', '', 'img/background/') + url
import './commands'

@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"include": ["./**/*.ts"],
"compilerOptions": {
"types": ["cypress", "dockerode", "cypress-wait-until"],
}
}

File diff suppressed because one or more lines are too long

@ -400,6 +400,8 @@
/*! https://mths.be/he v1.2.0 by @mathias | MIT license */
/*! https://mths.be/punycode v1.3.2 by @mathias */
/*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh <https://feross.org/opensource> */
/*! jQuery Migrate v3.4.0 | (c) OpenJS Foundation and other contributors | jquery.org/license */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

7789
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -18,7 +18,10 @@
"test:jsunit": "karma start tests/karma.config.js --single-run",
"sass": "sass --load-path core/css core/css/ apps/*/css",
"sass:watch": "sass --watch --load-path core/css core/css/ apps/*/css",
"sass:icons": "babel-node core/src/icons.js"
"sass:icons": "babel-node core/src/icons.js",
"cypress": "npm run cypress:e2e",
"cypress:e2e": "cypress run --e2e",
"cypress:gui": "cypress open --e2e"
},
"repository": {
"type": "git",
@ -78,6 +81,7 @@
"moment": "^2.29.4",
"moment-timezone": "^0.5.38",
"nextcloud-vue-collections": "^0.10.0",
"node-vibrant": "^3.1.6",
"p-limit": "^4.0.0",
"p-queue": "^7.3.0",
"path": "^0.12.7",
@ -107,18 +111,28 @@
},
"devDependencies": {
"@babel/node": "^7.20.0",
"@cypress/browserify-preprocessor": "^3.0.2",
"@nextcloud/babel-config": "^1.0.0",
"@nextcloud/cypress": "^1.0.0-beta.1",
"@nextcloud/eslint-config": "^8.0.0",
"@nextcloud/stylelint-config": "^2.1.2",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/user-event": "^14.4.3",
"@testing-library/vue": "^5.8.3",
"@types/dockerode": "^3.3.14",
"@typescript-eslint/eslint-plugin": "^5.44.0",
"@typescript-eslint/parser": "^5.44.0",
"@vue/test-utils": "^1.3.0",
"@vue/tsconfig": "^0.1.3",
"@vue/vue2-jest": "^29.1.1",
"babel-jest": "^29.0.3",
"babel-loader": "^8.2.5",
"babel-loader-exclude-node-modules-except": "^1.2.1",
"css-loader": "^6.7.1",
"cypress": "^11.2.0",
"cypress-wait-until": "^1.7.2",
"dockerode": "^3.3.4",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-es": "^4.1.0",
"exports-loader": "^4.0.0",
"file-loader": "^6.2.0",
@ -143,8 +157,12 @@
"sass-loader": "^12.6.0",
"sinon": "<= 5.0.7",
"style-loader": "^3.3.1",
"ts-node": "^10.9.1",
"tslib": "^2.4.1",
"typescript": "^4.9.3",
"vue-loader": "^15.9.8",
"vue-template-compiler": "^2.7.13",
"wait-on": "^6.0.1",
"webpack": "^5.75.0",
"webpack-cli": "^4.9.2",
"webpack-merge": "^5.8.0"

@ -1,34 +0,0 @@
@apache
Feature: app-theming
# FIXME test with cypress
# The existing DOM testing framework used here is not fully suitable for testing UIs implemented with modern frontend frameworks like Vue
Scenario: changing the color updates the primary color
Given I am logged in as the admin
And I visit the admin settings page
And I open the "Theming" section
# And I see that the color selector in the Theming app has loaded
# The "eventually" part is not really needed here, as the colour is not
# being animated at this point, but there is no need to create a specific
# step just for this.
# And I see that the primary color is eventually "#00639a"
# And I see that the non-plain background color variable is eventually "#0082c9"
# When I set the "Color" parameter in the Theming app to "#C9C9C9"
# Then I see that the parameters in the Theming app are eventually saved
# And I see that the primary color is eventually "#00639a"
# And I see that the non-plain background color variable is eventually "#C9C9C9"
Scenario: resetting the color updates the primary color
Given I am logged in as the admin
And I visit the admin settings page
And I open the "Theming" section
# And I see that the color selector in the Theming app has loaded
# And I set the "Color" parameter in the Theming app to "#C9C9C9"
# And I see that the parameters in the Theming app are eventually saved
# And I see that the primary color is eventually "#00639a"
# And I see that the non-plain background color variable is eventually "#C9C9C9"
# When I reset the "Color" parameter in the Theming app to its default value
# Then I see that the parameters in the Theming app are eventually saved
# And I see that the primary color is eventually "#00639a"
# And I see that the non-plain background color variable is eventually "#0082c9"

@ -0,0 +1,22 @@
{
"extends": "@vue/tsconfig/tsconfig.json",
"include": ["./**/*.ts"],
"compilerOptions": {
"types": ["node"],
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"target": "ESNext",
"module": "esnext",
"declaration": true,
"strict": true,
"noImplicitAny": false,
"resolveJsonModule": true
},
"ts-node": {
// these options are overrides used only by ts-node
// same as our --compilerOptions flag and our TS_NODE_COMPILER_OPTIONS environment variable
"compilerOptions": {
"module": "commonjs"
}
}
}

@ -166,8 +166,9 @@ module.exports = {
extensions: ['*', '.js', '.vue'],
symlinks: true,
fallback: {
stream: require.resolve('stream-browserify'),
buffer: require.resolve('buffer'),
fs: false,
stream: require.resolve('stream-browserify'),
},
},
}