19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:58 +01:00
parent 20e6dadd87
commit 4b94f0abc5
205 changed files with 24700 additions and 14614 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 336 B

Before After
Before After

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 367 B

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -1,282 +0,0 @@
/** @odoo-module **/
import { patch } from 'web.utils';
import { KeepLast } from "@web/core/utils/concurrency";
import { MediaDialog, TABS } from '@web_editor/components/media_dialog/media_dialog';
import { ImageSelector } from '@web_editor/components/media_dialog/image_selector';
import { useService } from '@web/core/utils/hooks';
import { uploadService, AUTOCLOSE_DELAY } from '@web_editor/components/upload_progress_toast/upload_service';
import { useState, Component } from "@odoo/owl";
class UnsplashCredentials extends Component {
setup() {
this.state = useState({
key: '',
appId: '',
hasKeyError: this.props.hasCredentialsError,
hasAppIdError: this.props.hasCredentialsError,
});
}
submitCredentials() {
if (this.state.key === '') {
this.state.hasKeyError = true;
} else if (this.state.appId === '') {
this.state.hasAppIdError = true;
} else {
this.props.submitCredentials(this.state.key, this.state.appId);
}
}
}
UnsplashCredentials.template = 'web_unsplash.UnsplashCredentials';
export class UnsplashError extends Component {}
UnsplashError.template = 'web_unsplash.UnsplashError';
UnsplashError.components = {
UnsplashCredentials,
};
patch(ImageSelector.prototype, 'image_selector_unsplash', {
setup() {
this._super();
this.unsplash = useService('unsplash');
this.keepLastUnsplash = new KeepLast();
this.state.unsplashRecords = [];
this.state.isFetchingUnsplash = false;
this.state.isMaxed = false;
this.state.unsplashError = null;
this.state.useUnsplash = true;
this.NUMBER_OF_RECORDS_TO_DISPLAY = 30;
this.errorMessages = {
'key_not_found': {
title: this.env._t("Setup Unsplash to access royalty free photos."),
subtitle: "",
},
401: {
title: this.env._t("Unauthorized Key"),
subtitle: this.env._t("Please check your Unsplash access key and application ID."),
},
403: {
title: this.env._t("Search is temporarily unavailable"),
subtitle: this.env._t("The max number of searches is exceeded. Please retry in an hour or extend to a better account."),
},
};
},
get canLoadMore() {
if (this.state.searchService === 'all') {
return this._super() || this.state.needle && !this.state.isMaxed && !this.state.unsplashError;
} else if (this.state.searchService === 'unsplash') {
return this.state.needle && !this.state.isMaxed && !this.state.unsplashError;
}
return this._super();
},
get hasContent() {
if (this.state.searchService === 'all') {
return this._super() || !!this.state.unsplashRecords.length;
} else if (this.state.searchService === 'unsplash') {
return !!this.state.unsplashRecords.length;
}
return this._super();
},
get errorTitle() {
if (this.errorMessages[this.state.unsplashError]) {
return this.errorMessages[this.state.unsplashError].title;
}
return this.env._t("Something went wrong");
},
get errorSubtitle() {
if (this.errorMessages[this.state.unsplashError]) {
return this.errorMessages[this.state.unsplashError].subtitle;
}
return this.env._t("Please check your internet connection or contact administrator.");
},
get selectedRecordIds() {
return this.props.selectedMedia[this.props.id].filter(media => media.mediaType === 'unsplashRecord').map(({ id }) => id);
},
get isFetching() {
return this._super() || this.state.isFetchingUnsplash;
},
// It seems that setters are mandatory when patching a component that
// extends another component.
set canLoadMore(_) {},
set hasContent(_) {},
set isFetching(_) {},
set selectedMediaIds(_) {},
set attachmentsDomain(_) {},
set errorTitle(_) {},
set errorSubtitle(_) {},
set selectedRecordIds(_) {},
async fetchUnsplashRecords(offset) {
if (!this.state.needle) {
return { records: [], isMaxed: false };
}
this.state.isFetchingUnsplash = true;
try {
const { isMaxed, images } = await this.unsplash.getImages(this.state.needle, offset, this.NUMBER_OF_RECORDS_TO_DISPLAY, this.props.orientation);
this.state.isFetchingUnsplash = false;
this.state.unsplashError = false;
// Ignore duplicates.
const existingIds = this.state.unsplashRecords.map(existing => existing.id);
const newImages = images.filter(record => !existingIds.includes(record.id));
const records = newImages.map(record => {
const url = new URL(record.urls.regular);
// In small windows, row height could get quite a bit larger than the min, so we keep some leeway.
url.searchParams.set('h', 2 * this.MIN_ROW_HEIGHT);
url.searchParams.delete('w');
return Object.assign({}, record, {
url: url.toString(),
mediaType: 'unsplashRecord',
});
});
return { isMaxed, records };
} catch (e) {
this.state.isFetchingUnsplash = false;
if (e === 'no_access') {
this.state.useUnsplash = false;
} else {
this.state.unsplashError = e;
}
return { records: [], isMaxed: true };
}
},
async loadMore(...args) {
await this._super(...args);
return this.keepLastUnsplash.add(this.fetchUnsplashRecords(this.state.unsplashRecords.length)).then(({ records, isMaxed }) => {
// This is never reached if another search or loadMore occurred.
this.state.unsplashRecords.push(...records);
this.state.isMaxed = isMaxed;
});
},
async search(...args) {
await this._super(...args);
await this.searchUnsplash();
},
async searchUnsplash() {
if (!this.state.needle) {
this.state.unsplashError = false;
this.state.unsplashRecords = [];
this.state.isMaxed = false;
}
return this.keepLastUnsplash.add(this.fetchUnsplashRecords(0)).then(({ records, isMaxed }) => {
// This is never reached if a new search occurred.
this.state.unsplashRecords = records;
this.state.isMaxed = isMaxed;
});
},
async onClickRecord(media) {
this.props.selectMedia({ ...media, mediaType: 'unsplashRecord', query: this.state.needle });
if (!this.props.multiSelect) {
await this.props.save();
}
},
async submitCredentials(key, appId) {
this.state.unsplashError = null;
await this.rpc('/web_unsplash/save_unsplash', { key, appId });
await this.searchUnsplash();
},
});
ImageSelector.components = {
...ImageSelector.components,
UnsplashError,
};
patch(MediaDialog.prototype, 'media_dialog_unsplash', {
setup() {
this._super();
this.uploadService = useService('upload');
},
async save() {
const _super = this._super.bind(this);
const selectedImages = this.selectedMedia[TABS.IMAGES.id];
if (selectedImages) {
const unsplashRecords = selectedImages.filter(media => media.mediaType === 'unsplashRecord');
if (unsplashRecords.length) {
await this.uploadService.uploadUnsplashRecords(unsplashRecords, { resModel: this.props.resModel, resId: this.props.resId }, (attachments) => {
this.selectedMedia[TABS.IMAGES.id] = this.selectedMedia[TABS.IMAGES.id].filter(media => media.mediaType !== 'unsplashRecord');
this.selectedMedia[TABS.IMAGES.id] = this.selectedMedia[TABS.IMAGES.id].concat(attachments.map(attachment => ({...attachment, mediaType: 'attachment'})));
});
}
}
return _super(...arguments);
},
});
patch(uploadService, 'upload_service_unsplash', {
start(env, { rpc }) {
const service = this._super(...arguments);
return {
...service,
async uploadUnsplashRecords(records, { resModel, resId }, onUploaded) {
service.incrementId();
const file = service.addFile({
id: service.fileId,
name: records.length > 1 ?
_.str.sprintf(env._t("Uploading %s '%s' images."), records.length, records[0].query) :
_.str.sprintf(env._t("Uploading '%s' image."), records[0].query),
size: null,
progress: 0,
});
try {
const urls = {};
for (const record of records) {
const _1920Url = new URL(record.urls.regular);
_1920Url.searchParams.set('w', '1920');
urls[record.id] = {
url: _1920Url.href,
download_url: record.links.download_location,
description: record.alt_description,
};
}
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', ev => {
const rpcComplete = ev.loaded / ev.total * 100;
file.progress = rpcComplete;
});
xhr.upload.addEventListener('load', function () {
// Don't show yet success as backend code only starts now
file.progress = 100;
});
const attachments = await rpc('/web_unsplash/attachment/add', {
'res_id': resId,
'res_model': resModel,
'unsplashurls': urls,
'query': records[0].query,
}, {xhr});
if (attachments.error) {
file.hasError = true;
file.errorMessage = attachments.error;
} else {
file.uploaded = true;
await onUploaded(attachments);
}
setTimeout(() => service.deleteFile(file.id), AUTOCLOSE_DELAY);
} catch (error) {
file.hasError = true;
setTimeout(() => service.deleteFile(file.id), AUTOCLOSE_DELAY);
throw error;
}
}
};
}
});

