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
12 changes: 8 additions & 4 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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).
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/data-steward-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions scripts/shoot.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
49 changes: 49 additions & 0 deletions src/App.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
76 changes: 76 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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));
Expand Down Expand Up @@ -67,6 +99,7 @@ function removeSelected() {
<p class="tagline">{{ t('app.tagline') }}</p>
</div>
<p class="wip">{{ t('app.wip') }}</p>
<button type="button" class="guide setup-link" @click="openWizard">{{ t('wizard.open') }}</button>
<a class="guide" :href="GUIDE_URL" target="_blank" rel="noopener" :title="t('app.guideTitle')">{{ t('app.guide') }}</a>
<label class="lang">
<select :value="locale" :aria-label="t('app.language')" @change="setLocale(($event.target as HTMLSelectElement).value as never)">
Expand All @@ -76,6 +109,11 @@ function removeSelected() {
</header>

<main class="main">
<section v-if="offerSetup" class="card offer" :aria-label="t('wizard.title')">
<p>{{ t('wizard.offer') }}</p>
<button type="button" class="offer__start" @click="openWizard">{{ t('wizard.start') }}</button>
<button type="button" class="offer__dismiss" @click="dismissOffer">{{ t('wizard.noThanks') }}</button>
</section>
<div class="top">
<section class="card">
<ProjectSettings :state="state" />
Expand Down Expand Up @@ -147,6 +185,7 @@ function removeSelected() {
</section>
</div>
</main>
<SetupWizard v-if="wizardOpen" :state="state" :pub="pub" @close="closeWizard" />
</template>

<style scoped>
Expand Down Expand Up @@ -181,6 +220,43 @@ function removeSelected() {
font-size: 0.85rem;
color: var(--color-accent);
}
.setup-link {
font: inherit;
font-size: 0.85rem;
border: none;
background: none;
padding: 0;
text-decoration: underline;
cursor: pointer;
}
.offer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
}
.offer p {
margin: 0;
flex: 1 1 20rem;
font-size: 0.9rem;
}
.offer__start {
font: inherit;
font-weight: 600;
padding: 6px 16px;
border: none;
border-radius: 6px;
background: var(--color-primary);
color: #fff;
cursor: pointer;
}
.offer__dismiss {
font: inherit;
border: none;
background: none;
color: var(--color-accent);
cursor: pointer;
}
.lang select {
font: inherit;
font-size: 0.8rem;
Expand Down
Loading
Loading