qa: Improve design

This commit is contained in:
nemunaire 2022-11-07 03:47:48 +01:00
parent 13588fc634
commit 0e19b59452
19 changed files with 493 additions and 247 deletions

11
qa/ui/src/app.css Normal file
View File

@ -0,0 +1,11 @@
.level .level-item {
text-align: center;
}
.level .level-item .heading {
font-variant: small-caps;
text-transform: lowercase;
}
.level .level-item .value {
font-size: 1.2rem;
font-weight: bolder;
}

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="fr">
<html lang="fr" class="d-flex flex-column mh-100 h-100">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@ -10,7 +10,7 @@
<meta name="robots" content="none">
%sveltekit.head%
</head>
<body>
<body class="flex-fill d-flex flex-column">
%sveltekit.body%
</body>
</html>

View File

@ -0,0 +1,41 @@
<script>
import {
Badge,
} from 'sveltestrap';
export { className as class };
let className = '';
export let state = "ok";
let color = "";
$: {
switch (state) {
case "ok":
color = "success";
break;
case "issue-flag":
case "issue-statement":
case "issue-mcq":
case "issue-hint":
case "issue-file":
case "issue":
color = "danger";
break;
case "orthograph":
case "suggest":
color = "info";
break;
case "too-hard":
case "too-easy":
color = "warning";
break;
default:
color = "secondary";
break;
}
}
</script>
<Badge {color} class={className}>
{state}
</Badge>

View File

