Skip to content

Commit 48a74e3

Browse files
rubenvdlindeConduction Release Botclaude
authored
feat(setup): ask which example data to load, as cards, instead of a bare Run button (#543)
The demo-data step was a Run button under a paragraph. The paragraph said the data was safe to load and safe to delete. Neither said what was about to land in the operator's register, and there was no way to say no. ## Declining was unsayable, and that reopened the wizard for ever This app implements a `skip-demo-data` action. No manifest step could reach it: the only step was the run-action that INSTALLS. So an operator who did not want example data had no way to record that, `demo-data` stayed `done: false`, and CnAppRoot reopens the wizard while any optional step is outstanding. On every fresh browser profile, over every page, until they imported data they did not want. Measured across the fleet on 2026-09-05: fifteen apps implement that action and not one declares a step that can post it. ## What the step asks now Two cards, read from the server: "None, I will set this up myself" and the dataset this app ships, with its object count. Picking one is an answer, and `none` closes both steps without importing anything. The list comes from `GET /api/setup/status` as `datasets` — the step declares `optionsSource` and carries no options of its own, so nothing in the manifest can disagree with what will actually be imported. The count is read from the descriptor file, so the card promises the number that lands. The card's description carries NO number, deliberately: the wizard translates a description by literal lookup, so an interpolated count would leave a Dutch operator reading English. The count travels as `objectCount` and the card renders it as a stat with a label the library translates. ## Compatibility `install-demo-data` still works and still means "the dataset this app ships", so a runbook or script that posts it keeps working. `skip-demo-data` now writes both keys rather than only the decision flag. ## Verification 21 PHPUnit tests pass, 9 of them new. PHPCS clean, ESLint clean, prettier clean. All 75 applicable hydra gates green. Requires @conduction/nextcloud-vue with CnChoiceCards and hydra-gates >= v1.15.0 for manifest schema 2.33.0. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 904dd72 commit 48a74e3

11 files changed

Lines changed: 2056 additions & 310 deletions

File tree

appinfo/routes.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
// First-time setup wizard (ADR-042) — the standard CnSetupWizard contract.
1515
['name' => 'setup#status', 'url' => '/api/setup/status', 'verb' => 'GET'],
1616
['name' => 'setup#runAction', 'url' => '/api/setup/action/{actionId}', 'verb' => 'POST', 'requirements' => ['actionId' => '[a-z0-9\\-]+']],
17+
['name' => 'setup#saveConfig', 'url' => '/api/setup/config', 'verb' => 'POST'],
1718
['name' => 'manifest#index', 'url' => '/api/manifest', 'verb' => 'GET'],
1819

1920
// Metrics and health

composer.lock

Lines changed: 8 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

l10n/nl.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1150,7 +1150,15 @@ OC.L10N.register(
11501150
"Version": "Versie",
11511151
"How many dashboards there are, and who they belong to.": "Hoeveel dashboards er zijn, en van wie ze zijn.",
11521152
"Store": "Store",
1153-
"Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installeer registers, schema's en flows die andere organisaties hebben gepubliceerd."
1153+
"Install dashboard configurations that other organisations have published: a set of tiles, a layout, or the flows behind them.": "Installeer registers, schema's en flows die andere organisaties hebben gepubliceerd.",
1154+
"Load example data?": "Voorbeeldgegevens laden?",
1155+
"Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Kies \"Geen\" op een productieomgeving.",
1156+
"Load the example data": "Laad de voorbeeldgegevens",
1157+
"Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt wat je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen.",
1158+
"None, I will set this up myself": "Geen, ik richt dit zelf in",
1159+
"Nothing is imported. You start with an empty app and add your own data.": "Er wordt niets geïmporteerd. Je begint met een lege app en voegt zelf gegevens toe.",
1160+
"Example data": "Voorbeeldgegevens",
1161+
"Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Voorbeeldwaarden voor elk schema dat deze app levert, gegenereerd uit de schema’s zelf. Het laat de lijsten, detailpagina’s en dashboards werkend zien in plaats van een verhaal te vertellen. Veilig om vaker uit te voeren, en je kunt het daarna verwijderen."
11541162
},
11551163
"nplurals=2; plural=(n != 1);"
11561164
);