View file

@ -1,82 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_unsplash.UnsplashError" owl="1">
<div class="alert alert-info w-100">
<h4><t t-esc="props.title"/></h4>
<p><t t-esc="props.subtitle"/></p>
<UnsplashCredentials t-if="props.showCredentials" submitCredentials="props.submitCredentials" hasCredentialsError="props.hasCredentialsError"/>
</div>
</t>
<t t-name="web_unsplash.UnsplashCredentials" owl="1">
<div class="d-flex align-items-center flex-wrap">
<a href="https://www.odoo.com/documentation/16.0/applications/websites/website/optimize/unsplash.html#generate-an-unsplash-access-key"
class="mx-2" target="_blank">Get an Access key</a>
and paste it here:
<input type="text"
class="o_input form-control w-auto mx-2"
id="accessKeyInput"
placeholder="Paste your access key here"
t-model="state.key"
t-on-input="() => this.state.hasKeyError = false"
t-att-class="{ 'is-invalid': state.hasKeyError }"/>
and paste
<a href="https://www.odoo.com/documentation/16.0/applications/websites/website/optimize/unsplash.html#generate-an-unsplash-application-id"
class="mx-2" target="_blank">Application ID</a>
here:
<div class="input-group d-flex justify-content-end align-items-center w-auto mx-2">
<input type="text"
class="o_input form-control w-auto"
placeholder="Paste your application ID here"
t-model="state.appId"
t-on-input="() => this.state.hasAppIdError = false"
t-att-class="{ 'is-invalid': state.hasAppIdError }"/>
<button type="button" class="btn btn-primary btn-block w-auto p-auto save_unsplash" t-on-click="() => this.submitCredentials()">Apply</button>
</div>
</div>
</t>
<t t-name="web_unsplash.ImagesListTemplate" t-inherit="web_editor.ImagesListTemplate" t-inherit-mode="extension">
<xpath expr="//t[@id='o_we_media_library_images']" position="after">
<t t-if="['all', 'unsplash'].includes(state.searchService)">
<t t-foreach="state.unsplashRecords" t-as="record" t-key="record.id">
<AutoResizeImage src="record.url"
author="record.user.name"
authorLink="record.user.links.html"
name="record.user.name"
title="record.user.name"
altDescription="record.alt_description"
selected="this.selectedRecordIds.includes(record.id)"
onImageClick="() => this.onClickRecord(record)"
minRowHeight="MIN_ROW_HEIGHT"
onLoaded="(imgEl) => this.onImageLoaded(imgEl, record)"/>
</t>
</t>
</xpath>
</t>
<t t-inherit="web_editor.FileSelector" t-inherit-mode="extension">
<xpath expr="//div[@name='load_more_attachments']" position="after">
<div t-if="state.unsplashError" class="d-flex mt-2 unsplash_error">
<UnsplashError
title="errorTitle"
subtitle="errorSubtitle"
showCredentials="['key_not_found', 401].includes(state.unsplashError)"
submitCredentials="(key, appId) => this.submitCredentials(key, appId)"
hasCredentialsError="state.unsplashError === 401"/>
</div>
</xpath>
</t>
<t t-inherit="web_editor.FileSelectorControlPanel" t-inherit-mode="extension">
<xpath expr="//option[@value='media-library']" position="after">
<option t-if="props.useUnsplash" t-att-selected="props.searchService === 'unsplash'" value="unsplash">Photos (via Unsplash)</option>
</xpath>
</t>
<t t-inherit="web_editor.FileSelector" t-inherit-mode="extension">
<xpath expr="//FileSelectorControlPanel" position="attributes">
<attribute name="useUnsplash">state.useUnsplash</attribute>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,35 @@
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
export class UnsplashBeacon extends Interaction {
static selector = "#wrapwrap";
async willStart() {
const unsplashImageEls = this.el.querySelectorAll("img[src*='/unsplash/']");
const unsplashImageIds = [];
for (const unsplashImageEl of unsplashImageEls) {
// extract the image id from URL
// (`http://www.domain.com:1234/unsplash/xYdf5feoI/lion.jpg` -> `xYdf5feoI`)
unsplashImageIds.push(unsplashImageEl.src.split("/unsplash/")[1].split("/")[0]);
}
if (unsplashImageIds.length) {
const appID = await this.waitFor(rpc("/web_unsplash/get_app_id"));
if (appID) {
const fetchURL = new URL("https://views.unsplash.com/v");
fetchURL.search = new URLSearchParams({
"photo_id": unsplashImageIds.join(","),
"app_id": appID,
});
fetch(fetchURL);
}
}
}
}
registry
.category("public.interactions")
.add("web_unsplash.unsplash_beacon", UnsplashBeacon);

