Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ et le versionnement sur [Semantic Versioning](http://semver.org/).

### Changed

- Clés étrangères et conditions affichées comme du SQL (mots-clés en bleu gras, chaînes en vert), modifiables par un crayon. Badges colorés : présence des fichiers attendus (obligatoire en rouge, recommandé en orange, facultatif en vert), contraintes des colonnes (valeur obligatoire, colonne obligatoire, unique). « Monter » et « Descendre » retirés des fichiers attendus. Bouton « Équivalents » renommé « Appliquer aux colonnes / fichiers / listes semblables ».
- Arborescence des fichiers attendus (arbre et vue du modèle) : à chaque niveau, les fichiers d'abord, puis les dossiers. Regex mises en valeur : encadrées en police à chasse fixe, variables de `variables.json` en bleu gras, variables facultatives en jaune. Accueil de l'éditeur en ligne : pleine largeur, sans les paragraphes de confidentialité et de commandes.
- Vue d'un modèle en onglets « Modèle », « Fichiers », « Tables » (nouveau : modèles de table du modèle avec présence, nombre de colonnes et problèmes, tables non référencées) et « Listes de codes » ; l'onglet suit l'adresse (fichier de l'arbre, problème cliqué). Bandeau de synthèse en tuiles qui ouvrent l'onglet correspondant, types de fichiers au pluriel. Bouton « Listes de codes (N) » retiré. « Télécharger le JSON » (ou le CSV) à côté des onglets Vue / Source, et non plus dans le titre.
- Vue d'un modèle (`files.json`) : fichiers attendus rangés selon leur arborescence, dossiers sur fond bleuté et contenu en retrait avec un filet bleu, icône par type de fichier.
Expand Down
25 changes: 25 additions & 0 deletions src/core/sql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Découpage d'une clé étrangère ou d'une condition (syntaxe SQL du validator) pour l'affichage :
// mots-clés connus, chaînes, nombres, identifiants et le reste, dans l'ordre du texte.
export type SqlKind = 'keyword' | 'string' | 'number' | 'identifier' | 'other'
export interface SqlToken {
kind: SqlKind
text: string
}

export const SQL_KEYWORDS = new Set([
'REFERENCES', 'AND', 'OR', 'NOT', 'IS', 'NULL', 'IN', 'LIKE', 'ILIKE', 'BETWEEN', 'TRUE', 'FALSE',
'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'EXISTS', 'SELECT', 'FROM', 'WHERE', 'LENGTH', 'UPPER', 'LOWER', 'SUBSTRING',
])

const TOKEN = /('(?:[^']|'')*'?)|(\b\d+(?:\.\d+)?\b)|(\b[A-Za-z_][A-Za-z0-9_]*\b)|(\s+|[^\sA-Za-z0-9_']+)/g

export function sqlTokens(text: string): SqlToken[] {
const out: SqlToken[] = []
for (const [all, string, number, word] of text.matchAll(TOKEN)) {
const kind: SqlKind = string ? 'string' : number ? 'number' : word ? (SQL_KEYWORDS.has(word.toUpperCase()) ? 'keyword' : 'identifier') : 'other'
const last = out.at(-1)
if (kind === 'other' && last?.kind === 'other') last.text += all
else out.push({ kind, text: all })
}
return out
}
5 changes: 4 additions & 1 deletion src/ui/components/EquivalentsDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const all = ref(false)
const property = ref<string>()
const unchecked = ref(new Set<string>())

// Libellé du bouton selon l'élément : colonnes, fichiers ou listes semblables des autres modèles.
const BUTTON: Record<Source['kind'], string> = { column: 'Appliquer aux colonnes semblables', file: 'Appliquer aux fichiers semblables', code: 'Appliquer aux listes semblables' }

