From 5ab77736806a71d80fdbceb6bd93df19b3d141e0 Mon Sep 17 00:00:00 2001 From: Luiz Olavo Bonino Date: Fri, 23 Oct 2026 11:28:04 +0100 Subject: [PATCH] Optional guided setup for new projects Offered once on a first visit and always available from the header: four steps (repository and folder with the resulting base URI, profile, Index ping, summary). Nothing changes before Finish; Skip leaves everything as it was. Six locales; the guide and its screenshot script updated. Co-Authored-By: Claude Opus 5.5 --- TASKS.md | 12 +- docs/data-steward-guide.md | 7 + scripts/shoot.cjs | 2 + src/App.test.ts | 49 ++++++ src/App.vue | 76 +++++++++ src/components/SetupWizard.vue | 285 +++++++++++++++++++++++++++++++++ src/i18n/de-DE.ts | 29 ++++ src/i18n/en.ts | 29 ++++ src/i18n/es-ES.ts | 29 ++++ src/i18n/fr-FR.ts | 29 ++++ src/i18n/nl-NL.ts | 29 ++++ src/i18n/pt-BR.ts | 29 ++++ 12 files changed, 601 insertions(+), 4 deletions(-) create mode 100644 src/components/SetupWizard.vue diff --git a/TASKS.md b/TASKS.md index c648fc4..73279dc 100644 --- a/TASKS.md +++ b/TASKS.md @@ -28,7 +28,7 @@ file tracks how we build it. | Phase | Theme | Status | |---|---|---| | 0 — Shared core | extract Contour's record engine into a package | ✅ package in Contour, on npm, used by FDPlight | -| 1 — Project model and profile | base URI, profile format, import from an FDP | 🟡 all steps in the UI; a guided first-run flow not built | +| 1 — Project model and profile | base URI, profile format, import from an FDP | ✅ settings, connect form and the optional guided setup | | 2 — Multi-shape forms | merged shapes per type, form per entity | ✅ default and ERDERA shapes tested | | 3 — Tree and wiring | entity tree, IRI minting, system triples, LDP | ✅ matches the RI (SPEC §9.5) | | 4 — Import and edit | open an existing file-only FDP, lossless round trip | ✅ repository import, report, copy by URL | @@ -42,6 +42,7 @@ Legend: ⬜ not started · 🟡 in progress · ✅ done. ### Log +- 2026-10-23 — Optional guided setup for new projects (#42). - 2026-10-23 — Profile documents: each type's merged SHACL at its profile IRI (#41). - 2026-10-23 — ERDERA data services need `dct:type`; a hidden record shows a warning (#40). - 2026-10-23 — Owner's decisions on the wizard, guide translations, profile documents, metrics and ERDERA follow-ups recorded (#39). @@ -155,14 +156,17 @@ Legend: ⬜ not started · 🟡 in progress · ✅ done. - [x] Scaffold imports the shared package and renders the default-profile forms, with a live preview of the generated Turtle (#8). -## Phase 1 — Project model and profile 🟡 +## Phase 1 — Project model and profile ✅ - [x] `Project` type (base URI, IRI mode, defaults, profile, entities) and its JSON form `fdplight.json` (SPEC §3.1) — `src/core/profile.ts`, `src/core/project.ts`. The entity tree is not yet persisted (open decision 5). -- [ ] New-project wizard, optional (decided 2026-10-23): a guided - first-run flow the user may follow or skip. The steps also stay in the +- [x] New-project wizard, optional (decided 2026-10-23): a guided + first-run flow the user may follow or skip (#42). Offered once on a + first visit, always available from the header; four steps + (repository and folder with the base URI, profile, Index ping, + summary); nothing changes before *Finish*. The steps also stay in the settings bar and the connect form. - [x] target repository (owner/name, branch): the connect form (#12); - [x] **base URI**, proposed as `https://{owner}.github.io/{repo}/` plus diff --git a/docs/data-steward-guide.md b/docs/data-steward-guide.md index 1d91d8a..4d1db0f 100644 --- a/docs/data-steward-guide.md +++ b/docs/data-steward-guide.md @@ -85,6 +85,13 @@ of the selected entity (middle), and the **checks and the generated file** ![FDPlight when it opens: settings, publishing, tree, form and generated file](images/start.png) +**Guided setup (optional).** On your first visit FDPlight offers a guided +setup. It asks for the repository (owner, name, branch, folder), shows the +base URI that follows from it, lets you pick a profile and whether to ping an +FDP Index, and fills in the settings and the connect form when you click +*Finish*. *Skip* leaves everything as it was. You can open it again from +*Guided setup* in the header, or follow Steps 1 to 7 below by hand. + ### Step 1 — Project settings ![Project settings with the Index ping switched on](images/settings.png) diff --git a/scripts/shoot.cjs b/scripts/shoot.cjs index 86827f0..25ab874 100644 --- a/scripts/shoot.cjs +++ b/scripts/shoot.cjs @@ -26,6 +26,8 @@ async function main() { const settle = () => page.waitForTimeout(700); // ---- a new project ---- + // The guided-setup offer is shown on a first visit only; the guide shows the editor. + await page.addInitScript(() => localStorage.setItem('fdplight.setup.offered', '1')); await page.goto(APP); await settle(); await shot('start'); diff --git a/src/App.test.ts b/src/App.test.ts index b209c9e..987f68d 100644 --- a/src/App.test.ts +++ b/src/App.test.ts @@ -138,6 +138,55 @@ describe('App', () => { } }); + it('offers an optional guided setup that fills in the settings on Finish', async () => { + localStorage.removeItem('fdplight.setup.offered'); + const w = mount(App, { attachTo: document.body }); + try { + expect(w.find('.offer').exists()).toBe(true); + await w.find('.offer__start').trigger('click'); + expect(w.find('.offer').exists()).toBe(false); + expect(localStorage.getItem('fdplight.setup.offered')).toBe('1'); + const dialog = () => w.find('[role="dialog"]'); + const inputs = () => dialog().findAll('input'); + await inputs()[0].setValue('erdera'); + await inputs()[1].setValue('my-fdp'); + await inputs()[3].setValue('metadata'); + expect(dialog().find('.wizard__base code').text()).toBe('https://erdera.github.io/my-fdp/metadata/'); + const next = () => dialog().findAll('button').find((b) => b.text() === 'Next')!.trigger('click'); + await next(); + await dialog().find('input[value="erdera"]').setValue(true); + await next(); + await dialog().find('input[type="checkbox"]').setValue(true); + await next(); + expect(dialog().text()).toContain('Repository erdera/my-fdp, branch main'); + await dialog().findAll('button').find((b) => b.text() === 'Finish')!.trigger('click'); + await flushPromises(); + expect(w.find('[role="dialog"]').exists()).toBe(false); + expect(w.find('.settings select').element as HTMLSelectElement).toHaveProperty('value', 'erdera'); + const values = w.findAll('.settings__field input').map((i) => (i.element as HTMLInputElement).value); + expect(values).toContain('https://erdera.github.io/my-fdp/metadata/'); + expect(values).toContain('metadata/'); + expect(values).toContain('https://home.fairdatapoint.org'); + expect(w.findAll('.publish input').map((i) => (i.element as HTMLInputElement).value).slice(0, 2)).toEqual(['erdera', 'my-fdp']); + } finally { + w.unmount(); + localStorage.setItem('fdplight.setup.offered', '1'); + } + }); + + it('can skip the guided setup and remembers it', async () => { + localStorage.removeItem('fdplight.setup.offered'); + const w = mount(App); + await w.find('.offer__dismiss').trigger('click'); + expect(w.find('.offer').exists()).toBe(false); + expect(mount(App).find('.offer').exists()).toBe(false); + await w.find('.setup-link').trigger('click'); + expect(w.find('[role="dialog"]').exists()).toBe(true); + await w.findAll('[role="dialog"] button').find((b) => b.text() === 'Skip')!.trigger('click'); + expect(w.find('[role="dialog"]').exists()).toBe(false); + expect(w.find('.settings__field input').exists()).toBe(true); + }); + it('marks entities with missing required metadata', async () => { vi.useFakeTimers(); const w = mount(App); diff --git a/src/App.vue b/src/App.vue index ba58288..8dd50ea 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,6 +6,7 @@ import EntityTree from './components/EntityTree.vue'; import IndexStatus from './components/IndexStatus.vue'; import ProjectSettings from './components/ProjectSettings.vue'; import PublishPanel from './components/PublishPanel.vue'; +import SetupWizard from './components/SetupWizard.vue'; import { useI18n } from './composables/useI18n'; import { usePublish } from './composables/usePublish'; import { LOCALES } from './i18n'; @@ -25,6 +26,37 @@ const isRoot = computed(() => entity.value.parent === null); const iri = computed(() => (state.settingsErrors.value.length ? '' : iriOf(project, entity.value))); const type = computed(() => state.typeOf(entity.value)); +// The optional guided setup (TASKS Phase 1): offered once on a first visit, +// always available from the header. +const SETUP_KEY = 'fdplight.setup.offered'; +function setupOffered(): boolean { + try { + return localStorage.getItem(SETUP_KEY) === '1'; + } catch { + return false; + } +} +const offerSetup = ref(!setupOffered() && !state.hasLocalWork()); +const wizardOpen = ref(false); +let opener: HTMLElement | null = null; +function dismissOffer(): void { + offerSetup.value = false; + try { + localStorage.setItem(SETUP_KEY, '1'); + } catch { + /* storage unavailable: offered again next time */ + } +} +function openWizard(): void { + opener = document.activeElement as HTMLElement | null; + dismissOffer(); + wizardOpen.value = true; +} +function closeWizard(): void { + wizardOpen.value = false; + nextTick(() => opener?.focus()); +} + // The id field edits a draft; the rename happens on change / Enter. const idDraft = ref(entity.value.id); watch(entity, (e) => (idDraft.value = e.id)); @@ -67,6 +99,7 @@ function removeSelected() {

{{ t('app.tagline') }}

{{ t('app.wip') }}

+ {{ t('app.guide') }}