View file

@ -1,34 +0,0 @@
odoo.define('web_unsplash.beacon', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
publicWidget.registry.UnsplashBeacon = publicWidget.Widget.extend({
// /!\ To adapt the day the beacon makes sense for backend customizations
selector: '#wrapwrap',
/**
* @override
*/
start: function () {
var unsplashImages = _.map(this.$('img[src*="/unsplash/"]'), function (img) {
// get image id from URL (`http://www.domain.com:1234/unsplash/xYdf5feoI/lion.jpg` -> `xYdf5feoI`)
return img.src.split('/unsplash/')[1].split('/')[0];
});
if (unsplashImages.length) {
this._rpc({
route: '/web_unsplash/get_app_id',
}).then(function (appID) {
if (!appID) {
return;
}
$.get('https://views.unsplash.com/v', {
'photo_id': unsplashImages.join(','),
'app_id': appID,
});
});
}
return this._super.apply(this, arguments);
},
});
});

View file

@ -0,0 +1,212 @@
import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
import { KeepLast } from "@web/core/utils/concurrency";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { ImageSelector } from "@html_editor/main/media/media_dialog/image_selector";
import { UnsplashError } from "../unsplash_error/unsplash_error";
import { useState } from "@odoo/owl";
patch(ImageSelector.prototype, {
setup() {
super.setup();
this.unsplash = useService("unsplash");
this.keepLastUnsplash = new KeepLast();
this.unsplashState = useState({
unsplashRecords: [],
isFetchingUnsplash: false,
isMaxed: false,
unsplashError: null,
useUnsplash: true,
});
this.NUMBER_OF_RECORDS_TO_DISPLAY = 30;
this.errorMessages = {
key_not_found: {
title: _t("Setup Unsplash to access royalty free photos."),
subtitle: "",
},
401: {
title: _t("Unauthorized Key"),
subtitle: _t("Please check your Unsplash access key and application ID."),
},
403: {
title: _t("Search is temporarily unavailable"),
subtitle: _t(
"The max number of searches is exceeded. Please retry in an hour or extend to a better account."
),
},
};
},
get canLoadMore() {
if (this.state.searchService === "all") {
return (
super.canLoadMore ||
(this.state.needle &&
!this.unsplashState.isMaxed &&
!this.unsplashState.unsplashError)
);
} else if (this.state.searchService === "unsplash") {
return (
this.state.needle &&
!this.unsplashState.isMaxed &&
!this.unsplashState.unsplashError
);
}
return super.canLoadMore;
},
get hasContent() {
if (this.state.searchService === "all") {
return super.hasContent || !!this.unsplashState.unsplashRecords.length;
} else if (this.state.searchService === "unsplash") {
return !!this.unsplashState.unsplashRecords.length;
}
return super.hasContent;
},
get errorTitle() {
if (this.errorMessages[this.unsplashState.unsplashError]) {
return this.errorMessages[this.unsplashState.unsplashError].title;
}
return _t("Something went wrong");
},
get errorSubtitle() {
if (this.errorMessages[this.unsplashState.unsplashError]) {
return this.errorMessages[this.unsplashState.unsplashError].subtitle;
}
return _t("Please check your internet connection or contact administrator.");
},
get selectedRecordIds() {
return this.props.selectedMedia[this.props.id]
.filter((media) => media.mediaType === "unsplashRecord")
.map(({ id }) => id);
},
get isFetching() {
return super.isFetching || this.unsplashState.isFetchingUnsplash;
},
get combinedRecords() {
/**
* Creates an array with alternating elements from two arrays.
*
* @param {Array} a
* @param {Array} b
* @returns {Array} alternating elements from a and b, starting with
* an element of a
*/
function alternate(a, b) {
return [a.map((v, i) => (i < b.length ? [v, b[i]] : v)), b.slice(a.length)].flat(2);
}
return alternate(this.unsplashState.unsplashRecords, this.state.libraryMedia);
},
get allAttachments() {
return [...super.allAttachments, ...this.unsplashState.unsplashRecords];
},
async fetchUnsplashRecords(offset) {
if (!this.state.needle) {
return { records: [], isMaxed: false };
}
this.unsplashState.isFetchingUnsplash = true;
try {
const { isMaxed, images } = await this.unsplash.getImages(
this.state.needle,
offset,
this.NUMBER_OF_RECORDS_TO_DISPLAY,
this.props.orientation
);
this.unsplashState.isFetchingUnsplash = false;
this.unsplashState.unsplashError = false;
// Use a set to keep track of every image we've received so far,
// based on their ids. This will allow us to ignore duplicate
// images from Unsplash. We can assume there are no duplicates at
// this point as a precondition.
const existingIds = new Set(this.unsplashState.unsplashRecords.map(r => r.id));
const newImages = images.filter(record => {
if (existingIds.has(record.id)) {
return false;
}
// Mark this image as seen so that we can ignore any duplicates
// from the same Unsplash batch.
existingIds.add(record.id);
return true;
});
const records = newImages.map((record) => {
const url = new URL(record.urls.regular);
// In small windows, row height could get quite a bit larger than the min, so we keep some leeway.
url.searchParams.set("h", 2 * this.MIN_ROW_HEIGHT);
url.searchParams.delete("w");
return Object.assign({}, record, {
url: url.toString(),
mediaType: "unsplashRecord",
});
});
return { isMaxed, records };
} catch (e) {
this.unsplashState.isFetchingUnsplash = false;
if (e === "no_access") {
this.unsplashState.useUnsplash = false;
} else {
this.unsplashState.unsplashError = e;
}
return { records: [], isMaxed: true };
}
},
async loadMore(...args) {
await super.loadMore(...args);
return this.keepLastUnsplash
.add(this.fetchUnsplashRecords(this.unsplashState.unsplashRecords.length))
.then(({ records, isMaxed }) => {
// This is never reached if another search or loadMore occurred.
this.unsplashState.unsplashRecords.push(...records);
this.unsplashState.isMaxed = isMaxed;
});
},
async search(...args) {
await super.search(...args);
await this.searchUnsplash();
},
async searchUnsplash() {
if (!this.state.needle) {
this.unsplashState.unsplashError = false;
this.unsplashState.unsplashRecords = [];
this.unsplashState.isMaxed = false;
}
return this.keepLastUnsplash
.add(this.fetchUnsplashRecords(0))
.then(({ records, isMaxed }) => {
// This is never reached if a new search occurred.
this.unsplashState.unsplashRecords = records;
this.unsplashState.isMaxed = isMaxed;
});
},
async onClickRecord(media) {
this.props.selectMedia({ ...media, mediaType: "unsplashRecord", query: this.state.needle });
if (!this.props.multiSelect) {
await this.props.save();
}
},
async submitCredentials(key, appId) {
this.unsplashState.unsplashError = null;
await rpc("/web_unsplash/save_unsplash", { key, appId });
await this.searchUnsplash();
},
});
ImageSelector.components = {
...ImageSelector.components,
UnsplashError,
};

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-inherit="html_editor.ExternalImage" t-inherit-mode="extension">
<xpath expr="//t[@t-if]" position="after">
<t t-elif="record.mediaType === 'unsplashRecord'">
<AutoResizeImage src="record.url"
author="record.user.name"
authorLink="record.user.links.html"
name="record.user.name"
title="record.user.name"
altDescription="record.alt_description"
selected="this.selectedRecordIds.includes(record.id)"
onImageClick="() => this.onClickRecord(record)"
minRowHeight="MIN_ROW_HEIGHT"
onLoaded="(imgEl) => this.onImageLoaded(imgEl, record)"/>
</t>
</xpath>
</t>
<t t-inherit="html_editor.ImagesListTemplate" t-inherit-mode="extension">
<xpath expr="//t[@id='o_we_media_library_images']" position="replace">
<t id='o_we_media_library_images' t-if="['all', 'unsplash', 'media-library'].includes(state.searchService)">
<t t-foreach="combinedRecords" t-as="record" t-key="record.id">
<t t-call="html_editor.ExternalImage"/>
</t>
</t>
</xpath>
</t>
<t t-inherit="html_editor.FileSelector" t-inherit-mode="extension">
<xpath expr="//div[@name='load_more_attachments']" position="before">
<div t-if="unsplashState?.unsplashError" class="d-flex mt-2 unsplash_error">
<UnsplashError
title="errorTitle"
subtitle="errorSubtitle"
showCredentials="['key_not_found', 401].includes(unsplashState.unsplashError)"
submitCredentials="(key, appId) => this.submitCredentials(key, appId)"
hasCredentialsError="unsplashState.unsplashError === 401"/>
</div>
</xpath>
</t>
<t t-inherit="html_editor.FileSelectorControlPanel" t-inherit-mode="extension">
<xpath expr="//option[@value='media-library']" position="after">
<option t-if="props.useUnsplash" t-att-selected="props.searchService === 'unsplash'" value="unsplash">Photos (via Unsplash)</option>
</xpath>
</t>
<t t-inherit="html_editor.FileSelector" t-inherit-mode="extension">
<xpath expr="//FileSelectorControlPanel" position="attributes">
<attribute name="useUnsplash">unsplashState?.useUnsplash</attribute>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,39 @@
import { MediaDialog, TABS } from "@html_editor/main/media/media_dialog/media_dialog";
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
patch(MediaDialog.prototype, {
setup() {
super.setup();
this.unsplashService = useService("unsplash");
},
async save() {
const selectedImages = this.selectedMedia[TABS.IMAGES.id];
if (selectedImages) {
const unsplashRecords = selectedImages.filter(
(media) => media.mediaType === "unsplashRecord"
);
if (unsplashRecords.length) {
await this.unsplashService.uploadUnsplashRecords(
unsplashRecords,
{ resModel: this.props.resModel, resId: this.props.resId },
(attachments) => {
this.selectedMedia[TABS.IMAGES.id] = this.selectedMedia[
TABS.IMAGES.id
].filter((media) => media.mediaType !== "unsplashRecord");
this.selectedMedia[TABS.IMAGES.id] = this.selectedMedia[
TABS.IMAGES.id
].concat(
attachments.map((attachment) => ({
...attachment,
mediaType: "attachment",
}))
);
}
);
}
}
return super.save(...arguments);
},
});

