feat: Update FaceAI upload panel and improve race storage metadata handling in tests
All checks were successful
Publish FaceAI Container / publish (push) Successful in 4m32s
All checks were successful
Publish FaceAI Container / publish (push) Successful in 4m32s
This commit is contained in:
parent
0926c52a00
commit
c0d072c6ea
6 changed files with 130 additions and 47 deletions
|
|
@ -46,8 +46,8 @@ const props = defineProps({
|
|||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
fileInput: {
|
||||
type: Object,
|
||||
assignFileInput: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
t: {
|
||||
|
|
@ -56,10 +56,6 @@ const props = defineProps({
|
|||
}
|
||||
});
|
||||
|
||||
function assignFileInput(element) {
|
||||
props.fileInput.value = element;
|
||||
}
|
||||
|
||||
const emit = defineEmits([
|
||||
'open-file-picker',
|
||||
'file-change',
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ const knownServerCodes = {
|
|||
RATE_LIMITED: 'rateLimited',
|
||||
MISSING_SELFIE: 'chooseSelfie',
|
||||
RACE_PKL_UNAVAILABLE: 'raceDataUnavailable',
|
||||
RACE_DIRECTORY_NOT_FOUND: 'invalidRaceData',
|
||||
RACE_DIRECTORY_NOT_FOUND: 'raceDataUnavailable',
|
||||
MISSING_RACE_STORAGE: 'invalidRaceData'
|
||||
};
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ const simulatorUrl = legacyUrl('/faceai_simulator.php?raceId=101&lang=it');
|
|||
const legacyHomeUrl = legacyUrl('/');
|
||||
|
||||
function isInvalidRaceAvailability(availability) {
|
||||
return availability?.reasonCode === 'RACE_DIRECTORY_NOT_FOUND' || availability?.reasonCode === 'MISSING_RACE_STORAGE';
|
||||
return availability?.reasonCode === 'MISSING_RACE_STORAGE';
|
||||
}
|
||||
|
||||
function buildLegacyReturnUrl(url) {
|
||||
|
|
@ -204,8 +204,9 @@ export function useFaceAiHome() {
|
|||
}
|
||||
|
||||
function getAvailabilityUserMessage(availability, fallbackKey = 'unavailableDefault') {
|
||||
if (isInvalidRaceAvailability(availability)) {
|
||||
return t('invalidRaceData');
|
||||
const mappedCodeKey = availability?.reasonCode ? knownServerCodes[availability.reasonCode] : null;
|
||||
if (mappedCodeKey) {
|
||||
return t(mappedCodeKey);
|
||||
}
|
||||
|
||||
return localizeServerMessage(availability?.message, fallbackKey);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ const {
|
|||
submitSearch,
|
||||
t
|
||||
} = useFaceAiHome();
|
||||
|
||||
function assignFileInput(element) {
|
||||
fileInput.value = element;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -63,7 +67,7 @@ const {
|
|||
:selected-file="selectedFile"
|
||||
:selected-file-size-label="selectedFileSizeLabel"
|
||||
:can-start-search="canStartSearch"
|
||||
:file-input="fileInput"
|
||||
:assign-file-input="assignFileInput"
|
||||
:t="t"
|
||||
@open-file-picker="openFilePicker"
|
||||
@file-change="onFileChange"
|
||||
|
|
|
|||
|
|
@ -201,6 +201,13 @@ test('builds the simulator FaceAI handoff URL with the exact local race storage
|
|||
});
|
||||
|
||||
test('shows the unsupported-race message when the current race has no PKL data and lets the user go back', async ({ page }) => {
|
||||
const consoleErrors = [];
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await launchFromSimulator(page, {
|
||||
raceId: '404',
|
||||
raceSlug: 'corsa-di-livorno',
|
||||
|
|
@ -211,6 +218,9 @@ test('shows the unsupported-race message when the current race has no PKL data a
|
|||
await expect(page.locator('.faceai-feedback')).toContainText('FaceAI non è disponibile per questa gara.');
|
||||
await expect(page.locator('input[type="file"]')).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: 'Scegli immagine' })).toBeDisabled();
|
||||
await expect.poll(() => {
|
||||
return consoleErrors.find((entry) => entry.includes('[FaceAI] Invalid race data:')) || null;
|
||||
}).toBeNull();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page).toHaveURL(FACEAI_HOME_URL_RE);
|
||||
|
|
@ -219,7 +229,7 @@ test('shows the unsupported-race message when the current race has no PKL data a
|
|||
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('404'));
|
||||
});
|
||||
|
||||
test('shows a localized invalid-race error when session race data points to a missing folder', async ({ page }) => {
|
||||
test('shows a localized invalid-race error when the handoff omits race storage metadata', async ({ page }) => {
|
||||
const consoleErrors = [];
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
|
|
@ -227,17 +237,18 @@ test('shows a localized invalid-race error when session race data points to a mi
|
|||
}
|
||||
});
|
||||
|
||||
const simulatorUrl = buildSimulatorUrl({
|
||||
const handoffUrl = new URL(buildHandoffUrl({
|
||||
raceId: '405',
|
||||
lang: 'en',
|
||||
raceSlug: 'ghost-race',
|
||||
raceName: 'Ghost Race',
|
||||
raceFolder: 'THIS RACE DOES NOT EXIST'
|
||||
});
|
||||
}));
|
||||
handoffUrl.searchParams.delete('raceYear');
|
||||
handoffUrl.searchParams.delete('raceMonthFolder');
|
||||
handoffUrl.searchParams.delete('raceFolder');
|
||||
|
||||
await page.goto(simulatorUrl, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('#faceaiLaunchButton')).toBeVisible();
|
||||
await page.locator('#faceaiLaunchButton').click();
|
||||
await page.goto(handoffUrl.toString(), { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await page.waitForURL(FACEAI_HOME_URL_RE, {
|
||||
timeout: SHORT_UI_TIMEOUT_MS
|
||||
|
|
@ -250,10 +261,10 @@ test('shows a localized invalid-race error when session race data points to a mi
|
|||
await expect(page.getByRole('button', { name: 'Back to the race page' })).toBeVisible();
|
||||
await expect.poll(() => {
|
||||
return consoleErrors.find((entry) => entry.includes('[FaceAI] Invalid race data:')) || null;
|
||||
}).toContain('RACE_DIRECTORY_NOT_FOUND');
|
||||
}).toContain('MISSING_RACE_STORAGE');
|
||||
await expect.poll(() => {
|
||||
return consoleErrors.find((entry) => entry.includes('[FaceAI] Invalid race data:')) || null;
|
||||
}).toContain('THIS RACE DOES NOT EXIST');
|
||||
}).toContain('MISSING_RACE_STORAGE');
|
||||
|
||||
await page.getByRole('button', { name: 'Back to the race page' }).click();
|
||||
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('405'));
|
||||
|
|
|
|||
|
|
@ -1,30 +1,36 @@
|
|||
const { test, expect } = require('@playwright/test');
|
||||
const {
|
||||
LIVE_EXPECTED_RACE_STORAGE,
|
||||
LIVE_FACEAI_BASE_URL,
|
||||
LIVE_SITE_BASE_URL,
|
||||
LIVE_SITE_EXPECT_RACE_DATA_AVAILABLE,
|
||||
LIVE_SITE_EXPECT_UNAVAILABLE_REASON_CODE,
|
||||
LIVE_SITE_PORTRAIT_PATH,
|
||||
LIVE_SITE_RACE_ID,
|
||||
LIVE_SITE_RACE_URL,
|
||||
LIVE_SITE_RUN_UPLOAD_FLOW,
|
||||
LIVE_SITE_SAMPLE_PHOTO_IDS,
|
||||
ensureLiveAuthenticatedRacePage,
|
||||
hasExpectedRaceStorage,
|
||||
requirePortraitFixture
|
||||
} = require('./live-site-test-utils');
|
||||
|
||||
const LIVE_EXPECTED_RACE_STORAGE = {
|
||||
year: '2026',
|
||||
monthFolder: '03.MARZO',
|
||||
raceFolder: 'HMF_2026',
|
||||
relativeDir: '2026/03.MARZO/HMF_2026'
|
||||
};
|
||||
|
||||
const LIVE_SAMPLE_PHOTO_IDS = [
|
||||
'21.ARRIVO_CON TEMPO\\DSC_8385.JPG',
|
||||
'21.ARRIVO_CON TEMPO\\DSC_8295.JPG'
|
||||
];
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function readFaceAiSession(page) {
|
||||
return page.evaluate(async () => {
|
||||
const response = await fetch('/api/session', { credentials: 'include' });
|
||||
const payload = await response.json().catch(() => null);
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
payload
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function openLiveFaceAi(page) {
|
||||
const consoleErrors = [];
|
||||
|
||||
|
|
@ -35,7 +41,7 @@ async function openLiveFaceAi(page) {
|
|||
});
|
||||
|
||||
await ensureLiveAuthenticatedRacePage(page);
|
||||
await expect(page.locator('h1')).toContainText(/HALF MARATHON FIRENZE|Competitions|Gare/i);
|
||||
await expect(page.locator('h1')).not.toHaveText(/^\s*$/);
|
||||
|
||||
const launchUrl = await page.evaluate(() => {
|
||||
return typeof buildFaceAiLaunchUrl === 'function' ? buildFaceAiLaunchUrl() : '';
|
||||
|
|
@ -44,10 +50,15 @@ async function openLiveFaceAi(page) {
|
|||
expect(launchUrl, 'Expected the legacy race page script to expose a FaceAI handoff URL builder.').toBeTruthy();
|
||||
|
||||
const parsedLaunchUrl = new URL(launchUrl, LIVE_SITE_BASE_URL);
|
||||
expect(parsedLaunchUrl.searchParams.get('raceId') || '').toBeTruthy();
|
||||
expect(parsedLaunchUrl.searchParams.get('returnUrl') || '').toBeTruthy();
|
||||
|
||||
if (hasExpectedRaceStorage()) {
|
||||
expect(parsedLaunchUrl.searchParams.get('raceYear')).toBe(LIVE_EXPECTED_RACE_STORAGE.year);
|
||||
expect(parsedLaunchUrl.searchParams.get('raceMonthFolder')).toBe(LIVE_EXPECTED_RACE_STORAGE.monthFolder);
|
||||
expect(parsedLaunchUrl.searchParams.get('raceFolder')).toBe(LIVE_EXPECTED_RACE_STORAGE.raceFolder);
|
||||
expect(parsedLaunchUrl.searchParams.get('raceStorageRelativeDir')).toBe(LIVE_EXPECTED_RACE_STORAGE.relativeDir);
|
||||
}
|
||||
|
||||
await expect(page.locator('#faceaiLaunchButton')).toBeVisible();
|
||||
await page.locator('#faceaiLaunchButton').click();
|
||||
|
|
@ -57,9 +68,13 @@ async function openLiveFaceAi(page) {
|
|||
});
|
||||
await expect(page.getByRole('heading', { name: /Trova le tue foto con un selfie|Find your photos with a selfie/i })).toBeVisible();
|
||||
|
||||
const sessionResponse = await readFaceAiSession(page);
|
||||
expect(sessionResponse.ok, 'Expected the live FaceAI app to expose a readable session payload after launch.').toBe(true);
|
||||
|
||||
return {
|
||||
consoleErrors,
|
||||
launchUrl: parsedLaunchUrl
|
||||
launchUrl: parsedLaunchUrl,
|
||||
session: sessionResponse.payload
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -180,8 +195,10 @@ async function expectLegacyFaceAiGalleryToRemainStable(page, expectedPhotoIds, h
|
|||
}
|
||||
|
||||
test('renders the exact live FaceAI filtered sample URL with visible thumbnails', async ({ page }) => {
|
||||
const samplePhotoIds = LIVE_SAMPLE_PHOTO_IDS;
|
||||
const sampleUrl = `${LIVE_SITE_BASE_URL}/42%20HALF%20MARATHON%20FIRENZE_gara-1018545---48-1.html?faceaiMatchSource=faceai&faceaiMatchCount=2&faceaiPhotoIds=${encodeURIComponent(samplePhotoIds.join(','))}`;
|
||||
const samplePhotoIds = LIVE_SITE_SAMPLE_PHOTO_IDS;
|
||||
test.skip(!samplePhotoIds.length, 'Set LIVE_SITE_SAMPLE_PHOTO_IDS to validate a race-specific filtered sample URL.');
|
||||
|
||||
const sampleUrl = `${LIVE_SITE_RACE_URL}?faceaiMatchSource=faceai&faceaiMatchCount=${samplePhotoIds.length}&faceaiPhotoIds=${encodeURIComponent(samplePhotoIds.join(','))}`;
|
||||
|
||||
await ensureLiveAuthenticatedRacePage(page);
|
||||
await page.goto(sampleUrl, {
|
||||
|
|
@ -199,7 +216,9 @@ test('renders the exact live FaceAI filtered sample URL with visible thumbnails'
|
|||
await expectLegacyFaceAiGalleryToRemainStable(page, samplePhotoIds);
|
||||
});
|
||||
|
||||
test('keeps the live Firenze FaceAI race storage metadata pinned to the stored server path', async ({ page }) => {
|
||||
test('keeps the live FaceAI race storage metadata pinned to the stored server path', async ({ page }) => {
|
||||
test.skip(!hasExpectedRaceStorage(), 'Set LIVE_SITE_EXPECTED_RACE_* values to validate exact race storage metadata.');
|
||||
|
||||
await ensureLiveAuthenticatedRacePage(page);
|
||||
|
||||
await expect(page.locator('#faceAiRaceYear')).toHaveValue(LIVE_EXPECTED_RACE_STORAGE.year);
|
||||
|
|
@ -218,15 +237,16 @@ test('keeps the live Firenze FaceAI race storage metadata pinned to the stored s
|
|||
expect(parsedLaunchUrl.searchParams.get('raceStorageRelativeDir')).toBe(LIVE_EXPECTED_RACE_STORAGE.relativeDir);
|
||||
});
|
||||
|
||||
test('resolves the live Firenze sample photo lookups inside the current race', async ({ page }) => {
|
||||
const samplePhotoIds = LIVE_SAMPLE_PHOTO_IDS;
|
||||
test('resolves the live sample photo lookups inside the current race', async ({ page }) => {
|
||||
const samplePhotoIds = LIVE_SITE_SAMPLE_PHOTO_IDS;
|
||||
test.skip(!samplePhotoIds.length, 'Set LIVE_SITE_SAMPLE_PHOTO_IDS to validate race-specific photo lookups.');
|
||||
|
||||
await ensureLiveAuthenticatedRacePage(page);
|
||||
|
||||
for (const photoKey of samplePhotoIds) {
|
||||
const lookup = await lookupLivePhoto(page, photoKey);
|
||||
expect(lookup.status, `Expected faceai_photo_lookup.jsp to resolve ${photoKey} on the live Firenze race page.`).toBe(200);
|
||||
expect(String(lookup.request.raceId || '')).toBe('1018545');
|
||||
expect(String(lookup.request.raceId || '')).toBe(LIVE_SITE_RACE_ID);
|
||||
expect(lookup.payload && lookup.payload.found, `Expected ${photoKey} to resolve within the current live race.`).toBe(true);
|
||||
expect(String(lookup.payload.photoId || '')).toBe(photoKey);
|
||||
expect(basenameOfPhotoKey(String(lookup.payload.resolvedFile || ''))).toBe(basenameOfPhotoKey(photoKey));
|
||||
|
|
@ -247,9 +267,10 @@ test('loads a live race page with an authenticated session', async ({ page }) =>
|
|||
});
|
||||
|
||||
test('launches the live FaceAI app with race storage metadata and a styled header', async ({ page }) => {
|
||||
const { consoleErrors, launchUrl } = await openLiveFaceAi(page);
|
||||
const { consoleErrors, launchUrl, session } = await openLiveFaceAi(page);
|
||||
|
||||
expect(launchUrl.searchParams.get('raceStorageRelativeDir')).toBe(LIVE_EXPECTED_RACE_STORAGE.relativeDir);
|
||||
expect(launchUrl.searchParams.get('raceId')).toBe(LIVE_SITE_RACE_ID);
|
||||
expect(launchUrl.searchParams.get('raceStorageRelativeDir') || '').toBeTruthy();
|
||||
await expect(page.locator('nav.navbar')).toBeVisible();
|
||||
await expect(page.locator('link[data-legacy-href*="bootstrap.min.css"]')).toHaveCount(1);
|
||||
await expect(page.locator('link[data-legacy-href*="custom-style.css"]')).toHaveCount(1);
|
||||
|
|
@ -271,8 +292,18 @@ test('launches the live FaceAI app with race storage metadata and a styled heade
|
|||
|
||||
expect(navComputedStyles.position).toBe('fixed');
|
||||
expect(navComputedStyles.display).not.toBe('block');
|
||||
|
||||
if (LIVE_SITE_EXPECT_RACE_DATA_AVAILABLE) {
|
||||
expect(session?.availability?.available).toBe(true);
|
||||
expect(consoleErrors.find((entry) => entry.includes('MISSING_RACE_STORAGE')) || '').toBe('');
|
||||
expect(consoleErrors.find((entry) => entry.includes('[FaceAI] Invalid race data:')) || '').toBe('');
|
||||
await expect(page.locator('input[type="file"]')).toBeEnabled();
|
||||
} else {
|
||||
expect(session?.availability?.available).toBe(false);
|
||||
expect(session?.availability?.reasonCode).toBe(LIVE_SITE_EXPECT_UNAVAILABLE_REASON_CODE);
|
||||
await expect(page.locator('input[type="file"]')).toBeDisabled();
|
||||
await expect(page.locator('.faceai-feedback')).toContainText(/FaceAI data is not available for this race|I dati FaceAI non sono disponibili per questa gara|The race data received for this session is invalid/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('returns to the live race page from FaceAI without leaving the legacy spinner stuck', async ({ page }) => {
|
||||
|
|
@ -304,6 +335,7 @@ test('returns to the live race page from FaceAI without leaving the legacy spinn
|
|||
test('accepts the supplied portrait image for the live upload flow', async ({ page }) => {
|
||||
test.slow();
|
||||
test.skip(!LIVE_SITE_RUN_UPLOAD_FLOW, 'Set LIVE_SITE_RUN_UPLOAD_FLOW=1 to exercise the live upload flow.');
|
||||
test.skip(!LIVE_SITE_EXPECT_RACE_DATA_AVAILABLE, 'Set LIVE_SITE_EXPECT_RACE_DATA_AVAILABLE=1 when the target race has FaceAI data available.');
|
||||
|
||||
requirePortraitFixture();
|
||||
|
||||
|
|
|
|||
|
|
@ -15,8 +15,40 @@ const LIVE_SITE_USERNAME = process.env.LIVE_SITE_USERNAME || '';
|
|||
const LIVE_SITE_PASSWORD = process.env.LIVE_SITE_PASSWORD || '';
|
||||
const LIVE_SITE_PORTRAIT_PATH = process.env.LIVE_SITE_PORTRAIT_PATH || path.join(WORKSPACE_ROOT, 'test_pkl', 'live', 'test_portrait_1.png');
|
||||
const LIVE_SITE_RUN_UPLOAD_FLOW = process.env.LIVE_SITE_RUN_UPLOAD_FLOW === '1';
|
||||
const LIVE_SITE_EXPECT_RACE_DATA_AVAILABLE = process.env.LIVE_SITE_EXPECT_RACE_DATA_AVAILABLE !== '0';
|
||||
const LIVE_SITE_EXPECT_UNAVAILABLE_REASON_CODE = process.env.LIVE_SITE_EXPECT_UNAVAILABLE_REASON_CODE || 'RACE_DIRECTORY_NOT_FOUND';
|
||||
const AUTH_FILE = path.join(__dirname, '.auth', 'user.json');
|
||||
|
||||
function parseOptionalCsv(value) {
|
||||
return String(value || '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseRaceIdFromUrl(value) {
|
||||
const match = String(value || '').match(/_gara-(\d+)---/i);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
const LIVE_SITE_RACE_ID = process.env.LIVE_SITE_RACE_ID || parseRaceIdFromUrl(LIVE_SITE_RACE_URL);
|
||||
const LIVE_EXPECTED_RACE_STORAGE = {
|
||||
year: String(process.env.LIVE_SITE_EXPECTED_RACE_YEAR || '').trim(),
|
||||
monthFolder: String(process.env.LIVE_SITE_EXPECTED_RACE_MONTH_FOLDER || '').trim(),
|
||||
raceFolder: String(process.env.LIVE_SITE_EXPECTED_RACE_FOLDER || '').trim(),
|
||||
relativeDir: String(process.env.LIVE_SITE_EXPECTED_RACE_STORAGE_RELATIVE_DIR || '').trim()
|
||||
};
|
||||
const LIVE_SITE_SAMPLE_PHOTO_IDS = parseOptionalCsv(process.env.LIVE_SITE_SAMPLE_PHOTO_IDS);
|
||||
|
||||
function hasExpectedRaceStorage() {
|
||||
return Boolean(
|
||||
LIVE_EXPECTED_RACE_STORAGE.year
|
||||
&& LIVE_EXPECTED_RACE_STORAGE.monthFolder
|
||||
&& LIVE_EXPECTED_RACE_STORAGE.raceFolder
|
||||
&& LIVE_EXPECTED_RACE_STORAGE.relativeDir
|
||||
);
|
||||
}
|
||||
|
||||
function ensureAuthDirectory() {
|
||||
fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true });
|
||||
}
|
||||
|
|
@ -155,19 +187,26 @@ function requirePortraitFixture() {
|
|||
module.exports = {
|
||||
AUTH_FILE,
|
||||
LIVE_FACEAI_BASE_URL,
|
||||
LIVE_EXPECTED_RACE_STORAGE,
|
||||
LIVE_SITE_BASE_URL,
|
||||
LIVE_SITE_LOGIN_URL,
|
||||
LIVE_SITE_PASSWORD,
|
||||
LIVE_SITE_PORTRAIT_PATH,
|
||||
LIVE_SITE_EXPECT_RACE_DATA_AVAILABLE,
|
||||
LIVE_SITE_EXPECT_UNAVAILABLE_REASON_CODE,
|
||||
LIVE_SITE_RACE_ID,
|
||||
LIVE_SITE_RACE_URL,
|
||||
LIVE_SITE_RESULT_URL_PATTERN,
|
||||
LIVE_SITE_RUN_UPLOAD_FLOW,
|
||||
LIVE_SITE_SAMPLE_PHOTO_IDS,
|
||||
LIVE_SITE_USERNAME,
|
||||
dismissCookieBanner,
|
||||
ensureLiveAuthenticatedRacePage,
|
||||
ensureAuthDirectory,
|
||||
expectRacePageLoaded,
|
||||
hasExpectedRaceStorage,
|
||||
loginSubmitLocator,
|
||||
parseRaceIdFromUrl,
|
||||
performLiveLogin,
|
||||
performLiveLoginRequest,
|
||||
requirePortraitFixture,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue