Merge pull request #866 from ae-utbm/openapi

Typescript support and auto generated typescript client API
This commit is contained in:
Bartuccio Antoine
2024-10-11 09:30:35 +02:00
committed by GitHub
28 changed files with 1398 additions and 461 deletions

View File

@ -123,37 +123,3 @@ function updateQueryString(key, value, action = History.REPLACE, url = null) {
return ret;
}
// TODO : If one day a test workflow is made for JS in this project
// please test this function. A all cost.
/**
* Given a paginated endpoint, fetch all the items of this endpoint,
* performing multiple API calls if necessary.
* @param {string} url The paginated endpoint to fetch
* @return {Promise<Object[]>}
*/
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
async function fetchPaginated(url) {
const maxPerPage = 199;
const paginatedUrl = new URL(url, document.location.origin);
paginatedUrl.searchParams.set("page_size", maxPerPage.toString());
paginatedUrl.searchParams.set("page", "1");
const firstPage = await (await fetch(paginatedUrl)).json();
const results = firstPage.results;
const nbPictures = firstPage.count;
const nbPages = Math.ceil(nbPictures / maxPerPage);
if (nbPages > 1) {
const promises = [];
for (let i = 2; i <= nbPages; i++) {
paginatedUrl.searchParams.set("page", i.toString());
promises.push(
fetch(paginatedUrl).then((res) => res.json().then((json) => json.results)),
);
}
results.push(...(await Promise.all(promises)).flat());
}
return results;
}

View File