View file

@ -1,59 +0,0 @@
/** @odoo-module **/
import { registry } from '@web/core/registry';
export const unsplashService = {
dependencies: ['rpc'],
async start(env, { rpc }) {
const _cache = {};
return {
async getImages(query, offset = 0, pageSize = 30, orientation) {
const from = offset;
const to = offset + pageSize;
// Use orientation in the cache key to not show images in cache
// when using the same query word but changing the orientation
let cachedData = orientation ? _cache[query + orientation] : _cache[query];
if (cachedData && (cachedData.images.length >= to || (cachedData.totalImages !== 0 && cachedData.totalImages < to))) {
return { images: cachedData.images.slice(from, to), isMaxed: to > cachedData.totalImages };
}
cachedData = await this._fetchImages(query, orientation);
return { images: cachedData.images.slice(from, to), isMaxed: to > cachedData.totalImages };
},
/**
* Fetches images from unsplash and stores it in cache
*/
async _fetchImages(query, orientation) {
const key = orientation ? query + orientation : query;
if (!_cache[key]) {
_cache[key] = {
images: [],
maxPages: 0,
totalImages: 0,
pageCached: 0
};
}
const cachedData = _cache[key];
const payload = {
query: query,
page: cachedData.pageCached + 1,
per_page: 30, // max size from unsplash API
};
if (orientation) {
payload.orientation = orientation;
}
const result = await rpc('/web_unsplash/fetch_images', payload);
if (result.error) {
return Promise.reject(result.error);
}
cachedData.pageCached++;
cachedData.images.push(...result.results);
cachedData.maxPages = result.total_pages;
cachedData.totalImages = result.total;
return cachedData;
},
};
},
};
registry.category('services').add('unsplash', unsplashService);