const LABELS: Record<string, string> = {
'/type': 'Type', '/title': 'Titre', '/description': 'Description', '/constraints/required': 'Valeur obligatoire',
'/constraints/presenceRequired': 'Colonne obligatoire', '/constraints/unique': 'Unique', '/constraints/minLength': 'Longueur minimale',
Expand Down Expand Up @@ -93,7 +96,7 @@ function apply() {
<template>
<v-dialog v-model="open" max-width="900" scrollable>
<template #activator="{ props: activator }">
<v-btn v-bind="activator" :prepend-icon="mdiSourceBranchSync" size="small" variant="text" data-testid="equivalents">Équivalents</v-btn>
<v-btn v-bind="activator" :prepend-icon="mdiSourceBranchSync" size="small" variant="text" data-testid="equivalents">{{ BUTTON[source.kind] }}</v-btn>
</template>
<v-card :title="`Équivalents de ${label}`" :subtitle="all ? 'Toute la configuration' : 'Modèles de la même famille'" data-testid="equivalents-dialog">
<v-card-text>
Expand Down
11 changes: 4 additions & 7 deletions src/ui/components/ModelView.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { mdiArrowDown, mdiArrowUp, mdiCompareHorizontal, mdiDatabaseOutline, mdiDelete, mdiFileDocumentOutline, mdiFileOutline, mdiFilePdfBox, mdiFolderDownload, mdiFolderOutline, mdiFormatListBulleted, mdiPlus, mdiTable } from '@mdi/js'
import { mdiCompareHorizontal, mdiDatabaseOutline, mdiDelete, mdiFileDocumentOutline, mdiFileOutline, mdiFilePdfBox, mdiFolderDownload, mdiFolderOutline, mdiPlus, mdiTable } from '@mdi/js'
import { computed, ref, watch } from 'vue'
import { FILE_TYPE_LABELS, MANDATORY_LABELS, fileTypeLabel, mandatoryLabel } from '@core/labels'
import { previousVersion } from '@core/compare'
import { renameCodeList } from '@core/edit/codelists'
import { addCode, addFile, codeName, moveItem, removeItem, setOrRemove, type DocumentList } from '@core/edit/operations'
import { addCode, addFile, codeName, removeItem, setOrRemove } from '@core/edit/operations'
import { setAt } from '@core/edit/pointer'
import { fileTree, type FileNode } from '@core/model/file-tree'
import { resolvePath, tableModelRef } from '@core/model/paths'
Expand Down Expand Up @@ -131,7 +131,6 @@ function newCode() {
const data = csvChoices.value.find((c) => !declared.has(c)) ?? ''
edit((d) => addCode(d, store.ids(), data, codeName(data) || 'NOUVELLE_LISTE'))
}
const move = (list: DocumentList, i: number, to: number) => edit((d) => moveItem(d, list, i, to))
</script>

<template>
Expand Down Expand Up @@ -200,7 +199,7 @@ const move = (list: DocumentList, i: number, to: number) => edit((d) => moveItem
<strong>{{ f.name }}</strong>
<ProblemBadge :file="path!" :pointer="`/files/${i}`" />
<span class="text-medium-emphasis">{{ fileTypeLabel(f.type) }}</span>
<span class="tag">{{ mandatoryLabel(f.mandatory) }}</span>
<span class="presence" :class="`presence-${f.mandatory ?? 'WARN'}`" data-testid="file-presence">{{ mandatoryLabel(f.mandatory) }}</span>
<RegexText :value="f.path" :model="name" class="path" />
</div>
</v-expansion-panel-title>
Expand Down Expand Up @@ -232,8 +231,6 @@ const move = (list: DocumentList, i: number, to: number) => edit((d) => moveItem
<v-textarea label="Description" :model-value="f.description" density="compact" variant="outlined" rows="1" auto-grow
@update:model-value="(v: string) => setProp(pointer('files', i, 'description'), v)" />
<div class="d-flex flex-wrap ga-2">
<v-btn v-if="!store.readOnly" :prepend-icon="mdiArrowUp" size="small" variant="text" :disabled="i === 0" @click="move('files', i, i - 1)">Monter</v-btn>
<v-btn v-if="!store.readOnly" :prepend-icon="mdiArrowDown" size="small" variant="text" :disabled="i === doc.files.length - 1" @click="move('files', i, i + 1)">Descendre</v-btn>
<EquivalentsDialog :source="{ kind: 'file', file: path!, pointer: pointer('files', i) }" :label="`${name} › ${f.name}`" />
<v-spacer />
<v-btn v-if="!store.readOnly" :prepend-icon="mdiDelete" size="small" variant="text" color="error" data-testid="remove-file" @click="edit((d) => removeItem(d, 'files', i))">Supprimer le fichier</v-btn>
Expand All @@ -256,7 +253,7 @@ const move = (list: DocumentList, i: number, to: number) => edit((d) => moveItem
<span v-else class="text-error">{{ t.name }} (modèle de table introuvable)</span>
<a v-if="t.file !== t.name" class="file-link" :href="href(path!, pointer('files', t.index))">fichier {{ t.file }}</a>
</th>
<td>{{ mandatoryLabel(t.mandatory) }}</td>
<td><span class="presence" :class="`presence-${t.mandatory ?? 'WARN'}`">{{ mandatoryLabel(t.mandatory) }}</span></td>
<td>{{ t.columns ?? '—' }}</td>
<td><ProblemCount :counts="t.path ? store.countsByFile.get(t.path) : undefined" /><span v-if="!t.path || !store.countsByFile.get(t.path)">aucun</span></td>
</tr>
Expand Down
33 changes: 33 additions & 0 deletions src/ui/components/SqlText.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import { sqlTokens } from '@core/sql'

// Clé étrangère ou condition affichée comme du SQL : mots-clés connus en bleu gras.
const props = defineProps<{ value: string }>()
const tokens = computed(() => sqlTokens(props.value))
</script>

<template>
<code class="sql" data-testid="sql"><span v-for="(t, i) in tokens" :key="i" :class="t.kind">{{ t.text }}</span></code>
</template>

<style scoped>
.sql {
display: inline-block;
padding: 4px 8px;
border: 1px solid rgb(var(--v-theme-divider));
border-radius: 4px;
background: rgb(var(--v-theme-surface-light));
color: rgb(var(--v-theme-on-surface));
font-family: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.keyword {
color: rgb(var(--v-theme-primary));
font-weight: 700;
}
.string {
color: rgb(var(--v-theme-success));
}
</style>
40 changes: 30 additions & 10 deletions src/ui/components/TableView.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { mdiArrowDown, mdiArrowUp, mdiDelete, mdiPlus } from '@mdi/js'
import { mdiArrowDown, mdiArrowUp, mdiDelete, mdiPencil, mdiPlus } from '@mdi/js'
import { computed, ref, watch } from 'vue'
import { setAt } from '@core/edit/pointer'
import {
Expand All @@ -16,6 +16,7 @@ import { useProject } from '../stores/project'
import FileFrame from './FileFrame.vue'
import ProblemBadge from './ProblemBadge.vue'
import RegexText from './RegexText.vue'
import SqlText from './SqlText.vue'
import EquivalentsDialog from './EquivalentsDialog.vue'
import NotConformant from './NotConformant.vue'

Expand Down Expand Up @@ -59,12 +60,12 @@ function add() {
}

// Résumé des contraintes ; le motif s'affiche avec ses variables, regex complète au survol (010).
function constraints(c: ColumnConstraints | undefined): { text: string; title?: string; regex?: string }[] {
function constraints(c: ColumnConstraints | undefined): { text: string; title?: string; regex?: string; tone?: string }[] {
if (!c) return []
const out: { text: string; title?: string; regex?: string }[] = []
if (c.required) out.push({ text: 'valeur obligatoire' })
if (c.presenceRequired) out.push({ text: 'colonne obligatoire' })
if (c.unique) out.push({ text: 'unique' })
const out: { text: string; title?: string; regex?: string; tone?: string }[] = []
if (c.required) out.push({ text: 'valeur obligatoire', tone: 'presence presence-ERROR' })
if (c.presenceRequired) out.push({ text: 'colonne obligatoire', tone: 'presence presence-WARN' })
if (c.unique) out.push({ text: 'unique', tone: 'unique' })
if (c.minLength !== undefined) out.push({ text: `longueur ≥ ${c.minLength}` })
if (c.maxLength !== undefined && c.maxLength >= 0) out.push({ text: `longueur ≤ ${c.maxLength}` })
if (c.pattern) out.push({ text: `motif ${store.regexDisplay(c.pattern, model.value)}`, title: c.pattern, regex: c.pattern })
Expand All @@ -84,6 +85,9 @@ function target(text: string): { label: string; path?: string } | undefined {
return { label: fk.target, ...(tablePath && { path: tablePath }) }
}

/** Clé étrangère ou condition en cours de saisie (`<liste>/<indice>`). */
const editing = ref<string>()

const LISTS: { key: TableList; title: string; placeholder: string }[] = [
{ key: 'foreignKeys', title: 'Clés étrangères', placeholder: '(COLONNE) REFERENCES Liste(COLONNE)' },
{ key: 'conditions', title: 'Conditions', placeholder: 'COLONNE IS NOT NULL OR …' },
Expand Down Expand Up @@ -116,7 +120,7 @@ const LISTS: { key: TableList; title: string; placeholder: string }[] = [
<strong>{{ c.name }}</strong>
<ProblemBadge :file="file" :pointer="`/columns/${i}`" />
<span class="text-medium-emphasis">{{ c.type }}<template v-if="columnTypeFamily(c.type)"> · {{ columnTypeFamily(c.type) }}</template></span>
<span v-for="k in constraints(c.constraints)" :key="k.text" class="constraint" :title="k.title"><template v-if="k.regex">motif <RegexText :value="k.regex" :model="model" /></template><template v-else>{{ k.text }}</template></span>
<span v-for="k in constraints(c.constraints)" :key="k.text" :class="k.tone ?? 'constraint'" :title="k.title"><template v-if="k.regex">motif <RegexText :value="k.regex" :model="model" /></template><template v-else>{{ k.text }}</template></span>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
Expand Down Expand Up @@ -168,9 +172,17 @@ const LISTS: { key: TableList; title: string; placeholder: string }[] = [
<v-spacer />
<v-btn v-if="!store.readOnly" :prepend-icon="mdiPlus" variant="tonal" :data-testid="`add-${list.key}`" @click="edit((t) => addListItem(t, list.key))">Ajouter</v-btn>
</div>
<div v-for="(text, i) in table.constraints?.[list.key] ?? []" :key="i" class="d-flex align-center ga-2" :data-pointer="`/constraints/${list.key}/${i}`">
<v-text-field :label="`${list.title} ${i + 1}`" :model-value="text" :placeholder="list.placeholder" density="compact" variant="outlined" hide-details class="mono"
:data-testid="`${list.key}-${i}`" @update:model-value="(v: string) => edit((t) => setListItem(t, list.key, i, v), `/constraints/${list.key}/${i}`)" />
<div v-for="(text, i) in table.constraints?.[list.key] ?? []" :key="i" class="d-flex align-center ga-2 my-1" :data-pointer="`/constraints/${list.key}/${i}`">
<!-- Affichage SQL (mots-clés en bleu gras) ; le crayon passe en saisie, un élément vide s'édite d'emblée. -->
<v-text-field v-if="!text || editing === `${list.key}/${i}`" :label="`${list.title} ${i + 1}`" :model-value="text" :placeholder="list.placeholder"
density="compact" variant="outlined" hide-details class="mono" autofocus :data-testid="`${list.key}-${i}`"
@update:model-value="(v: string) => edit((t) => setListItem(t, list.key, i, v), `/constraints/${list.key}/${i}`)"
@blur="editing = undefined" @keydown.enter="editing = undefined" />
<template v-else>
<SqlText :value="text" :data-testid="`${list.key}-${i}-sql`" />
<v-btn v-if="!store.readOnly" :icon="mdiPencil" size="small" variant="text" :aria-label="`Modifier ${list.title.toLowerCase()} ${i + 1}`"
:data-testid="`${list.key}-${i}-edit`" @click="editing = `${list.key}/${i}`" />
</template>
<template v-if="list.key === 'foreignKeys' && target(text)">
→
<a v-if="target(text)!.path" :href="href(target(text)!.path!)">{{ target(text)!.label }}</a>
Expand All @@ -184,6 +196,14 @@ const LISTS: { key: TableList; title: string; placeholder: string }[] = [
</template>

<style scoped>
.unique {
padding: 0 8px;
border-radius: 10px;
border: 1px solid rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-primary));
font-size: 0.8rem;
font-weight: 600;
}
.field {
min-width: 16rem;
flex: 1 1 16rem;
Expand Down
36 changes: 36 additions & 0 deletions src/ui/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,39 @@ h3,
.v-navigation-drawer .v-list-item--active .v-list-item-title {
font-weight: 700;
}

/* Présence d'un fichier attendu : obligatoire en rouge, recommandé en orange, facultatif en vert
(texte foncé sur fond clair, contraste ≥ 4,5:1 ; équivalents sombres). */
.presence {
display: inline-block;
padding: 0 8px;
border-radius: 10px;
border: 1px solid currentColor;
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
}
.presence-ERROR {
color: #8c1d18;
background: #fde7e5;
}
.presence-WARN {
color: #7a4100;
background: #fff0d6;
}
.presence-OPTIONAL {
color: #1e5631;
background: #e3f4e8;
}
.v-theme--dark .presence-ERROR {
color: #ffb4ab;
background: rgba(255, 180, 171, 0.12);
}
.v-theme--dark .presence-WARN {
color: #ffcc80;
background: rgba(255, 204, 128, 0.12);
}
.v-theme--dark .presence-OPTIONAL {
color: #a5d6a7;
background: rgba(165, 214, 167, 0.12);
}
3 changes: 3 additions & 0 deletions tests/e2e/002-us2-table.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ test('annule et rétablit, aux boutons et au clavier', async ({ page }) => {
})

test('modifie une clé étrangère et conserve les propriétés inconnues', async ({ page }) => {
// Affichage SQL (REFERENCES en mot-clé), puis saisie par le crayon.
await expect(page.getByTestId('foreignKeys-0-sql').locator('.keyword')).toHaveText('REFERENCES')
await page.getByTestId('foreignKeys-0-edit').click()
const fk = page.getByTestId('foreignKeys-0').getByRole('textbox')
await fk.fill('(TYPEZONE) REFERENCES Inconnue(TYPEZONE)')
await expect(page.getByTestId('problem-ERR-FK-TARGET-LIST')).toBeVisible()
Expand Down
29 changes: 29 additions & 0 deletions tests/e2e/sql-badges.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { expect, test } from '@playwright/test'

// Clés étrangères et conditions en SQL, badges de présence colorés, boutons « semblables ».
test('conditions et clés étrangères affichées comme du SQL, mots-clés en bleu gras', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' })
await page.goto('./#/cnig_PLU_2017/types/DOC_URBA.json')
const condition = page.getByTestId('conditions-0-sql')
await expect(condition).toContainText('IS NOT NULL')
const keyword = condition.locator('.keyword').first()
await expect(keyword).toHaveCSS('color', 'rgb(26, 95, 180)')
await expect(keyword).toHaveCSS('font-weight', '700')
await expect(page.getByTestId('foreignKeys-0-sql').locator('.keyword')).toHaveText('REFERENCES')
})

test('fichiers attendus : présence colorée, sans « Monter » ni « Descendre », bouton « fichiers semblables »', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' })
await page.goto('./#/cnig_PLU_2017/files.json')
await page.getByTestId('model-tab-files').click()
const zone = page.getByTestId('file-ZONE_URBA')
await expect(zone.getByTestId('file-presence')).toHaveText('Obligatoire')
await expect(zone.getByTestId('file-presence')).toHaveCSS('color', 'rgb(140, 29, 24)')
await zone.click()
await expect(zone.getByRole('button', { name: 'Monter' })).toHaveCount(0)
await expect(zone.getByRole('button', { name: 'Descendre' })).toHaveCount(0)
await expect(zone.getByTestId('equivalents')).toHaveText('Appliquer aux fichiers semblables')
await page.goto('./#/cnig_PLU_2017/types/ZONE_URBA.json')
await page.getByTestId('column-TYPEZONE').click()
await expect(page.getByTestId('column-TYPEZONE').getByTestId('equivalents')).toHaveText('Appliquer aux colonnes semblables')
})
28 changes: 28 additions & 0 deletions tests/unit/sql.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { sqlTokens } from '@core/sql'

describe('sqlTokens', () => {
it('clé étrangère : REFERENCES en mot-clé, colonnes et liste en identifiants', () => {
expect(sqlTokens('(TYPEPSC, STYPEPSC) REFERENCES PrescriptionUrbaType(TYPEPSC, STYPEPSC)').filter((t) => t.kind !== 'other')).toEqual([
{ kind: 'identifier', text: 'TYPEPSC' },
{ kind: 'identifier', text: 'STYPEPSC' },
{ kind: 'keyword', text: 'REFERENCES' },
{ kind: 'identifier', text: 'PrescriptionUrbaType' },
{ kind: 'identifier', text: 'TYPEPSC' },
{ kind: 'identifier', text: 'STYPEPSC' },
])
})

it('condition : mots-clés sans tenir compte de la casse, chaînes et nombres ; texte restitué à l\'identique', () => {
const text = "DATEFIN IS NOT NULL or ETAT NOT IN ('04', 'l''a') AND LENGTH(X) > 2"
const tokens = sqlTokens(text)
expect(tokens.map((t) => t.text).join('')).toBe(text)
expect(tokens.filter((t) => t.kind === 'keyword').map((t) => t.text)).toEqual(['IS', 'NOT', 'NULL', 'or', 'NOT', 'IN', 'AND', 'LENGTH'])
expect(tokens.filter((t) => t.kind === 'string').map((t) => t.text)).toEqual(["'04'", "'l''a'"])
expect(tokens.filter((t) => t.kind === 'number').map((t) => t.text)).toEqual(['2'])
})

it('chaîne non fermée : gardée telle quelle', () => {
expect(sqlTokens("ETAT = 'ab").map((t) => t.text).join('')).toBe("ETAT = 'ab")
})
})
Loading