@ -3,12 +3,15 @@
import {
Button,
Col,
Container,
Icon,
Row,
Table,
} from 'sveltestrap';
import QAItems from '$lib/components/QAItems.svelte';
import QAItem from '$lib/components/QAItem.svelte';
import QANewItem from '$lib/components/QANewItem.svelte';
import { themes, themesIdx } from '$lib/stores/themes';
@ -16,49 +19,87 @@
themes.refresh();
}
export let theme_id = null;
export let exercice = {};
let query_selected = null;
let countCreation = 0;
</script>
<h2>
{#if query_selected}
<Button
class="float-start"
color="link"
on:click={() => query_selected = null}
>
<Icon name="chevron-left" />
</Button>
{:else if theme_id}
<Button
class="float-start"
color="link"
on:click={() => goto('themes/' + theme_id)}
>
<Icon name="chevron-left" />
</Button>
{:else}
<Button
class="float-start"
color="link"
on:click={() => goto('exercices/')}
>
<Icon name="chevron-left" />
</Button>
{/if}
{exercice.title}
{#if exercice.wip}
<Icon name="cone-striped" />
{/if}
{#if $themes.length && $themesIdx[exercice.id_theme]}
<small>
<a href="themes/{exercice.id_theme}" title={$themesIdx[exercice.id_theme].authors}>{$themesIdx[exercice.id_theme].name}</a>
</small>
{#if $themesIdx[exercice.id_theme].exercices && $themesIdx[exercice.id_theme].exercices[exercice.id]}
<div class="btn-group" role="group">
<a href="exercices/{$themesIdx[exercice.id_theme].exercices[exercice.id].previous}" title="Exercice précédent" class:disabled={!$themesIdx[exercice.id_theme].exercices[exercice.id].previous} class="btn btn-sm btn-light"><Icon name="chevron-left" /></a>
<a href="exercices/{$themesIdx[exercice.id_theme].exercices[exercice.id].next}" title="Exercice suivant" class:disabled={!$themesIdx[exercice.id_theme].exercices[exercice.id].next} class="btn btn-sm btn-light"><Icon name="chevron-right" /></a>
</div>
{/if}
<a href="../{$themesIdx[exercice.id_theme].urlid}/{exercice.urlid}" target="_self" class="float-right ml-2 btn btn-sm btn-info"><Icon name="play-fill" /> Site du challenge</a>
{/if}
</h2>
<div class="row mb-3">
<div
class="col-md-6"
style="overflow-y: auto; max-height: 50vh;"
>
{@html exercice.statement}
</div>
<div
class="col-md-6"
style="overflow-y: auto; max-height: 50vh;"
>
{@html exercice.overview}
</div>
</div>
<Container fluid class="flex-fill d-flex">
<Row class="flex-fill">
<Col md={3} class="px-0 py-2" style="background: #e7e8e9">
{#key countCreation}
<QAItems
bind:query_selected={query_selected}
{exercice}
/>
{/key}
</Col>
<Col md={9} class="d-flex flex-column py-2">
{#if query_selected}
<QAItem
bind:query_selected={query_selected}
on:update-queries={() => countCreation++}
/>
{:else}
<Row class="mb-3">
<div
class="col-md-6"
style="overflow-y: auto; max-height: 40vh;"
>
{@html exercice.statement}
</div>
<div
class="col-md-6"
style="overflow-y: auto; max-height: 40vh;"
>
{@html exercice.overview}
</div>
</Row>
<div class="mb-5">
<QANewItem
{exercice}
on:new-query={() => countCreation++}
/>
{#key countCreation}
<QAItems
{exercice}
/>
{/key}
</div>
<QANewItem
{exercice}
on:new-query={() => countCreation++}
/>
{/if}
</Col>
</Row>
</Container>

View File

@ -33,9 +33,9 @@
}
</script>
<Navbar color="dark" dark expand="md">
<Navbar color="dark" dark expand="xs">
<NavbarBrand href=".">
<img src="../img/fic.png" alt="FIC">
<img src="../img/fic.png" alt="FIC" height="26">
QA
</NavbarBrand>
<Nav navbar>
@ -45,7 +45,7 @@
active={activemenu === ''}
>
<Icon name="house-door" />
Accueil
<span class="d-none d-md-inline">Accueil</span>
</NavLink>
</NavItem>
<NavItem>
@ -54,7 +54,7 @@
active={activemenu === 'themes'}
>
<Icon name="box-seam" />
Scénarios
<span class="d-none d-md-inline">Scénarios</span>
</NavLink>
</NavItem>
<NavItem>
@ -63,7 +63,7 @@
active={activemenu === 'exercices'}
>
<Icon name="bar-chart-steps" />
Étapes
<span class="d-none d-md-inline">Étapes</span>
</NavLink>
</NavItem>
<NavItem>
@ -72,7 +72,7 @@
active={activemenu === 'teams'}
>
<Icon name="people" />
Équipes
<span class="d-none d-md-inline">Équipes</span>
</NavLink>
</NavItem>
<NavItem>
@ -81,12 +81,12 @@
active={activemenu === 'repositories'}
>
<Icon name="archive" />
Dépôts
<span class="d-none d-md-inline">Dépôts</span>
</NavLink>
</NavItem>
</Nav>
<Nav class="ms-auto text-light" navbar>
<NavItem class="ms-2">
<NavItem class="ms-2 text-truncate">
v{$version.version}
{#if $auth}&ndash; Logged as {$auth.name} (team #{$auth.id_team}){/if}
</NavItem>

View File

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import {
Icon,
Spinner,
} from 'sveltestrap';
@ -45,6 +46,9 @@
<div class={className}>
<h3>Vos étapes</h3>
{#await my_exercicesP}
<div class="text-center">
<Spinner size="lg" />
</div>
{:then}
<table class="table table-stripped table-hover">
<thead>

View File

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import {
Icon,
Spinner,
} from 'sveltestrap';
@ -30,8 +31,14 @@
<div class={className}>
<h3>Étapes à tester et valider</h3>
{#await todos.refresh()}
<div class="text-center">
<Spinner size="lg" />
</div>
{:then}
{#await exo_doneP}
<div class="text-center">
<Spinner size="lg" />
</div>
{:then exo_done}
<table class="table table-stripped table-hover">
<thead>

View File

@ -2,24 +2,63 @@
import { createEventDispatcher } from 'svelte';
import {
Alert,
Button,
Card,
CardBody,
CardHeader,
Col,
Icon,
Row,
Spinner,
} from 'sveltestrap';
import BadgeState from '$lib/components/BadgeState.svelte';
import DateFormat from '$lib/components/DateFormat.svelte';
import { getQAComments, QAComment } from '$lib/qa.js';
import { getQAComments, QAComment } from '$lib/qa';
import { auth } from '$lib/stores/auth';
import { viewIdx } from '$lib/stores/todo';
const dispatch = createEventDispatcher();
export let query_selected;
let query_commentsP;
$: query_commentsP = getQAComments(query_selected.id);
let thumbs = [];
let thumb_me = [];
let has_comments = false;
$: updateComments(query_selected)
function updateComments(query_selected) {
if (query_selected) {
query_commentsP = getQAComments(query_selected.id);
query_commentsP.then((comments) => {
thumbs = [];
thumb_me = [];
has_comments = false;
for (const c of comments) {
has_comments = true;
if (c.content == "+1") {
let found = false;
for (const t of thumbs) {
if (t == c.user) {
found = true;
break;
}
}
if (!found) {
thumbs.push(c.user);
}
if (c.user == $auth.name) {
thumb_me.push(c);
}
}
}
})
}
}
let newComment = new QAComment();
let submissionInProgress = false;
@ -65,119 +104,182 @@
dispatch("update-queries");
})
}
function deleteMyThumbs() {
if (thumb_me.length) {
for (const c of thumb_me) {
c.delete(query_selected.id);
}
dispatch("update-queries");
}
}
function deleteComment(comment) {
comment.delete(query_selected.id).then(() => {
dispatch("update-queries");
})
}
</script>
<Card>
<CardHeader>
<div class="d-flex justify-content-between">
<h4 class="card-title mb-0">{query_selected.subject}</h4>
<div>
{#if $auth && $auth.id_team == query_selected.id_team}
{#if query_selected.solved && !query_selected.closed}
<Button on:click={closeQA} color="success">
<Icon name="check" />
Valider la résolution
</Button>
<Button on:click={reopenQA} color="danger">
<Icon name="x" />
Réouvrir
</Button>
{/if}
{#if !query_selected.solved}
<Button on:click={deleteQA} color="danger">
<Icon name="trash-fill" />
Supprimer
</Button>
{/if}
{:else if $auth && !query_selected.solved}
<Button on:click={solveQA} color="success">
<Icon name="check" />
Marquer comme résolu
</Button>
{/if}
</div>
</div>
</CardHeader>
<CardBody>
<div class="row">
<dl class="col row">
<dt class="col-3">Qui ?</dt>
<dd class="col-9">{query_selected.user} (team #{query_selected.id_team})</dd>
<dt class="col-3">État</dt>
<dd class="col-9">{query_selected.state}</dd>
<dt class="col-3">Date de création</dt>
<dd class="col-9">
<DateFormat date={query_selected.creation} dateStyle="long" timeStyle="medium" />
</dd>
<dt class="col-3">Date de résolution</dt>
<dd class="col-9">
{#if query_selected.solved}
<DateFormat date={query_selected.solved} dateStyle="long" timeStyle="medium" />
{:else}
-
{/if}
</dd>
<dt class="col-3">Date de clôture</dt>
<dd class="col-9">
{#if query_selected.closed}
<DateFormat date={query_selected.closed} dateStyle="long" timeStyle="medium" />
{:else}
-
{/if}
</dd>
</dl>
<div class="col-auto">
{#if $auth && $auth.id_team == query_selected.id_team}
<Button on:click={updateQA} color="primary">
<Icon name="upload" />
Mettre à jour
</Button>
{/if}
</div>
</div>
{#await query_commentsP}
<div class="d-flex">
<Spinner />
{#if query_selected}
<Card>
<CardHeader>
<div class="d-flex justify-content-between align-items-center">
<h4 class="card-title fw-bold mb-0">{query_selected.subject}</h4>
<div>
Chargement des commentaires en cours&hellip;
{#if $auth && !query_selected.solved && $viewIdx[query_selected.id_exercice]}
<Button on:click={solveQA} color="success">
<Icon name="check" />
Marquer comme résolu
</Button>
{/if}
{#if $auth && $auth.id_team == query_selected.id_team}
{#if query_selected.solved && !query_selected.closed && (query_selected.subject != "RAS" || query_selected.state != "ok")}
<Button on:click={closeQA} color="success">
<Icon name="check" />
Valider la résolution
</Button>
<Button on:click={reopenQA} color="danger">
<Icon name="x" />
Réouvrir
</Button>
{/if}
{#if (!query_selected.solved && !has_comments) || (query_selected.subject == "RAS" && query_selected.state == "ok" && !has_comments)}
<Button on:click={deleteQA} color="danger">
<Icon name="trash-fill" />
Supprimer
</Button>
{/if}
{/if}
</div>
</div>
{:then query_comments}
<table class="table table-striped">
{#each query_comments as comment (comment.id)}
<tr>
<td style="white-space: pre-line">
Le <DateFormat date={comment.date} dateStyle="medium" timeStyle="short" />, <strong>{comment.user}</strong> a écrit&nbsp;: {comment.content}
</td>
</tr>
{/each}
</table>
{/await}
<form on:submit|preventDefault={addComment}>
<label for="newComment">Répondre :</label>
<textarea
class="form-control"
placeholder="Ajouter un commentaire"
rows="2"
id="newComment"
bind:value={newComment.content}
></textarea>
<Button
type="submit"
color="primary"
class="mt-1 float-right"
disabled={!newComment.content || newComment.content.length == 0 || submissionInProgress}
>
{#if submissionInProgress}
<Spinner size="sm" />
{/if}
Ajouter le commentaire
</Button>
</form>
</CardHeader>
<CardBody>
<Row class="level" cols={5}>
<Col>
<div class="level-item">
<div class="heading">
Qui ?
</div>
<div class="value">
{query_selected.user.split("@")[0]}
</div>
<div class="text-muted">
(team #{query_selected.id_team})
</div>
</div>
</Col>
</CardBody>
</Card>
<Col>
<div class="level-item">
<div class="heading">
État
</div>
<BadgeState class="value" state={query_selected.state} />
<div
class="text-muted"
title={thumbs.join(', ')}
on:click={deleteMyThumbs}
>
<Icon
name="hand-thumbs-up-fill"
class={thumb_me.length?"text-info":""}
style={thumb_me.length?"cursor: pointer;":""}
/>
{thumbs.length}
</div>
</div>
</Col>
<Col>
<div class="level-item">
<div class="heading">
Date de création
</div>
<div class="value">
<DateFormat date={query_selected.creation} dateStyle="long" timeStyle="medium" />
</div>
</div>
</Col>
<Col>
<div class="level-item">
<div class="heading">
Date de résolution
</div>
<div class="value">
{#if query_selected.solved}
<DateFormat date={query_selected.solved} dateStyle="long" timeStyle="medium" />
{:else}
-
{/if}
</div>
</div>
</Col>
<Col>
<div class="level-item">
<div class="heading">
Date de clôture
</div>
<div class="value">
{#if query_selected.closed}
<DateFormat date={query_selected.closed} dateStyle="long" timeStyle="medium" />
{:else}
-
{/if}
</div>
</div>
</Col>
</Row>
<hr>
{#await query_commentsP then query_comments}
{#each query_comments as comment (comment.id)}
{#if comment.content != "+1"}
<Alert fade={false}>
<div style="white-space: pre-line">{comment.content}</div>
<div class="d-flex justify-content-end align-items-center">
{#if comment.user == $auth.name}
<Button
size="sm"
color="danger"
class="me-2"
on:click={() => deleteComment(comment)}
>
<Icon name="trash-fill" />
</Button>
{/if}
<em>
Par <strong>{comment.user}</strong>, le <DateFormat date={comment.date} dateStyle="medium" timeStyle="short" />
</em>
</div>
</Alert>
{/if}
{/each}
{/await}
<form on:submit|preventDefault={addComment}>
<textarea
class="form-control"
placeholder="Ajouter votre commentaire"
rows="2"
id="newComment"
bind:value={newComment.content}
></textarea>
{#if newComment.content && newComment.content.length > 0}
<Button
type="submit"
color="primary"
class="mt-1 float-right"
disabled={submissionInProgress}
>
{#if submissionInProgress}
<Spinner size="sm" />
{/if}
Ajouter le commentaire
</Button>
{/if}
</form>
</CardBody>
</Card>
{/if}

View File

@ -6,13 +6,14 @@
} from 'sveltestrap';
import { getExerciceQA, QAComment } from '$lib/qa.js';
import QAItem from '$lib/components/QAItem.svelte';
import BadgeState from '$lib/components/BadgeState.svelte';
export { className as class };
let className = '';
export let exercice = { };
export let query_selected = null;
const fields = [ "state", "subject" ];
let queriesP;
$: queriesP = getExerciceQA(exercice.id);
@ -34,62 +35,46 @@
{#await queriesP}
{:then queries}
<table
class="table table-bordered table-striped"
class:table-hover={queries.length}
class:table-sm={queries.length}
>
<thead class="thead-dark">
<tr>
{#each fields as field}
<th>
{ field }
</th>
{/each}
<th></th>
</tr>
</thead>
<div class="list-group {className}" class:list-group-flush={true}>
{#if queries.length}
<tbody>
{#each queries as q (q.id)}
<tr on:click={() => query_selected = q} class:bg-warning={query_selected && q.id == query_selected.id}>
{#each fields as field}
<td>
{@html q[field]}
</td>
{/each}
<td>
<button
type="button"
class="btn btn-sm btn-light"
disabled={thumbInProgress !== null}
on:click|preventDefault={() => thumbUp(q.id)}
>
{#if thumbInProgress == q.id}
<Spinner size="sm" />
{:else}
<Icon name="hand-thumbs-up" />
{/if}
</button>
</td>
</tr>
{/each}
</tbody>
{:else}
<tbody>
<tr>
<td colspan={fields.length+1} class="font-weight-bold text-info text-center">
Aucune requête enregistrée
</td>
</tr>
</tbody>
{#each queries as q (q.id)}
<button
type="button"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
class:active={query_selected && q.id == query_selected.id}
aria-current="true"
on:click={() => query_selected = q}
>
<div class="text-truncate">
<BadgeState state={q.state} />
{#if !q.solved}
<strong>{q.subject}</strong>
{:else if !q.closed}
{q.subject}
{:else}
<s>{q.subject}</s>
{/if}
</div>
{#if !q.closed}
<button
type="button"
class="btn btn-sm btn-info"
disabled={thumbInProgress !== null}
on:click|preventDefault={() => thumbUp(q.id)}
>
{#if thumbInProgress == q.id}
<Spinner size="sm" />
{:else}
<Icon name="hand-thumbs-up-fill" />
{/if}
</button>
{/if}
</button>
{/each}
{:else}
<div class="fw-bold text-center">
Aucune requête enregistrée
</div>
{/if}
</table>
{#if query_selected}
<QAItem
bind:query_selected={query_selected}
on:update-queries={updateQueries}
/>
{/if}
</div>
{/await}

View File

@ -91,7 +91,7 @@ export class QAComment {
method: 'DELETE',
headers: {'Accept': 'application/json'}
});
if (res.status == 200) {
if (res.status < 300) {
return true;
} else {
throw new Error((await res.json()).errmsg);

View File

@ -37,3 +37,19 @@ export const exercicesIdx = derived(
return exercices_idx;
},
);
export const exercicesByTheme = derived(
exercices,
$exercices => {
const exercices_idx = { };
for (const e of $exercices) {
if (!exercices_idx[e.id_theme]) {
exercices_idx[e.id_theme] = []
}
exercices_idx[e.id_theme].push(e);
}
return exercices_idx;
},
);

View File

@ -1,6 +1,6 @@
import { writable } from 'svelte/store';
import { writable, derived } from 'svelte/store';
import { getQAWork } from '$lib/todo'
import { getQAView, getQAWork } from '$lib/todo';
function createTodosStore() {
const { subscribe, set, update } = writable([]);
@ -24,3 +24,39 @@ function createTodosStore() {
}
export const todos = createTodosStore();
function createViewStore() {
const { subscribe, set, update } = writable([]);
return {
subscribe,
set: (v) => {
update((m) => Object.assign(m, v));
},
update,
refresh: async () => {
const list = await getQAView();
update((m) => list);
return list;
},
};
}
export const view = createViewStore();
export const viewIdx = derived(
view,
$view => {
const idx = { };
for (const v of $view) {
idx[v.id_exercice] = v;
}
return idx;
}
);

View File

@ -1,4 +1,6 @@
<script>
import '../app.css';
import {
Container,
Styles,
@ -6,9 +8,13 @@
import Header from '$lib/components/Header.svelte';
import { version } from '$lib/stores/auth';
import { view } from '$lib/stores/todo';
version.refresh();
setInterval(version.refresh, 30000);
view.refresh();
setInterval(view.refresh, 60000);
</script>
<svelte:head>

View File

@ -1,6 +1,7 @@
<script>
import {
Alert,
Col,
Container,
Row,
} from 'sveltestrap';
@ -32,7 +33,11 @@
</p>
</Alert>
<Row>
<MyTodo class="col-6" />
<MyExercices class="col-6" />
<Col>
<MyTodo />
</Col>
<Col>
<MyExercices />
</Col>
</Row>
</Container>

View File

@ -47,6 +47,9 @@
{#each fieldsExercices as field}
<td>
{@html exercice[field]}
{#if field == "title" && exercice.wip}
<Icon name="cone-striped" />
{/if}
</td>
{/each}
</tr>

View File

@ -15,19 +15,12 @@
let exerciceP = getExercice($page.params.eid);
</script>
<Container class="mt-2 mb-5">
{#await exerciceP}
{#await exerciceP}
<Container class="mt-2 mb-5">
<div class="d-flex justify-content-center">
<Spinner size="lg" />
</div>
{:then exercice}
<Button
class="float-start"
color="link"
on:click={() => goto('exercices')}
>
<Icon name="chevron-left" />
</Button>
<ExerciceQA {exercice} />
{/await}
</Container>
</Container>
{:then exercice}
<ExerciceQA {exercice} />
{/await}

View File

@ -41,9 +41,9 @@
{#if theme.name.indexOf(query) >= 0 || theme.authors.indexOf(query) >= 0 || theme.intro.indexOf(query) >= 0}
<tr on:click={() => show(theme.id)}>
{#each fields as field}
<td>
<td class:text-end={field == "image"}>
{#if field == "image"}
<img src={"../files" + theme[field]} alt="Image du scénario">
<img src={"../files" + theme[field]} alt="Image du scénario" height="120">
{:else}
{@html theme[field]}
{/if}

View File

@ -40,13 +40,13 @@
<small class="m-2 mb-3 text-muted text-truncate">{@html theme.authors}</small>
</div>
<Container class="text-muted">
<Container class="text-muted" style="overflow-y: auto; max-height: 34vh">
{@html theme.intro}
</Container>
{#await getThemedExercices($page.params.tid)}
{:then exercices}
<h3>
<h3 class="mt-2">
Défis ({exercices.length})
</h3>
@ -70,6 +70,9 @@
{#each fieldsExercices as field}
<td>
{@html exercice[field]}
{#if field == "title" && exercice.wip}
<Icon name="cone-striped" />
{/if}
</td>
{/each}
</tr>

View File

@ -15,19 +15,12 @@
let exerciceP = getThemedExercice($page.params.tid, $page.params.eid);
</script>
<Container class="mt-2 mb-5">
{#await exerciceP}
{#await exerciceP}
<Container class="mt-2 mb-5">
<div class="d-flex justify-content-center">
<Spinner size="lg" />
</div>
{:then exercice}
<Button
class="float-start"
color="link"
on:click={() => goto('themes/' + $page.params.tid)}
>
<Icon name="chevron-left" />
</Button>
<ExerciceQA {exercice} />
{/await}
</Container>
</Container>
{:then exercice}
<ExerciceQA theme_id={$page.params.tid} {exercice} />
{/await}