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

View File

@ -4,7 +4,7 @@ import { apiGetBase, hasError, getNetboxData } from './util';
let timeout: number = 1000; let timeout: number = 1000;
interface JobInfo { interface JobInfo {
id: Nullable<string>; url: Nullable<string>;
complete: boolean; complete: boolean;
} }
@ -23,15 +23,16 @@ function asyncTimeout(ms: number) {
function getJobInfo(): JobInfo { function getJobInfo(): JobInfo {
let complete = false; let complete = false;
const id = getNetboxData('data-job-id'); // Determine the API URL for the job status
const jobComplete = getNetboxData('data-job-complete'); const url = getNetboxData('data-job-url');
// Determine the job completion status, if present. If the job is not complete, the value will be // 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. // "None". Otherwise, it will be a stringified date.
const jobComplete = getNetboxData('data-job-complete');
if (typeof jobComplete === 'string' && jobComplete.toLowerCase() !== 'none') { if (typeof jobComplete === 'string' && jobComplete.toLowerCase() !== 'none') {
complete = true; complete = true;
} }
return { id, complete }; return { url, complete };
} }
/** /**
@ -59,10 +60,10 @@ function updateLabel(status: JobStatus) {
/** /**
* Recursively check the job's status. * Recursively check the job's status.
* @param id Job ID * @param url API URL for job result
*/ */
async function checkJobStatus(id: string) { async function checkJobStatus(url: string) {
const res = await apiGetBase<APIJobResult>(`/api/extras/job-results/${id}/`); const res = await apiGetBase<APIJobResult>(url);
if (hasError(res)) { if (hasError(res)) {
// If the response is an API error, display an error message and stop checking for job status. // If the response is an API error, display an error message and stop checking for job status.
const toast = createToast('danger', 'Error', res.error); const toast = createToast('danger', 'Error', res.error);
@ -82,17 +83,17 @@ async function checkJobStatus(id: string) {
if (timeout < 10000) { if (timeout < 10000) {
timeout += 1000; timeout += 1000;
} }
await Promise.all([checkJobStatus(id), asyncTimeout(timeout)]); await Promise.all([checkJobStatus(url), asyncTimeout(timeout)]);
} }
} }
} }
function initJobs() { 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. // 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. * Submit form configuration to the NetBox API.
*/ */
async function submitFormConfig(formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> { async function submitFormConfig(url: string, formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> {
return await apiPatch<APIUserConfig>('/api/users/config/', formConfig); return await apiPatch<APIUserConfig>(url, formConfig);
} }
/** /**
@ -66,6 +66,18 @@ function handleSubmit(event: Event): void {
const element = event.currentTarget as HTMLFormElement; 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. // Get all the selected options from any select element in the form.
const options = getSelectedOptions(element); const options = getSelectedOptions(element);
@ -83,7 +95,7 @@ function handleSubmit(event: Event): void {
const data = path.reduceRight<Dict<Dict>>((value, key) => ({ [key]: value }), formData); 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. // 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)) { if (hasError(res)) {
const toast = createToast('danger', 'Error Updating Table Configuration', res.error); const toast = createToast('danger', 'Error Updating Table Configuration', res.error);
toast.show(); toast.show();

View File

@ -1,7 +1,4 @@
import Cookie from 'cookie'; import Cookie from 'cookie';
import queryString from 'query-string';
import type { Stringifiable } from 'query-string';
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
type ReqData = URLSearchParams | Dict | undefined | unknown; type ReqData = URLSearchParams | Dict | undefined | unknown;
@ -107,84 +104,8 @@ function getCsrfToken(): string {
return csrfToken; 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>( export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
path: string, url: string,
method: Method, method: Method,
data?: D, data?: D,
): Promise<APIResponse<R>> { ): Promise<APIResponse<R>> {
@ -196,7 +117,6 @@ export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
body = JSON.stringify(data); body = JSON.stringify(data);
headers.set('content-type', 'application/json'); headers.set('content-type', 'application/json');
} }
const url = buildUrl(path);
const res = await fetch(url, { method, body, headers, credentials: 'same-origin' }); const res = await fetch(url, { method, body, headers, credentials: 'same-origin' });
const contentType = res.headers.get('Content-Type'); const contentType = res.headers.get('Content-Type');

View File

@ -1,10 +1,10 @@
{% if perms.dcim.change_cable %} {% if perms.dcim.change_cable %}
{% if cable.status == 'connected' %} {% 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> <i class="mdi mdi-lan-disconnect" aria-hidden="true"></i>
</button> </button>
{% else %} {% 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> <i class="mdi mdi-lan-connect" aria-hidden="true"></i>
</button> </button>
{% endif %} {% endif %}

View File

@ -96,6 +96,6 @@
{% endblock %} {% endblock %}
{% block data %} {% 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> <span data-job-complete="{{ result.completed }}"></span>
{% endblock %} {% endblock %}

View File

@ -112,6 +112,6 @@
{% endblock content-wrapper %} {% endblock content-wrapper %}
{% block data %} {% 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> <span data-job-complete="{{ result.completed }}"></span>
{% endblock %} {% endblock %}

View File

@ -7,7 +7,7 @@
<h5 class="modal-title">Table Configuration</h5> <h5 class="modal-title">Table Configuration</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </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="modal-body row">
<div class="col-5 text-center"> <div class="col-5 text-center">
{{ form.available_columns.label }} {{ form.available_columns.label }}