View file

@ -0,0 +1,27 @@
import { Component, useState } from "@odoo/owl";
export class UnsplashCredentials extends Component {
static template = "web_unsplash.UnsplashCredentials";
static props = {
submitCredentials: Function,
hasCredentialsError: Boolean,
};
setup() {
this.state = useState({
key: "",
appId: "",
hasKeyError: this.props.hasCredentialsError,
hasAppIdError: this.props.hasCredentialsError,
});
}
submitCredentials() {
if (this.state.key === "") {
this.state.hasKeyError = true;
} else if (this.state.appId === "") {
this.state.hasAppIdError = true;
} else {
this.props.submitCredentials(this.state.key, this.state.appId);
}
}
}

View file

@ -0,0 +1,27 @@
<templates id="template" xml:space="preserve">
<t t-name="web_unsplash.UnsplashCredentials">
<div class="d-flex align-items-center flex-wrap">
<a href="https://www.odoo.com/documentation/latest/applications/websites/website/optimize/unsplash.html#generate-an-unsplash-access-key"
class="me-1" target="_blank">Get an Access key</a>
and paste it here:
<input type="text"
class="o_input o_required_modifier form-control w-auto mx-2"
id="accessKeyInput"
placeholder="Paste your access key here"
t-model="state.key"
t-on-input="() => this.state.hasKeyError = false"
t-att-class="{ 'is-invalid': state.hasKeyError }"/>
and paste
<a href="https://www.odoo.com/documentation/latest/applications/websites/website/optimize/unsplash.html#generate-an-unsplash-application-id"
class="mx-1" target="_blank">Application ID</a>
here:
<input type="text"
class="o_input o_required_modifier form-control w-auto ms-2"
placeholder="Paste your application ID here"
t-model="state.appId"
t-on-input="() => this.state.hasAppIdError = false"
t-att-class="{ 'is-invalid': state.hasAppIdError }"/>
<button type="button" class="btn btn-primary w-auto ms-3 p-auto save_unsplash" t-on-click="() => this.submitCredentials()">Apply</button>
</div>
</t>
</templates>

