mirror of
https://github.com/directus/directus.git
synced 2026-01-30 12:08:14 -05:00
Remove access token from asset url when unnecessary (#10548)
* Remove access token when unnecessary * Add missing translations * Refactor naming convention * Refactor naming convention * Refactor access token usage for media upload * Use static token for previews * Add missing imageToken for image upload * Rename imageToken to staticAccessToken * Remove temporary access token from embedUrl * Catch invalid URLs when replacing token * Fix embedUrl not updating when source changed * Patch audio and iframe uploads * Hide tinymce offscreen selection * Fix merge of interface fields * Use static tokens except for preview * Remove dirty checks through v-model for wysiwyg * Add missing translations * Fix lint warnings * Revert naming from staticAccessToken to imageToken
This commit is contained in:
@@ -39,6 +39,9 @@ body.mce-content-readonly {
|
||||
color: ${cssVar('--foreground-subdued')};
|
||||
background-color: ${cssVar('--background-subdued')};
|
||||
}
|
||||
.mce-offscreen-selection {
|
||||
display: none;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: ${cssVar(`--family-${font}`)}, serif;
|
||||
color: ${cssVar('--foreground-normal-alt')};
|
||||
@@ -135,12 +138,15 @@ blockquote {
|
||||
margin-left: 0px;
|
||||
}
|
||||
video,
|
||||
iframe,
|
||||
img {
|
||||
max-width: 100%;
|
||||
border-radius: ${cssVar('--border-radius')};
|
||||
height: auto;
|
||||
}
|
||||
iframe {
|
||||
max-width: 100%;
|
||||
border-radius: ${cssVar('--border-radius')};
|
||||
}
|
||||
hr {
|
||||
background-color: ${cssVar('--border-normal')};
|
||||
height: 1px;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
:init="editorOptions"
|
||||
:disabled="disabled"
|
||||
model-events="change keydown blur focus paste ExecCommand SetContent"
|
||||
@dirty="setDirty"
|
||||
@focusin="setFocus(true)"
|
||||
@focusout="setFocus(false)"
|
||||
/>
|
||||
@@ -60,7 +59,7 @@
|
||||
<interface-input-code
|
||||
:value="code"
|
||||
language="htmlmixed"
|
||||
line-wrapping="true"
|
||||
:line-wrapping="true"
|
||||
@input="code = $event"
|
||||
></interface-input-code>
|
||||
</div>
|
||||
@@ -117,9 +116,14 @@
|
||||
<v-tabs-items v-model="openMediaTab">
|
||||
<v-tab-item value="video">
|
||||
<template v-if="mediaSelection">
|
||||
<video class="media-preview" controls="controls">
|
||||
<source :src="mediaSelection.source" />
|
||||
<video v-if="mediaSelection.tag !== 'iframe'" class="media-preview" controls="controls">
|
||||
<source :src="mediaSelection.previewUrl" />
|
||||
</video>
|
||||
<iframe
|
||||
v-if="mediaSelection.tag === 'iframe'"
|
||||
class="media-preview"
|
||||
:src="mediaSelection.previewUrl"
|
||||
></iframe>
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<div class="type-label">{{ t('source') }}</div>
|
||||
@@ -182,13 +186,10 @@ import 'tinymce/icons/default';
|
||||
|
||||
import Editor from '@tinymce/tinymce-vue';
|
||||
import getEditorStyles from './get-editor-styles';
|
||||
import { escapeRegExp } from 'lodash';
|
||||
import useImage from './useImage';
|
||||
import useMedia from './useMedia';
|
||||
import useLink from './useLink';
|
||||
import useSourceCode from './useSourceCode';
|
||||
import { getToken } from '@/api';
|
||||
import { getPublicURL } from '@/utils/get-root-path';
|
||||
import { percentage } from '@/utils/percentage';
|
||||
|
||||
type CustomFormat = {
|
||||
@@ -265,13 +266,13 @@ export default defineComponent({
|
||||
const { t } = useI18n();
|
||||
const editorRef = ref<any | null>(null);
|
||||
const editorElement = ref<ComponentPublicInstance | null>(null);
|
||||
const isEditorDirty = ref(false);
|
||||
const { imageToken } = toRefs(props);
|
||||
|
||||
let tinymceEditor: HTMLElement | null;
|
||||
let count = ref(0);
|
||||
onMounted(() => {
|
||||
let iframe;
|
||||
let contentLoaded = false;
|
||||
const wysiwyg = document.getElementById(props.field);
|
||||
|
||||
if (wysiwyg) iframe = wysiwyg.getElementsByTagName('iframe');
|
||||
@@ -282,6 +283,11 @@ export default defineComponent({
|
||||
if (tinymceEditor) {
|
||||
const observer = new MutationObserver((_mutations) => {
|
||||
count.value = tinymceEditor?.textContent?.replace('\n', '')?.length ?? 0;
|
||||
if (!contentLoaded) {
|
||||
contentLoaded = true;
|
||||
} else {
|
||||
emit('input', editorRef.value.getContent());
|
||||
}
|
||||
});
|
||||
|
||||
const config = { characterData: true, childList: true, subtree: true };
|
||||
@@ -291,7 +297,6 @@ export default defineComponent({
|
||||
|
||||
const { imageDrawerOpen, imageSelection, closeImageDrawer, onImageSelect, saveImage, imageButton } = useImage(
|
||||
editorRef,
|
||||
isEditorDirty,
|
||||
imageToken
|
||||
);
|
||||
|
||||
@@ -307,51 +312,18 @@ export default defineComponent({
|
||||
mediaWidth,
|
||||
mediaSource,
|
||||
mediaButton,
|
||||
} = useMedia(editorRef, isEditorDirty, imageToken);
|
||||
} = useMedia(editorRef, imageToken);
|
||||
|
||||
const { linkButton, linkDrawerOpen, closeLinkDrawer, saveLink, linkSelection } = useLink(editorRef, isEditorDirty);
|
||||
const { linkButton, linkDrawerOpen, closeLinkDrawer, saveLink, linkSelection } = useLink(editorRef);
|
||||
|
||||
const { codeDrawerOpen, code, closeCodeDrawer, saveCode, sourceCodeButton } = useSourceCode(
|
||||
editorRef,
|
||||
isEditorDirty
|
||||
);
|
||||
|
||||
const replaceTokens = (value: string, token: string | null) => {
|
||||
const url = getPublicURL();
|
||||
const regex = new RegExp(
|
||||
`(<[^]+?=")(${escapeRegExp(
|
||||
url
|
||||
)}assets/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:\\?[^#"]*)?(?:#[^"]*)?)("[^>]*>)`,
|
||||
'gi'
|
||||
);
|
||||
|
||||
return value.replace(regex, (_, pre, matchedUrl, post) => {
|
||||
const matched = new URL(matchedUrl.replace(/&/g, '&'));
|
||||
const params = new URLSearchParams(matched.search);
|
||||
|
||||
if (!token) {
|
||||
params.delete('access_token');
|
||||
} else {
|
||||
params.set('access_token', token);
|
||||
}
|
||||
|
||||
const paramsString = params.toString().length > 0 ? `?${params.toString().replace(/&/g, '&')}` : '';
|
||||
|
||||
return `${pre}${matched.origin}${matched.pathname}${paramsString}${post}`;
|
||||
});
|
||||
};
|
||||
const { codeDrawerOpen, code, closeCodeDrawer, saveCode, sourceCodeButton } = useSourceCode(editorRef);
|
||||
|
||||
const internalValue = computed({
|
||||
get() {
|
||||
if (!props.value) return '';
|
||||
return replaceTokens(props.value, getToken());
|
||||
return props.value || '';
|
||||
},
|
||||
set(newValue: string) {
|
||||
if (!isEditorDirty.value) return;
|
||||
if (newValue !== props.value && (props.value === null && newValue === '') === false) {
|
||||
const removeToken = replaceTokens(newValue, props.imageToken ?? null);
|
||||
emit('input', removeToken);
|
||||
}
|
||||
set() {
|
||||
return;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -390,7 +362,7 @@ export default defineComponent({
|
||||
menubar: false,
|
||||
convert_urls: false,
|
||||
image_dimensions: false,
|
||||
extended_valid_elements: 'audio[loop],source',
|
||||
extended_valid_elements: 'audio[loop|controls],source',
|
||||
toolbar: toolbarString,
|
||||
style_formats: styleFormats,
|
||||
file_picker_types: 'customImage customMedia image media',
|
||||
@@ -410,7 +382,6 @@ export default defineComponent({
|
||||
editorOptions,
|
||||
internalValue,
|
||||
setFocus,
|
||||
setDirty,
|
||||
onImageSelect,
|
||||
saveImage,
|
||||
imageDrawerOpen,
|
||||
@@ -447,10 +418,6 @@ export default defineComponent({
|
||||
editor.ui.registry.addButton('customCode', sourceCodeButton);
|
||||
}
|
||||
|
||||
function setDirty() {
|
||||
isEditorDirty.value = true;
|
||||
}
|
||||
|
||||
function setFocus(val: boolean) {
|
||||
if (editorElement.value == null) return;
|
||||
const body = editorElement.value.$el.parentElement?.querySelector('.tox-tinymce');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { addTokenToURL } from '@/api';
|
||||
import { getToken } from '@/api';
|
||||
import { i18n } from '@/lang';
|
||||
import { addQueryToPath } from '@/utils/add-query-to-path';
|
||||
import { getPublicURL } from '@/utils/get-root-path';
|
||||
@@ -28,11 +28,7 @@ type UsableImage = {
|
||||
imageButton: ImageButton;
|
||||
};
|
||||
|
||||
export default function useImage(
|
||||
editor: Ref<any>,
|
||||
isEditorDirty: Ref<boolean>,
|
||||
imageToken: Ref<string | undefined>
|
||||
): UsableImage {
|
||||
export default function useImage(editor: Ref<any>, imageToken: Ref<string | undefined>): UsableImage {
|
||||
const imageDrawerOpen = ref(false);
|
||||
const imageSelection = ref<ImageSelection | null>(null);
|
||||
|
||||
@@ -59,7 +55,7 @@ export default function useImage(
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
previewUrl: imageUrl,
|
||||
previewUrl: replaceUrlAccessToken(imageUrl, imageToken.value ?? getToken()),
|
||||
};
|
||||
} else {
|
||||
imageSelection.value = null;
|
||||
@@ -86,14 +82,14 @@ export default function useImage(
|
||||
}
|
||||
|
||||
function onImageSelect(image: Record<string, any>) {
|
||||
const imageUrl = addTokenToURL(getPublicURL() + 'assets/' + image.id, imageToken.value);
|
||||
const assetUrl = getPublicURL() + 'assets/' + image.id;
|
||||
|
||||
imageSelection.value = {
|
||||
imageUrl,
|
||||
imageUrl: replaceUrlAccessToken(assetUrl, imageToken.value),
|
||||
alt: image.title,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
previewUrl: imageUrl,
|
||||
previewUrl: replaceUrlAccessToken(assetUrl, imageToken.value ?? getToken()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,8 +101,30 @@ export default function useImage(
|
||||
...(img.height ? { height: img.height.toString() } : {}),
|
||||
});
|
||||
const imageHtml = `<img src="${resizedImageUrl}" alt="${img.alt}" />`;
|
||||
isEditorDirty.value = true;
|
||||
editor.value.selection.setContent(imageHtml);
|
||||
closeImageDrawer();
|
||||
}
|
||||
|
||||
function replaceUrlAccessToken(url: string, token: string | null | undefined): string {
|
||||
// Only process assets URL
|
||||
if (!url.includes(getPublicURL() + 'assets/')) {
|
||||
return url;
|
||||
}
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const params = new URLSearchParams(parsedUrl.search);
|
||||
|
||||
if (!token) {
|
||||
params.delete('access_token');
|
||||
} else {
|
||||
params.set('access_token', token);
|
||||
}
|
||||
|
||||
return Array.from(params).length > 0
|
||||
? `${parsedUrl.origin}${parsedUrl.pathname}?${params.toString()}`
|
||||
: `${parsedUrl.origin}${parsedUrl.pathname}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ type UsableLink = {
|
||||
linkButton: LinkButton;
|
||||
};
|
||||
|
||||
export default function useLink(editor: Ref<any>, isEditorDirty: Ref<boolean>): UsableLink {
|
||||
export default function useLink(editor: Ref<any>): UsableLink {
|
||||
const linkDrawerOpen = ref(false);
|
||||
const defaultLinkSelection = {
|
||||
url: null,
|
||||
@@ -94,7 +94,6 @@ export default function useLink(editor: Ref<any>, isEditorDirty: Ref<boolean>):
|
||||
link.displayText || link.url
|
||||
}</a>`;
|
||||
|
||||
isEditorDirty.value = true;
|
||||
editor.value.selection.setContent(linkHtml);
|
||||
closeLinkDrawer();
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { addTokenToURL } from '@/api';
|
||||
import { getToken } from '@/api';
|
||||
import { i18n } from '@/lang';
|
||||
import { getPublicURL } from '@/utils/get-root-path';
|
||||
import { computed, Ref, ref, watch } from 'vue';
|
||||
|
||||
type MediaSelection = {
|
||||
source: string;
|
||||
sourceUrl: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
tag?: 'video' | 'audio';
|
||||
tag?: 'video' | 'audio' | 'iframe';
|
||||
type?: string;
|
||||
previewUrl?: string;
|
||||
};
|
||||
|
||||
type MediaButton = {
|
||||
@@ -33,11 +34,7 @@ type UsableMedia = {
|
||||
mediaButton: MediaButton;
|
||||
};
|
||||
|
||||
export default function useMedia(
|
||||
editor: Ref<any>,
|
||||
isEditorDirty: Ref<boolean>,
|
||||
imageToken: Ref<string | undefined>
|
||||
): UsableMedia {
|
||||
export default function useMedia(editor: Ref<any>, imageToken: Ref<string | undefined>): UsableMedia {
|
||||
const mediaDrawerOpen = ref(false);
|
||||
const mediaSelection = ref<MediaSelection | null>(null);
|
||||
const openMediaTab = ref(['video', 'audio']);
|
||||
@@ -76,10 +73,14 @@ export default function useMedia(
|
||||
|
||||
const mediaSource = computed({
|
||||
get() {
|
||||
return mediaSelection.value?.source;
|
||||
return mediaSelection.value?.sourceUrl;
|
||||
},
|
||||
set(newSource: any) {
|
||||
mediaSelection.value = { ...mediaSelection.value, source: newSource };
|
||||
mediaSelection.value = {
|
||||
...mediaSelection.value,
|
||||
sourceUrl: newSource,
|
||||
};
|
||||
mediaSelection.value.previewUrl = replaceUrlAccessToken(newSource, imageToken.value || getToken());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -106,15 +107,15 @@ export default function useMedia(
|
||||
watch(mediaSelection, (vid) => {
|
||||
if (embed.value === '') {
|
||||
if (vid === null) return;
|
||||
embed.value = `<${vid.tag} width="${vid.width}" height="${vid.height}" controls><source src="${vid.source}" type="${vid.type}" /></${vid.tag}>`;
|
||||
embed.value = `<${vid.tag} width="${vid.width}" height="${vid.height}" controls><source src="${vid.sourceUrl}" type="${vid.type}" /></${vid.tag}>`;
|
||||
} else {
|
||||
embed.value = embed.value
|
||||
.replace(/src=".*?"/g, `src="${vid?.source}"`)
|
||||
.replace(/src=".*?"/g, `src="${vid?.sourceUrl}"`)
|
||||
.replace(/width=".*?"/g, `width="${vid?.width}"`)
|
||||
.replace(/height=".*?"/g, `height="${vid?.height}"`)
|
||||
.replace(/type=".*?"/g, `type="${vid?.type}"`)
|
||||
.replaceAll(/<(video|audio)/g, `<${vid?.tag}`)
|
||||
.replaceAll(/<\/(video|audio)/g, `</${vid?.tag}`);
|
||||
.replaceAll(/<(video|audio|iframe)/g, `<${vid?.tag}`)
|
||||
.replaceAll(/<\/(video|audio|iframe)/g, `</${vid?.tag}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -122,20 +123,24 @@ export default function useMedia(
|
||||
if (newEmbed === '') {
|
||||
mediaSelection.value = null;
|
||||
} else {
|
||||
const tag = /<(video|audio)/g.exec(newEmbed)?.[1];
|
||||
const source = /src="(.*?)"/g.exec(newEmbed)?.[1] || undefined;
|
||||
const tag = /<(video|audio|iframe)/g.exec(newEmbed)?.[1];
|
||||
const sourceUrl = /src="(.*?)"/g.exec(newEmbed)?.[1] || undefined;
|
||||
const width = Number(/width="(.*?)"/g.exec(newEmbed)?.[1]) || undefined;
|
||||
const height = Number(/height="(.*?)"/g.exec(newEmbed)?.[1]) || undefined;
|
||||
const type = /type="(.*?)"/g.exec(newEmbed)?.[1] || undefined;
|
||||
|
||||
if (source === undefined) return;
|
||||
if (sourceUrl === undefined) return;
|
||||
|
||||
// Add temporarily access token for preview
|
||||
const previewUrl = replaceUrlAccessToken(sourceUrl, imageToken.value || getToken());
|
||||
|
||||
mediaSelection.value = {
|
||||
tag: tag === 'audio' ? 'audio' : 'video',
|
||||
source,
|
||||
tag: tag === 'audio' ? 'audio' : tag === 'iframe' ? 'iframe' : 'video',
|
||||
sourceUrl,
|
||||
width,
|
||||
height,
|
||||
type,
|
||||
previewUrl,
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -164,23 +169,22 @@ export default function useMedia(
|
||||
}
|
||||
|
||||
function onMediaSelect(media: Record<string, any>) {
|
||||
const url = getPublicURL() + 'assets/' + media.id;
|
||||
const sourceUrl = getPublicURL() + 'assets/' + media.id;
|
||||
const tag = media.type.startsWith('audio') ? 'audio' : 'video';
|
||||
const source = imageToken.value ? addTokenToURL(url, imageToken.value) : url;
|
||||
|
||||
mediaSelection.value = {
|
||||
source,
|
||||
sourceUrl: replaceUrlAccessToken(sourceUrl, imageToken.value),
|
||||
width: media.width || 300,
|
||||
height: media.height || 150,
|
||||
tag,
|
||||
type: media.type,
|
||||
previewUrl: replaceUrlAccessToken(sourceUrl, imageToken.value || getToken()),
|
||||
};
|
||||
}
|
||||
|
||||
function saveMedia() {
|
||||
if (embed.value === '') return;
|
||||
|
||||
isEditorDirty.value = true;
|
||||
if (startEmbed.value !== '') {
|
||||
const updatedContent = editor.value.getContent().replace(startEmbed.value, embed.value);
|
||||
editor.value.setContent(updatedContent);
|
||||
@@ -189,4 +193,27 @@ export default function useMedia(
|
||||
}
|
||||
closeMediaDrawer();
|
||||
}
|
||||
|
||||
function replaceUrlAccessToken(url: string, token: string | null | undefined): string {
|
||||
// Only process assets URL
|
||||
if (!url.includes(getPublicURL() + 'assets/')) {
|
||||
return url;
|
||||
}
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const params = new URLSearchParams(parsedUrl.search);
|
||||
|
||||
if (!token) {
|
||||
params.delete('access_token');
|
||||
} else {
|
||||
params.set('access_token', token);
|
||||
}
|
||||
|
||||
return Array.from(params).length > 0
|
||||
? `${parsedUrl.origin}${parsedUrl.pathname}?${params.toString()}`
|
||||
: `${parsedUrl.origin}${parsedUrl.pathname}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ type UsableSourceCode = {
|
||||
sourceCodeButton: SourceCodeButton;
|
||||
};
|
||||
|
||||
export default function useSourceCode(editor: Ref<any>, isEditorDirty: Ref<boolean>): UsableSourceCode {
|
||||
export default function useSourceCode(editor: Ref<any>): UsableSourceCode {
|
||||
const codeDrawerOpen = ref(false);
|
||||
const code = ref<string>();
|
||||
|
||||
@@ -35,7 +35,6 @@ export default function useSourceCode(editor: Ref<any>, isEditorDirty: Ref<boole
|
||||
}
|
||||
|
||||
function saveCode() {
|
||||
isEditorDirty.value = true;
|
||||
editor.value.setContent(code.value);
|
||||
closeCodeDrawer();
|
||||
}
|
||||
|
||||
@@ -204,8 +204,6 @@ import 'codemirror/addon/display/placeholder.js';
|
||||
|
||||
import { applyEdit, CustomSyntax, Alteration } from './edits';
|
||||
import { getPublicURL } from '@/utils/get-root-path';
|
||||
import { addTokenToURL } from '@/api';
|
||||
import escapeStringRegexp from 'escape-string-regexp';
|
||||
import useShortcut from '@/composables/use-shortcut';
|
||||
import translateShortcut from '@/utils/translate-shortcut';
|
||||
import { percentage } from '@/utils/percentage';
|
||||
@@ -338,20 +336,7 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
const markdownString = computed(() => {
|
||||
let mdString = props.value || '';
|
||||
|
||||
if (!props.imageToken) {
|
||||
const baseUrl = getPublicURL() + 'assets/';
|
||||
const regex = new RegExp(`\\]\\((${escapeStringRegexp(baseUrl)}[^\\s\\)]*)`, 'gm');
|
||||
|
||||
const images = Array.from(mdString.matchAll(regex));
|
||||
|
||||
for (const image of images) {
|
||||
mdString = mdString.replace(image[1], addTokenToURL(image[1]));
|
||||
}
|
||||
}
|
||||
|
||||
return mdString;
|
||||
return props.value || '';
|
||||
});
|
||||
|
||||
const table = reactive({
|
||||
@@ -400,7 +385,7 @@ export default defineComponent({
|
||||
url += '?access_token=' + props.imageToken;
|
||||
}
|
||||
|
||||
codemirror.replaceSelection(``);
|
||||
codemirror.replaceSelection(``);
|
||||
|
||||
imageDialogOpen.value = false;
|
||||
}
|
||||
|
||||
@@ -742,6 +742,8 @@ unlimited: Unlimited
|
||||
open_link_in: Open link in
|
||||
new_tab: New tab
|
||||
current_tab: Current tab
|
||||
save_image: Save Image
|
||||
save_media: Save Media
|
||||
wysiwyg_options:
|
||||
aligncenter: Align Center
|
||||
alignjustify: Align Justify
|
||||
@@ -1419,8 +1421,8 @@ interfaces:
|
||||
customSyntax_label: Add custom syntax types
|
||||
customSyntax_add: Add custom syntax
|
||||
box: Block / Inline
|
||||
imageToken: Image Token
|
||||
imageToken_label: What (static) token to append to image sources
|
||||
imageToken: Static Access Token
|
||||
imageToken_label: Static access token is appended to the assets' URL
|
||||
map:
|
||||
map: Map
|
||||
description: Select a location on a map
|
||||
@@ -1527,8 +1529,8 @@ interfaces:
|
||||
custom_formats: Custom Formats
|
||||
options_override: Options Override
|
||||
folder_note: Folder for uploaded files. Does not affect existing files.
|
||||
imageToken: Image Token
|
||||
imageToken_label: What (static) token to append to image sources
|
||||
imageToken: Static Access Token
|
||||
imageToken_label: Static access token is appended to the assets' URL
|
||||
input-autocomplete-api:
|
||||
input-autocomplete-api: Autocomplete Input (API)
|
||||
description: A search typeahead for external API values.
|
||||
|
||||
Reference in New Issue
Block a user