Skip to content

Commit

Permalink
Merge pull request #946 from ae-utbm/product-csv
Browse files Browse the repository at this point in the history
Rework the product admin page
  • Loading branch information
imperosol authored Dec 21, 2024
2 parents baebc0b + accf1be commit 6d02970
Show file tree
Hide file tree
Showing 24 changed files with 665 additions and 214 deletions.
5 changes: 4 additions & 1 deletion core/static/bundled/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(

// 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 <T>(
endpoint: PaginatedEndpoint<T>,
options?: PaginatedRequest,
) => {
): Promise<T[]> => {
const maxPerPage = 199;
const queryParams = options ?? {};
queryParams.query = queryParams.query ?? {};
Expand Down
49 changes: 49 additions & 0 deletions core/static/bundled/utils/csv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { NestedKeyOf } from "#core:utils/types";

interface StringifyOptions<T extends object> {
/** The columns to include in the resulting CSV. */
columns: readonly NestedKeyOf<T>[];
/** Content of the first row */
titleRow?: readonly string[];
}

function getNested<T extends object>(obj: T, key: NestedKeyOf<T>) {
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: <T extends object>(objs: T[], options?: StringifyOptions<T>) => {
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}`;
},
};
37 changes: 37 additions & 0 deletions core/static/bundled/utils/types.d.ts
Original file line number Diff line number Diff line change
@@ -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<Bar>) {
* console.log(key);
* }
*
* foo("foo.foo_inner"); // OK
* foo("foo.bar"); // FAIL
* ```
*/
export type NestedKeyOf<T extends object> = {
[Key in keyof T & (string | number)]: NestedKeyOfHandleValue<T[Key], `${Key}`>;
}[keyof T & (string | number)];

type NestedKeyOfInner<T extends object> = {
[Key in keyof T & (string | number)]: NestedKeyOfHandleValue<
T[Key],
`['${Key}']` | `.${Key}`
>;
}[keyof T & (string | number)];

type NestedKeyOfHandleValue<T, Text extends string> = T extends unknown[]
? Text
: T extends object
? Text | `${Text}${NestedKeyOfInner<T>}`
: Text;
96 changes: 96 additions & 0 deletions core/static/core/components/card.scss
Original file line number Diff line number Diff line change
@@ -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
}
}

2 changes: 1 addition & 1 deletion core/static/core/forms.scss
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ form {
}
}

label {
label, legend {
display: block;
margin-bottom: 8px;

Expand Down
32 changes: 32 additions & 0 deletions core/static/core/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -198,6 +205,9 @@ body {
margin: 20px auto 0;

/*---------------------------------NAV---------------------------------*/
a.btn {
display: inline-block;
}
.btn {
font-size: 15px;
font-weight: normal;
Expand Down Expand Up @@ -252,6 +262,13 @@ body {
}
}

/**
* A spacer below an element. Somewhat cleaner than putting <br/> everywhere.
*/
.margin-bottom {
margin-bottom: 1.5rem;
}

/*--------------------------------CONTENT------------------------------*/
#quick_notif {
width: 100%;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion core/static/user/user_detail.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@import "core/static/core/colors";

main {
box-sizing: border-box;
display: flex;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion core/templates/core/macros.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@
nb_page (str): call to a javascript function or variable returning
the maximum number of pages to paginate
#}
<nav class="pagination" x-show="{{ nb_pages }} > 1">
<nav class="pagination" x-show="{{ nb_pages }} > 1" x-cloak>
{# Adding the prevent here is important, because otherwise,
clicking on the pagination buttons could submit the picture management form
and reload the page #}
Expand Down
33 changes: 33 additions & 0 deletions counter/static/bundled/counter/components/ajax-select-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
type CounterSchema,
type ProductTypeSchema,
type SimpleProductSchema,
counterSearchCounter,
productSearchProducts,
producttypeFetchAll,
} from "#openapi";

@registerComponent("product-ajax-select")
Expand Down Expand Up @@ -34,6 +36,37 @@ export class ProductAjaxSelect extends AjaxSelect {
}
}

@registerComponent("product-type-ajax-select")
export class ProductTypeAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["name"];
private productTypes = null as ProductTypeSchema[];

protected async search(query: string): Promise<TomOption[]> {
// The production database has a grand total of 26 product types
// and the filter logic is really simple.
// Thus, it's appropriate to fetch all product types during first use,
// then to reuse the result again and again.
if (this.productTypes === null) {
this.productTypes = (await producttypeFetchAll()).data || null;
}
return this.productTypes.filter((t) =>
t.name.toLowerCase().includes(query.toLowerCase()),
);
}

protected renderOption(item: ProductTypeSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}

protected renderItem(item: ProductTypeSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}

@registerComponent("counter-ajax-select")
export class CounterAjaxSelect extends AjaxSelect {
protected valueField = "id";
Expand Down
Loading

0 comments on commit 6d02970

Please sign in to comment.