Merge pull request #7218 from netbox-community/7162-base-path-bug2

Fixes #7162: Decouple base path rendering from API request logic
This commit is contained in:
Jeremy Stretch 2021-09-08 16:19:49 -04:00 committed by GitHub
commit 8c1a01d5ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 36 additions and 103 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -8,12 +8,12 @@ import { isTruthy, apiPatch, hasError, getElements } from '../util';
* @param element Connection Toggle Button Element
*/
function toggleConnection(element: HTMLButtonElement): void {
const id = element.getAttribute('data');
const url = element.getAttribute('data-url');
const connected = element.classList.contains('connected');
const status = connected ? 'planned' : 'connected';
if (isTruthy(id)) {
apiPatch(`/api/dcim/cables/${id}/`, { status }).then(res => {
if (isTruthy(url)) {
apiPatch(url, { status }).then(res => {
if (hasError(res)) {
// If the API responds with an error, show it to the user.
createToast('danger', 'Error', res.error).show();

View File

@ -4,7 +4,7 @@ import { apiGetBase, hasError, getNetboxData } from './util';
let timeout: number = 1000;
interface JobInfo {
id: Nullable<string>;
url: Nullable<string>;
complete: boolean;
}
@ -23,15 +23,16 @@ function asyncTimeout(ms: number) {
function getJobInfo(): JobInfo {
let complete = false;
const id = getNetboxData('data-job-id');
const jobComplete = getNetboxData('data-job-complete');
// Determine the API URL for the job status
const url = getNetboxData('data-job-url');
// Determine the job completion status, if present. If the job is not complete, the value will be
// "None". Otherwise, it will be a stringified date.
const jobComplete = getNetboxData('data-job-complete');
if (typeof jobComplete === 'string' && jobComplete.toLowerCase() !== 'none') {
complete = true;
}
return { id, complete };
return { url, complete };
}
/**
@ -59,10 +60,10 @@ function updateLabel(status: JobStatus) {
/**
* Recursively check the job's status.
* @param id Job ID
* @param url API URL for job result
*/
async function checkJobStatus(id: string) {
const res = await apiGetBase<APIJobResult>(`/api/extras/job-results/${id}/`);
async function checkJobStatus(url: string) {
const res = await apiGetBase<APIJobResult>(url);
if (hasError(res)) {
// If the response is an API error, display an error message and stop checking for job status.
const toast = createToast('danger', 'Error', res.error);
@ -82,17 +83,17 @@ async function checkJobStatus(id: string) {
if (timeout < 10000) {
timeout += 1000;
}
await Promise.all([checkJobStatus(id), asyncTimeout(timeout)]);
await Promise.all([checkJobStatus(url), asyncTimeout(timeout)]);
}
}
}
function initJobs() {
const { id, complete } = getJobInfo();
const { url, complete } = getJobInfo();
if (id !== null && !complete) {
if (url !== null && !complete) {
// If there is a job ID and it is not completed, check for the job's status.
Promise.resolve(checkJobStatus(id));
Promise.resolve(checkJobStatus(url));
}
}

View File

@ -53,8 +53,8 @@ function removeColumns(event: Event): void {
/**
* Submit form configuration to the NetBox API.
*/
async function submitFormConfig(formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> {
return await apiPatch<APIUserConfig>('/api/users/config/', formConfig);
async function submitFormConfig(url: string, formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> {
return await apiPatch<APIUserConfig>(url, formConfig);
}
/**
@ -66,6 +66,18 @@ function handleSubmit(event: Event): void {
const element = event.currentTarget as HTMLFormElement;
// Get the API URL for submitting the form
const url = element.getAttribute('data-url');
if (url == null) {
const toast = createToast(
'danger',
'Error Updating Table Configuration',
'No API path defined for configuration form.'
);
toast.show();
return;
}
// Get all the selected options from any select element in the form.
const options = getSelectedOptions(element);
@ -83,7 +95,7 @@ function handleSubmit(event: Event): void {
const data = path.reduceRight<Dict<Dict>>((value, key) => ({ [key]: value }), formData);
// Submit the resulting object to the API to update the user's preferences for this table.
submitFormConfig(data).then(res => {
submitFormConfig(url, data).then(res => {
if (hasError(res)) {
const toast = createToast('danger', 'Error Updating Table Configuration', res.error);
toast.show();

View File

@ -1,7 +1,4 @@
import Cookie from 'cookie';
import queryString from 'query-string';
import type { Stringifiable } from 'query-string';
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
type ReqData = URLSearchParams | Dict | undefined | unknown;
@ -107,84 +104,8 @@ function getCsrfToken(): string {
return csrfToken;
}
/**
* Get the NetBox `settings.BASE_PATH` from the `<html/>` element's data attributes.
*
* @returns If there is no `BASE_PATH` specified, the return value will be `''`.
*/ function getBasePath(): string {
const value = document.documentElement.getAttribute('data-netbox-base-path');
if (value === null) {
return '';
}
return value;
}
/**
* Constrict an object from a URL query param string, with all values as an array.
*
* @param params URL search query string.
* @returns Constructed query object.
*/
function queryParamsToObject(params: string): Record<string, Stringifiable[]> {
const result = {} as Record<string, Stringifiable[]>;
const searchParams = new URLSearchParams(params);
for (const [key, originalValue] of searchParams.entries()) {
let value = [] as Stringifiable[];
if (key in result) {
value = [...value, ...result[key]];
}
if (Array.isArray(originalValue)) {
value = [...value, ...originalValue];
} else {
value = [...value, originalValue];
}
result[key] = value;
}
return result;
}
/**
* Build a NetBox URL that includes `settings.BASE_PATH` and enforces leading and trailing slashes.
*
* @example
* ```js
* // With a BASE_PATH of 'netbox/'
* const url = buildUrl('/api/dcim/devices');
* console.log(url);
* // => /netbox/api/dcim/devices/
* ```
*
* @param path Relative path _after_ (excluding) the `BASE_PATH`.
*/
function buildUrl(destination: string): string {
// Separate the path from any URL search params.
const [pathname, search] = destination.split(/(?=\?)/g);
// If the `origin` exists in the API path (as in the case of paginated responses), remove it.
const origin = new RegExp(window.location.origin, 'g');
const path = pathname.replaceAll(origin, '');
const basePath = getBasePath();
// Combine `BASE_PATH` with this request's path, removing _all_ slashes.
let combined = [...basePath.split('/'), ...path.split('/')].filter(p => p);
if (combined[0] !== '/') {
// Ensure the URL has a leading slash.
combined = ['', ...combined];
}
if (combined[combined.length - 1] !== '/') {
// Ensure the URL has a trailing slash.
combined = [...combined, ''];
}
const url = combined.join('/');
// Construct an object from the URL search params so it can be re-serialized with the new URL.
const query = queryParamsToObject(search);
return queryString.stringifyUrl({ url, query });
}
export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
path: string,
url: string,
method: Method,
data?: D,
): Promise<APIResponse<R>> {
@ -196,7 +117,6 @@ export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
body = JSON.stringify(data);
headers.set('content-type', 'application/json');
}
const url = buildUrl(path);
const res = await fetch(url, { method, body, headers, credentials: 'same-origin' });
const contentType = res.headers.get('Content-Type');

View File

@ -1,10 +1,10 @@
{% if perms.dcim.change_cable %}
{% if cable.status == 'connected' %}
<button type="button" class="btn btn-warning btn-sm cable-toggle connected" title="Mark Planned" data="{{ cable.pk }}">
<button type="button" class="btn btn-warning btn-sm cable-toggle connected" title="Mark Planned" data-url="{% url 'dcim-api:cable-detail' pk=cable.pk %}">
<i class="mdi mdi-lan-disconnect" aria-hidden="true"></i>
</button>
{% else %}
<button type="button" class="btn btn-info btn-sm cable-toggle" title="Mark Installed" data="{{ cable.pk }}">
<button type="button" class="btn btn-info btn-sm cable-toggle" title="Mark Installed" data-url="{% url 'dcim-api:cable-detail' pk=cable.pk %}">
<i class="mdi mdi-lan-connect" aria-hidden="true"></i>
</button>
{% endif %}

View File

@ -96,6 +96,6 @@
{% endblock %}
{% block data %}
<span data-job-id="{{ result.pk }}"></span>
<span data-job-url="{% url 'extras-api:jobresult-detail' pk=result.pk %}"></span>
<span data-job-complete="{{ result.completed }}"></span>
{% endblock %}

View File

@ -112,6 +112,6 @@
{% endblock content-wrapper %}
{% block data %}
<span data-job-id="{{ result.pk }}"></span>
<span data-job-url="{% url 'extras-api:jobresult-detail' pk=result.pk %}"></span>
<span data-job-complete="{{ result.completed }}"></span>
{% endblock %}

View File

@ -7,7 +7,7 @@
<h5 class="modal-title">Table Configuration</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form class="form-horizontal userconfigform" data-config-root="tables.{{ form.table_name }}">
<form class="form-horizontal userconfigform" data-url="{% url 'users-api:userconfig-list' %}" data-config-root="tables.{{ form.table_name }}">
<div class="modal-body row">
<div class="col-5 text-center">
{{ form.available_columns.label }}