diff --git a/core/static/bundled/utils/api.ts b/core/static/bundled/utils/api.ts index ac647cd7..5d72b3b6 100644 --- a/core/static/bundled/utils/api.ts +++ b/core/static/bundled/utils/api.ts @@ -22,10 +22,13 @@ type PaginatedEndpoint = ( // TODO : If one day a test workflow is made for JS in this project // please test this function. A all cost. +/** + * Load complete dataset from paginated routes. + */ export const paginated = async ( endpoint: PaginatedEndpoint, options?: PaginatedRequest, -) => { +): Promise => { const maxPerPage = 199; const queryParams = options ?? {}; queryParams.query = queryParams.query ?? {}; diff --git a/core/static/bundled/utils/csv.ts b/core/static/bundled/utils/csv.ts new file mode 100644 index 00000000..df1a5ebf --- /dev/null +++ b/core/static/bundled/utils/csv.ts @@ -0,0 +1,49 @@ +import type { NestedKeyOf } from "#core:utils/types"; + +interface StringifyOptions { + /** The columns to include in the resulting CSV. */ + columns: readonly NestedKeyOf[]; + /** Content of the first row */ + titleRow?: readonly string[]; +} + +function getNested(obj: T, key: NestedKeyOf) { + const path: (keyof object)[] = key.split(".") as (keyof unknown)[]; + let res = obj[path.shift() as keyof T]; + for (const node of path) { + if (res === null) { + break; + } + res = res[node]; + } + return res; +} + +/** + * Convert the content the string to make sure it won't break + * the resulting csv. + * cf. https://en.wikipedia.org/wiki/Comma-separated_values#Basic_rules + */ +function sanitizeCell(content: string): string { + return `"${content.replace(/"/g, '""')}"`; +} + +export const csv = { + stringify: (objs: T[], options?: StringifyOptions) => { + const columns = options.columns; + const content = objs + .map((obj) => { + return columns + .map((col) => { + return sanitizeCell((getNested(obj, col) ?? "").toString()); + }) + .join(","); + }) + .join("\n"); + if (!options.titleRow) { + return content; + } + const firstRow = options.titleRow.map(sanitizeCell).join(","); + return `${firstRow}\n${content}`; + }, +}; diff --git a/core/static/bundled/utils/types.d.ts b/core/static/bundled/utils/types.d.ts new file mode 100644 index 00000000..e9040c67 --- /dev/null +++ b/core/static/bundled/utils/types.d.ts @@ -0,0 +1,37 @@ +/** + * A key of an object, or of one of its descendants. + * + * Example : + * ```typescript + * interface Foo { + * foo_inner: number; + * } + * + * interface Bar { + * foo: Foo; + * } + * + * const foo = (key: NestedKeyOf) { + * console.log(key); + * } + * + * foo("foo.foo_inner"); // OK + * foo("foo.bar"); // FAIL + * ``` + */ +export type NestedKeyOf = { + [Key in keyof T & (string | number)]: NestedKeyOfHandleValue; +}[keyof T & (string | number)]; + +type NestedKeyOfInner = { + [Key in keyof T & (string | number)]: NestedKeyOfHandleValue< + T[Key], + `['${Key}']` | `.${Key}` + >; +}[keyof T & (string | number)]; + +type NestedKeyOfHandleValue = T extends unknown[] + ? Text + : T extends object + ? Text | `${Text}${NestedKeyOfInner}` + : Text; diff --git a/core/static/core/components/card.scss b/core/static/core/components/card.scss new file mode 100644 index 00000000..1cbb2601 --- /dev/null +++ b/core/static/core/components/card.scss @@ -0,0 +1,96 @@ +@import "core/static/core/colors"; + +@mixin row-layout { + min-height: 100px; + width: 100%; + max-width: 100%; + display: flex; + flex-direction: row; + gap: 10px; + .card-image { + max-width: 75px; + } + .card-content { + flex: 1; + text-align: left; + } +} + +.card { + background-color: $primary-neutral-light-color; + border-radius: 5px; + position: relative; + box-sizing: border-box; + padding: 20px 10px; + height: fit-content; + width: 150px; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + + &:hover { + background-color: darken($primary-neutral-light-color, 5%); + } + + &.selected { + animation: bg-in-out 1s ease; + background-color: rgb(216, 236, 255); + } + + .card-image { + width: 100%; + height: 100%; + min-height: 70px; + max-height: 70px; + object-fit: contain; + border-radius: 4px; + line-height: 70px; + } + + i.card-image { + color: black; + text-align: center; + background-color: rgba(173, 173, 173, 0.2); + width: 80%; + } + + .card-content { + color: black; + display: flex; + flex-direction: column; + gap: 5px; + width: 100%; + + p { + font-size: 13px; + margin: 0; + } + + .card-title { + margin: 0; + font-size: 15px; + word-break: break-word; + } + } + + @keyframes bg-in-out { + 0% { + background-color: white; + } + 100% { + background-color: rgb(216, 236, 255); + } + } + + @media screen and (max-width: 765px) { + @include row-layout + } + + // When combined with card, card-row display the card in a row layout, + // whatever the size of the screen. + &.card-row { + @include row-layout + } +} + diff --git a/core/static/core/forms.scss b/core/static/core/forms.scss index 42a4d719..36f41d94 100644 --- a/core/static/core/forms.scss +++ b/core/static/core/forms.scss @@ -107,7 +107,7 @@ form { } } - label { + label, legend { display: block; margin-bottom: 8px; diff --git a/core/static/core/style.scss b/core/static/core/style.scss index d7a396d1..61eb71e0 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -19,6 +19,13 @@ body { --loading-stroke: 5px; --loading-duration: 1s; position: relative; + + &.aria-busy-grow { + // Make sure the element take enough place to hold the loading wheel + min-height: calc((var(--loading-size)) * 1.5); + min-width: calc((var(--loading-size)) * 1.5); + overflow: hidden; + } } [aria-busy]:after { @@ -198,6 +205,9 @@ body { margin: 20px auto 0; /*---------------------------------NAV---------------------------------*/ + a.btn { + display: inline-block; + } .btn { font-size: 15px; font-weight: normal; @@ -252,6 +262,13 @@ body { } } + /** + * A spacer below an element. Somewhat cleaner than putting
everywhere. + */ + .margin-bottom { + margin-bottom: 1.5rem; + } + /*--------------------------------CONTENT------------------------------*/ #quick_notif { width: 100%; @@ -409,6 +426,21 @@ body { } } + .row { + display: flex; + flex-wrap: wrap; + + $col-gap: 1rem; + &.gap { + column-gap: var($col-gap); + } + @for $i from 2 through 5 { + &.gap-#{$i}x { + column-gap: $i * $col-gap; + } + } + } + /*---------------------------------NEWS--------------------------------*/ #news { display: flex; diff --git a/core/static/user/user_detail.scss b/core/static/user/user_detail.scss index 87a3d199..60985f16 100644 --- a/core/static/user/user_detail.scss +++ b/core/static/user/user_detail.scss @@ -1,3 +1,5 @@ +@import "core/static/core/colors"; + main { box-sizing: border-box; display: flex; @@ -69,7 +71,7 @@ main { border-radius: 50%; justify-content: center; align-items: center; - background-color: #f2f2f2; + background-color: $primary-neutral-light-color; > span { font-size: small; diff --git a/core/templates/core/macros.jinja b/core/templates/core/macros.jinja index 6ab52cad..e624e87a 100644 --- a/core/templates/core/macros.jinja +++ b/core/templates/core/macros.jinja @@ -140,7 +140,7 @@ nb_page (str): call to a javascript function or variable returning the maximum number of pages to paginate #} -