View file

@ -0,0 +1,16 @@
import { Component } from "@odoo/owl";
import { UnsplashCredentials } from "../unsplash_credentials/unsplash_credentials";
export class UnsplashError extends Component {
static template = "web_unsplash.UnsplashError";
static components = {
UnsplashCredentials,
};
static props = {
title: String,
subtitle: String,
showCredentials: Boolean,
submitCredentials: { type: Function, optional: true },
hasCredentialsError: { type: Boolean, optional: true },
};
}

View file

@ -0,0 +1,9 @@
<templates id="template" xml:space="preserve">
<t t-name="web_unsplash.UnsplashError">
<div class="alert alert-info w-100">
<h4><t t-esc="props.title"/></h4>
<p><t t-esc="props.subtitle"/></p>
<UnsplashCredentials t-if="props.showCredentials" submitCredentials="props.submitCredentials" hasCredentialsError="props.hasCredentialsError"/>
</div>
</t>
</templates>

View file

@ -0,0 +1,130 @@
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
import { _t } from "@web/core/l10n/translation";
import { AUTOCLOSE_DELAY } from "@html_editor/main/media/media_dialog/upload_progress_toast/upload_service";
export const unsplashService = {
dependencies: ["upload"],
async start(env, { upload }) {
const _cache = {};
return {
async uploadUnsplashRecords(records, { resModel, resId }, onUploaded) {
upload.incrementId();
const file = upload.addFile({
id: upload.fileId,
name:
records.length > 1
? _t("Uploading %(count)s '%(query)s' images.", {
count: records.length,
query: records[0].query,
})
: _t("Uploading '%s' image.", records[0].query),
});
try {
const urls = {};
for (const record of records) {
const _1920Url = new URL(record.urls.regular);
_1920Url.searchParams.set("w", "1920");
urls[record.id] = {
url: _1920Url.href,
download_url: record.links.download_location,
description: record.alt_description,
};
}
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (ev) => {
const rpcComplete = (ev.loaded / ev.total) * 100;
file.progress = rpcComplete;
});
xhr.upload.addEventListener("load", function () {
// Don't show yet success as backend code only starts now
file.progress = 100;
});
const attachments = await rpc(
"/web_unsplash/attachment/add",
{
res_id: resId,
res_model: resModel,
unsplashurls: urls,
query: records[0].query,
},
{ xhr }
);
if (attachments.error) {
file.hasError = true;
file.errorMessage = attachments.error;
} else {
file.uploaded = true;
await onUploaded(attachments);
}
setTimeout(() => upload.deleteFile(file.id), AUTOCLOSE_DELAY);
} catch (error) {
file.hasError = true;
setTimeout(() => upload.deleteFile(file.id), AUTOCLOSE_DELAY);
throw error;
}
},
async getImages(query, offset = 0, pageSize = 30, orientation) {
const from = offset;
const to = offset + pageSize;
// Use orientation in the cache key to not show images in cache
// when using the same query word but changing the orientation
let cachedData = orientation ? _cache[query + orientation] : _cache[query];
if (
cachedData &&
(cachedData.images.length >= to ||
(cachedData.totalImages !== 0 && cachedData.totalImages < to))
) {
return {
images: cachedData.images.slice(from, to),
isMaxed: to > cachedData.totalImages,
};
}
cachedData = await this._fetchImages(query, orientation);
return {
images: cachedData.images.slice(from, to),
isMaxed: to > cachedData.totalImages,
};
},
/**
* Fetches images from unsplash and stores it in cache
*/
async _fetchImages(query, orientation) {
const key = orientation ? query + orientation : query;
if (!_cache[key]) {
_cache[key] = {
images: [],
maxPages: 0,
totalImages: 0,
pageCached: 0,
};
}
const cachedData = _cache[key];
const payload = {
query: query,
page: cachedData.pageCached + 1,
per_page: 30, // max size from unsplash API
};
if (orientation) {
payload.orientation = orientation;
}
const result = await rpc("/web_unsplash/fetch_images", payload);
if (result.error) {
return Promise.reject(result.error);
}
cachedData.pageCached++;
cachedData.images.push(...result.results);
cachedData.maxPages = result.total_pages;
cachedData.totalImages = result.total;
return cachedData;
},
};
},
};
registry.category("services").add("unsplash", unsplashService);