@ -2,25 +2,21 @@
import "codemirror/lib/codemirror.css";
import "easymde/src/css/easymde.css";
import easyMde from "easymde";
// This scripts dependens on Alpine but it should be loaded on every page
import { markdownRenderMarkdown } from "#openapi";
/**
* Create a new easymde based textarea
* @param {HTMLTextAreaElement} textarea to use
* @param {string} link to the markdown api
**/
window.easymdeFactory = (textarea, markdownApiUrl) => {
window.easymdeFactory = (textarea) => {
const easymde = new easyMde({
element: textarea,
spellChecker: false,
autoDownloadFontAwesome: false,
previewRender: Alpine.debounce(async (plainText, preview) => {
const res = await fetch(markdownApiUrl, {
method: "POST",
body: JSON.stringify({ text: plainText }),
});
preview.innerHTML = await res.text();
preview.innerHTML = (
await markdownRenderMarkdown({ body: { text: plainText } })
).data;
return null;
}, 300),
forceSync: true, // Avoid validation error on generic create view

View File

@ -1,16 +1,26 @@
import cytoscape from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay from "cytoscape-klay";
import { familyGetFamilyGraph } from "#openapi";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
async function getGraphData(url, godfathersDepth, godchildrenDepth) {
const data = await (
await fetch(
`${url}?godfathers_depth=${godfathersDepth}&godchildren_depth=${godchildrenDepth}`,
)
).json();
async function getGraphData(userId, godfathersDepth, godchildrenDepth) {
const data = (
await familyGetFamilyGraph({
path: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
user_id: userId,
},
query: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
godfathers_depth: godfathersDepth,
// biome-ignore lint/style/useNamingConvention: api is snake_case
godchildren_depth: godchildrenDepth,
},
})
).data;
return [
...data.users.map((user) => {
return { data: user };
@ -160,15 +170,14 @@ function createGraph(container, data, activeUserId) {
/**
* @typedef FamilyGraphConfig
* @param {string} apiUrl Base url for fetching the tree as a string
* @param {string} activeUser Id of the user to fetch the tree from
* @param {number} depthMin Minimum tree depth for godfathers and godchildren
* @param {number} depthMax Maximum tree depth for godfathers and godchildren
* @property {number} activeUser Id of the user to fetch the tree from
* @property {number} depthMin Minimum tree depth for godfathers and godchildren
* @property {number} depthMax Maximum tree depth for godfathers and godchildren
**/
/**
* Create a family graph of an user
* @param {FamilyGraphConfig} Configuration
* @param {FamilyGraphConfig} config
**/
window.loadFamilyGraph = (config) => {
document.addEventListener("alpine:init", () => {
@ -248,7 +257,7 @@ window.loadFamilyGraph = (config) => {
async fetchGraphData() {
this.graphData = await getGraphData(
config.apiUrl,
config.activeUser,
this.godfathersDepth,
this.godchildrenDepth,
);

View File

@ -1,5 +1,7 @@
import { paginated } from "#core:utils/api";
import { HttpReader, ZipWriter } from "@zip.js/zip.js";
import { showSaveFilePicker } from "native-file-system-adapter";
import { picturesFetchPictures } from "#openapi";
/**
* @typedef UserProfile
@ -28,12 +30,12 @@ import { showSaveFilePicker } from "native-file-system-adapter";
/**
* @typedef PicturePageConfig
* @param {string} apiUrl Url of the api endpoint to fetch pictures from the user
* @property {number} userId Id of the user to get the pictures from
**/
/**
* Load user picture page with a nice download bar
* @param {PicturePageConfig} Configuration
* @param {PicturePageConfig} config
**/
window.loadPicturePage = (config) => {
document.addEventListener("alpine:init", () => {
@ -44,8 +46,10 @@ window.loadPicturePage = (config) => {
albums: {},
async init() {
// biome-ignore lint/correctness/noUndeclaredVariables: imported from script.json
this.pictures = await fetchPaginated(config.apiUrl);
this.pictures = await paginated(picturesFetchPictures, {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
query: { users_identified: [config.userId] },
});
this.albums = this.pictures.reduce((acc, picture) => {
if (!acc[picture.album]) {
acc[picture.album] = [];

View File

@ -0,0 +1,81 @@
import type { Client, Options, RequestResult } from "@hey-api/client-fetch";
import { client } from "#openapi";
interface PaginatedResponse<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
interface PaginatedRequest {
query?: {
page?: number;
// biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size?: number;
};
}
type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
options?: Options<PaginatedRequest, ThrowOnError>,
) => RequestResult<PaginatedResponse<T>, unknown, ThrowOnError>;
// TODO : If one day a test workflow is made for JS in this project
// please test this function. A all cost.
export const paginated = async <T>(
endpoint: PaginatedEndpoint<T>,
options?: PaginatedRequest,
) => {
const maxPerPage = 199;
options.query.page_size = maxPerPage;
options.query.page = 1;
const firstPage = (await endpoint(options)).data;
const results = firstPage.results;
const nbElements = firstPage.count;
const nbPages = Math.ceil(nbElements / maxPerPage);
if (nbPages > 1) {
const promises: Promise<T[]>[] = [];
for (let i = 2; i <= nbPages; i++) {
const nextPage = structuredClone(options);
nextPage.query.page = i;
promises.push(endpoint(nextPage).then((res) => res.data.results));
}
results.push(...(await Promise.all(promises)).flat());
}
return results;
};
interface Request {
client?: Client;
}
interface InterceptorOptions {
url: string;
}
type GenericEndpoint = <ThrowOnError extends boolean = false>(
options?: Options<Request, ThrowOnError>,
) => RequestResult<unknown, unknown, ThrowOnError>;
/**
* Return the endpoint url of the endpoint
**/
export const makeUrl = async (endpoint: GenericEndpoint) => {
let url = "";
const interceptor = (_request: undefined, options: InterceptorOptions) => {
url = options.url;
throw new Error("We don't want to send the request");
};
client.interceptors.request.use(interceptor);
try {
await endpoint({ client: client });
} catch (_error) {
/* do nothing */
}
client.interceptors.request.eject(interceptor);
return url;
};

View File

@ -24,7 +24,7 @@
<script src="{{ static('webpack/alpine-index.js') }}" defer></script>
<!-- Jquery declared here to be accessible in every django widgets -->
<script src="{{ static('webpack/jquery-index.js') }}"></script>
<!-- Put here to always have acces to those functions on django widgets -->
<!-- Put here to always have access to those functions on django widgets -->
<script src="{{ static('core/js/script.js') }}"></script>
<script defer src="{{ static('vendored/select2/select2.min.js') }}"></script>
<script defer src="{{ static('core/js/sith-select2.js') }}"></script>

View File

@ -92,8 +92,7 @@
<script>
window.addEventListener("DOMContentLoaded", () => {
loadFamilyGraph({
apiUrl: "{{ api_url }}",
activeUser: "{{ object.id }}",
activeUser: {{ object.id }},
depthMin: {{ depth_min }},
depthMax: {{ depth_max }},
});

View File

@ -62,9 +62,7 @@
{{ super() }}
<script>
window.addEventListener("DOMContentLoaded", () => {
loadPicturePage({
apiUrl: "{{ url("api:pictures") }}?users_identified={{ object.id }}"
});
loadPicturePage({ userId: {{ object.id }} });
})
</script>
{% endblock script %}

View File

@ -7,9 +7,7 @@
<script type="text/javascript">
addEventListener("DOMContentLoaded", (event) => {
easymdeFactory(
document.getElementById("{{ widget.attrs.id }}"),
"{{ markdown_api_url }}",
);
document.getElementById("{{ widget.attrs.id }}"));
})
</script>
</div>

View File

@ -39,7 +39,6 @@ from django.forms import (
TextInput,
)
from django.templatetags.static import static
from django.urls import reverse
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
@ -76,7 +75,6 @@ class MarkdownInput(Textarea):
"js": static("webpack/easymde-index.js"),
"css": static("webpack/easymde-index.css"),
}
context["markdown_api_url"] = reverse("api:markdown")
return context