l10n/nl.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
{
22
"translations": {
3+
"Load example data?": "Voorbeeldgegevens laden?",
4+
"Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Kies \"Geen\" op een productieomgeving.",
5+
"Load the example data": "Laad de voorbeeldgegevens",
6+
"Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt wat je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen.",
7+
"None, I will set this up myself": "Geen, ik richt dit zelf in",
8+
"Nothing is imported. You start with an empty app and add your own data.": "Er wordt niets geïmporteerd. Je begint met een lege app en voegt zelf gegevens toe.",
9+
"Example data": "Voorbeeldgegevens",
10+
"Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Voorbeeldwaarden voor elk schema dat deze app levert, gegenereerd uit de schema’s zelf. Het laat de lijsten, detailpagina’s en dashboards werkend zien in plaats van een verhaal te vertellen. Veilig om vaker uit te voeren, en je kunt het daarna verwijderen.",
311
"A short setup to get this app ready. Nothing here is required; you can close it and come back later.": "Een korte installatie om deze app klaar te zetten. Niets hiervan is verplicht; je kunt dit sluiten en later terugkomen.",
412
"Demo data (optional)": "Demovoorbeelddata (optioneel)",
513
"Load a small example dataset so the lists, detail pages and dashboards show a working product straight away. The data is obviously sample data, it is safe to run more than once, and it can be removed afterwards. Skip this on a production install.": "Laad een kleine voorbeeldset zodat de lijsten, detailpagina's en dashboards meteen een werkend product laten zien. De data is duidelijk voorbeelddata, veilig om meerdere keren uit te voeren en achteraf te verwijderen. Sla dit over op een productie-installatie.",

lib/Controller/SetupController.php

Lines changed: 113 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,19 @@ class SetupController extends Controller {
6565
*/
6666
private const DEMO_DECIDED_KEY = 'demo_data_decided';
6767

68+
/**
69+
* App-config key holding the dataset the operator picked.
70+
*
71+
* The wizard's `choice` step writes it through `POST /api/setup/config`, and
72+
* the `run-action` step that follows reads it back. Two steps rather than
73+
* one because `CnSetupWizard::runAction()` posts to
74+
* `/api/setup/action/{action}` with no body: an action cannot carry the
75+
* answer, so the answer has to be stored before the action runs.
76+
*
77+
* @var string
78+
*/
79+
private const DATASET_KEY = 'demo_dataset';
80+
6881
/**
6982
* Constructor.
7083
*
@@ -99,19 +112,77 @@ public function __construct(
99112
#[AuthorizedAdminSetting(LaunchPadAdmin::class)]
100113
public function status(): JSONResponse {
101114
$demoDecided = $this->appConfig->getValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, '') !== '';
115+
$picked = $this->appConfig->getValueString(Application::APP_ID, self::DATASET_KEY, '');
102116

103117
return new JSONResponse(
104118
data: [
105119
'version' => self::SETUP_VERSION,
106120
'completed' => true,
121+
// The choice step reads its options from here: it declares
122+
// `optionsSource: datasets` and no options of its own, so a
123+
// dataset missing from this list is a dataset nobody can pick.
124+
'datasets' => $this->demoDataService->listChoices(),
107125
'steps' => [
108-
'demo-data' => ['done' => $demoDecided],
126+
'demo-data' => ['done' => ($picked !== '')],
127+
// "None" is an ANSWER, so the load step is finished the moment
128+
// it is chosen: there is nothing left for the operator to run.
129+
'load-demo-data' => [
130+
'done' => ($demoDecided === true || $picked === DemoDataService::NONE_DATASET),
131+
],
109132
],
110133
]
111134
);
112135

113136
}//end status()
114137

138+
/**
139+
* Persist the wizard's `choice` answer.
140+
*
141+
* @return JSONResponse `{ success, config }`.
142+
*
143+
* @spec exclude Setup config write; ADR-042 contract, no per-app behavioural spec.
144+
*/
145+
#[AuthorizedAdminSetting(LaunchPadAdmin::class)]
146+
public function saveConfig(): JSONResponse {
147+
// 🔴 ONE NAMED KEY, NEVER A CALLER-SUPPLIED ONE. The body arrives from
148+
// the browser and this app's own settings share the appconfig namespace,
149+
// so looping over the posted keys would let this endpoint write any of
150+
// them. The key is written in the source; only its value comes from the
151+
// request.
152+
$value = $this->request->getParam(self::DATASET_KEY);
153+
if ($value === null) {
154+
return new JSONResponse(data: ['success' => true, 'config' => []]);
155+
}
156+
157+
// The step is not `multiple`, but the wizard's contract allows a list, so
158+
// both shapes are read rather than one of them reaching `(string)`.
159+
$submitted = $value;
160+
if (is_array($value) === true) {
161+
$submitted = ($value[0] ?? null);
162+
}
163+
164+
if (is_scalar($submitted) === false) {
165+
return new JSONResponse(
166+
data: ['success' => false, 'message' => 'A dataset is named by a string.'],
167+
statusCode: Http::STATUS_BAD_REQUEST,
168+
);
169+
}
170+
171+
$datasetId = (string)$submitted;
172+
$known = array_column($this->demoDataService->listChoices(), 'id');
173+
if (in_array($datasetId, $known, true) === false) {
174+
return new JSONResponse(
175+
data: ['success' => false, 'message' => 'No dataset is called "' . $datasetId . '".'],
176+
statusCode: Http::STATUS_BAD_REQUEST,
177+
);
178+
}
179+
180+
$this->appConfig->setValueString(Application::APP_ID, self::DATASET_KEY, $datasetId);
181+
182+
return new JSONResponse(data: ['success' => true, 'config' => [self::DATASET_KEY => $datasetId]]);
183+
184+
}//end saveConfig()
185+
115186
/**
116187
* Run a privileged server-side setup action.
117188
*
@@ -125,15 +196,23 @@ public function status(): JSONResponse {
125196
*/
126197
#[AuthorizedAdminSetting(LaunchPadAdmin::class)]
127198
public function runAction(string $actionId): JSONResponse {
128-
if ($actionId === 'install-demo-data') {
129-
return $this->installDemoData();
199+
// `install-demo-data` is the id the step used before it asked WHICH
200+
// dataset, and it still means "import the one this app ships". Kept so
201+
// an older manifest, a runbook or a script that posts it keeps working.
202+
if ($actionId === 'load-demo-data' || $actionId === 'install-demo-data') {
203+
return $this->loadDataset(actionId: $actionId);
130204
}
131205

132206
// DECLINING IS AN ANSWER — see DEMO_DECIDED_KEY.
207+
//
208+
// 🔴 AND IT ANSWERS *BOTH* STEPS. The wizard now has a choice step and a
209+
// run-action step; closing only the second leaves the first outstanding,
210+
// and CnAppRoot opens the wizard while ANY optional step is outstanding.
133211
if ($actionId === 'skip-demo-data') {
212+
$this->appConfig->setValueString(Application::APP_ID, self::DATASET_KEY, DemoDataService::NONE_DATASET);
134213
$this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'skipped');
135214

136-
return new JSONResponse(data: ['success' => true, 'message' => 'Demo data skipped.']);
215+
return new JSONResponse(data: ['success' => true, 'message' => 'No example data was loaded.']);
137216
}
138217

139218
return new JSONResponse(
@@ -144,15 +223,42 @@ public function runAction(string $actionId): JSONResponse {
144223
}//end runAction()
145224

146225
/**
147-
* Import the shipped demo dataset.
226+
* Import the dataset the operator picked in the previous step.
227+
*
228+
* @param string $actionId The action that asked, which decides whether an
229+
* unanswered choice is refused or means the shipped set.
148230
*
149231
* Reports the FAILURE rather than a quiet success: an operator who asked for
150232
* demo data and got none must be told, which is why DemoDataService::install()
151233
* throws instead of returning an empty result.
152234
*
153235
* @return JSONResponse `{ success, message }`.
154236
*/
155-
private function installDemoData(): JSONResponse {
237+
private function loadDataset(string $actionId): JSONResponse {
238+
$picked = $this->appConfig->getValueString(Application::APP_ID, self::DATASET_KEY, '');
239+
240+
// The legacy id carries no answer, so it means the shipped dataset. A
241+
// caller that posts it has said which one by posting it.
242+
if ($actionId === 'install-demo-data' && $picked === '') {
243+
$picked = DemoDataService::DEMO_DATASET;
244+
}
245+
246+
// 🔴 NO SILENT DEFAULT. Importing here because the operator clicked Run
247+
// one step early would plant example objects nobody asked for, which is
248+
// the failure this whole step exists to avoid.
249+
if ($picked === '') {
250+
return new JSONResponse(
251+
data: ['success' => false, 'message' => 'Pick a dataset first.'],
252+
statusCode: Http::STATUS_BAD_REQUEST,
253+
);
254+
}
255+
256+
if ($picked === DemoDataService::NONE_DATASET) {
257+
$this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'skipped');
258+
259+
return new JSONResponse(data: ['success' => true, 'message' => 'No example data was loaded.']);
260+
}
261+
156262
try {
157263
$imported = $this->demoDataService->install();
158264
} catch (\Throwable $e) {
@@ -176,5 +282,5 @@ private function installDemoData(): JSONResponse {
176282
]
177283
);
178284

179-
}//end installDemoData()
285+
}//end loadDataset()
180286
}//end class

lib/Service/DemoDataService.php

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,108 @@ public function isAvailable(): bool {
8484
return is_file($this->descriptorPath()) === true;
8585
}//end isAvailable()
8686

87+
/**
88+
* The answer that means "plant nothing".
89+
*
90+
* 🔴 NOT THE ABSENCE OF AN ANSWER. An operator who declines has FINISHED the
91+
* step; a step that can never be marked done reopens the wizard over every
92+
* page (nextcloud-vue#806).
93+
*
94+
* @var string
95+
*/
96+
public const NONE_DATASET = 'none';
97+
98+
/**
99+
* The id of the dataset this app ships.
100+
*
101+
* @var string
102+
*/
103+
public const DEMO_DATASET = 'demo';
104+
105+
/**
106+
* Every answer the wizard's choice step may offer, declining included.
107+
*
108+
* 🔴 THE SERVER OWNS THIS LIST, AND THAT IS THE POINT. The step declares
109+
* `optionsSource: datasets` and no options of its own, so the label, the
110+
* description and the object count come from the descriptor that will
111+
* actually be imported. A manifest that restated them could disagree with
112+
* what lands, and nothing would notice.
113+
*
114+
* @return array<int, array{id: string, label: string, description: string, objectCount: integer, icon: string}> The answers.
115+
*
116+
* @spec exclude Demo-data choice list; ADR-111 rule 1 has no per-app behavioural spec.
117+
*/
118+
public function listChoices(): array {
119+
$choices = [
120+
[
121+
'id' => self::NONE_DATASET,
122+
'label' => 'None, I will set this up myself',
123+
'description' => 'Nothing is imported. You start with an empty app and add your own data.',
124+
'objectCount' => 0,
125+
'icon' => 'CloseCircleOutline',
126+
],
127+
];
128+
129+
$objects = $this->shippedObjectCount();
130+
if ($objects !== null) {
131+
$choices[] = [
132+
'id' => self::DEMO_DATASET,
133+
'label' => 'Example data',
134+
// 🔴 NO NUMBER IN THIS SENTENCE. The wizard runs a card's
135+
// description through the app's translation function, which is a
136+
// literal lookup, so an interpolated count would make the string
137+
// untranslatable and leave a Dutch operator reading English. The
138+
// count travels as `objectCount` and the card renders it as a
139+
// stat, with a label the library translates.
140+
'description' => (
141+
'Sample values for every schema this app supplies, generated from the schemas '
142+
. 'themselves. It shows the lists, detail pages and dashboards working rather '
143+
. 'than telling a story. Safe to run more than once, and you can delete it '
144+
. 'afterwards.'
145+
),
146+
'objectCount' => $objects,
147+
'icon' => 'DatabaseOutline',
148+
];
149+
}
150+
151+
return $choices;
152+
153+
}//end listChoices()
154+
155+
/**
156+
* How many objects the shipped descriptor carries, or null when it ships none.
157+
*
158+
* Counted from the FILE, so the card promises the number that will actually
159+
* be imported. A missing or malformed descriptor returns null and the app
160+
* then offers only "None" — honest, rather than an import that cannot run.
161+
*
162+
* @return integer|null The object count, or null when there is no usable descriptor.
163+
*/
164+
private function shippedObjectCount(): ?int {
165+
$path = $this->descriptorPath();
166+
if (is_file($path) === false) {
167+
return null;
168+
}
169+
170+
$raw = file_get_contents($path);
171+
if ($raw === false) {
172+
return null;
173+
}
174+
175+
$data = json_decode($raw, true);
176+
if (is_array($data) === false) {
177+
return null;
178+
}
179+
180+
$components = ($data['components'] ?? []);
181+
if (is_array($components) === false || is_array(($components['objects'] ?? null)) === false) {
182+
return 0;
183+
}
184+
185+
return count($components['objects']);
186+
187+
}//end shippedObjectCount()
188+
87189
/**
88190
* Import the demo dataset.
89191
*

0 commit comments

Comments
 (0)