View file

@ -0,0 +1,117 @@
import { setupEditor } from "@html_editor/../tests/_helpers/editor";
import { insertText } from "@html_editor/../tests/_helpers/user_actions";
import { expectElementCount } from "@html_editor/../tests/_helpers/ui_expectations";
import { expect, test } from "@odoo/hoot";
import { animationFrame, click, Deferred, press, waitFor } from "@odoo/hoot-dom";
import { contains, makeMockEnv, onRpc } from "@web/../tests/web_test_helpers";
test("Unsplash is inserted in the Media Dialog", async () => {
const imageRecord = {
id: 1,
name: "logo",
mimetype: "image/png",
image_src: "/web/static/img/logo2.png",
access_token: false,
public: true,
};
onRpc("ir.attachment", "search_read", () => [imageRecord]);
const fetchDef = new Deferred();
onRpc("/web_unsplash/fetch_images", () => {
expect.step("fetch_images");
fetchDef.resolve();
return {
total: 1,
total_pages: 1,
results: [
{
id: "oXV3bzR7jxI",
alt_description: "An image alt description",
urls: {
regular: "/web/static/img/logo2.png",
},
user: {
name: "Username",
links: {
html: "https://example.com/",
},
},
links: {
download_location: "https://example.com/",
},
},
],
};
});
onRpc("/web_unsplash/attachment/add", (args) => [
{ ...imageRecord, description: "unsplash_image" },
]);
const env = await makeMockEnv();
const { editor } = await setupEditor(`<p>[]</p>`, { env });
await expectElementCount(".o-we-powerbox", 0);
await insertText(editor, "/image");
await animationFrame();
await expectElementCount(".o-we-powerbox", 1);
await click(".o-we-command");
await animationFrame();
expect(".o_select_media_dialog").toHaveCount(1);
contains("input.o_we_search").edit("cat");
await fetchDef;
expect.verifySteps(["fetch_images"]);
await waitFor("img[title='Username']");
await click(".o_button_area[aria-label='Username']");
await waitFor(".o-wysiwyg img[alt='unsplash_image']");
expect(".o-wysiwyg img[alt='unsplash_image']").toHaveCount(1);
});
test("Unsplash error is displayed when there is no key", async () => {
const imageRecord = {
id: 1,
name: "logo",
mimetype: "image/png",
image_src: "/web/static/img/logo2.png",
access_token: false,
public: true,
};
onRpc("ir.attachment", "search_read", () => [imageRecord]);
const fetchDef = new Deferred();
onRpc("/web_unsplash/fetch_images", () => {
fetchDef.resolve();
return {
error: "key_not_found",
};
});
const env = await makeMockEnv();
const { editor } = await setupEditor(`<p>[]</p>`, { env });
await expectElementCount(".o-we-powerbox", 0);
await insertText(editor, "/image");
await animationFrame();
await expectElementCount(".o-we-powerbox", 1);
await click(".o-we-command");
await animationFrame();
expect(".o_select_media_dialog").toHaveCount(1);
contains("input.o_we_search").edit("cat");
await fetchDef;
await waitFor(".unsplash_error");
expect(".unsplash_error").toHaveCount(1);
});
test("Document tab does not crash with FileSelector extension", async () => {
onRpc("ir.attachment", "search_read", () => [
{
id: 1,
name: "logo",
mimetype: "image/png",
image_src: "/web/static/img/logo2.png",
access_token: false,
public: true,
},
]);
const env = await makeMockEnv();
const { editor } = await setupEditor("<p>a[]</p>", { env });
await insertText(editor, "/image");
await animationFrame();
await press("enter");
await animationFrame();
await click("li:nth-child(2) > a.nav-link");
expect(".o_existing_attachment_cell").toHaveCount(1);
});