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 appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
// First-time setup wizard (ADR-042) - the standard CnSetupWizard contract.
['name' => 'setup#status', 'url' => '/api/setup/status', 'verb' => 'GET'],
['name' => 'setup#runAction', 'url' => '/api/setup/action/{actionId}', 'verb' => 'POST', 'requirements' => ['actionId' => '[a-z0-9\\-]+']],
['name' => 'setup#saveConfig', 'url' => '/api/setup/config', 'verb' => 'POST'],
['name' => 'metrics#index', 'url' => '/api/metrics', 'verb' => 'GET'],
['name' => 'health#index', 'url' => '/api/health', 'verb' => 'GET'],

Expand Down
12 changes: 6 additions & 6 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions l10n/nl.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
OC.L10N.register(
"integriq",
{
"Load example data?": "Voorbeeldgegevens laden?",
"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.",
"Load the example data": "Laad de voorbeeldgegevens",
"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.",
"None, I will set this up myself": "Geen, ik richt dit zelf in",
"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.",
"Example data": "Voorbeeldgegevens",
"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.",
"Welcome": "Welkom",
"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.",
"Demo data (optional)": "Demovoorbeelddata (optioneel)",
Expand Down
8 changes: 8 additions & 0 deletions l10n/nl.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
{
"translations": {
"Load example data?": "Voorbeeldgegevens laden?",
"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.",
"Load the example data": "Laad de voorbeeldgegevens",
"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.",
"None, I will set this up myself": "Geen, ik richt dit zelf in",
"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.",
"Example data": "Voorbeeldgegevens",
"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.",
"Welcome": "Welkom",
"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.",
"Demo data (optional)": "Demovoorbeelddata (optioneel)",
Expand Down
120 changes: 113 additions & 7 deletions lib/Controller/SetupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ class SetupController extends Controller {
*/
private const DEMO_DECIDED_KEY = 'demo_data_decided';

/**
* App-config key holding the dataset the operator picked.
*
* The wizard's `choice` step writes it through `POST /api/setup/config`, and
* the `run-action` step that follows reads it back. Two steps rather than
* one because `CnSetupWizard::runAction()` posts to
* `/api/setup/action/{action}` with no body: an action cannot carry the
* answer, so the answer has to be stored before the action runs.
*
* @var string
*/
private const DATASET_KEY = 'demo_dataset';

/**
* Constructor.
*
Expand Down Expand Up @@ -99,19 +112,77 @@ public function __construct(
#[AuthorizedAdminSetting(IntegriqAdmin::class)]
public function status(): JSONResponse {
$demoDecided = $this->appConfig->getValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, '') !== '';
$picked = $this->appConfig->getValueString(Application::APP_ID, self::DATASET_KEY, '');

return new JSONResponse(
data: [
'version' => self::SETUP_VERSION,
'completed' => true,
// The choice step reads its options from here: it declares
// `optionsSource: datasets` and no options of its own, so a
// dataset missing from this list is a dataset nobody can pick.
'datasets' => $this->demoDataService->listChoices(),
'steps' => [
'demo-data' => ['done' => $demoDecided],
'demo-data' => ['done' => ($picked !== '')],
// "None" is an ANSWER, so the load step is finished the moment
// it is chosen: there is nothing left for the operator to run.
'load-demo-data' => [
'done' => ($demoDecided === true || $picked === DemoDataService::NONE_DATASET),
],
],
]
);

}//end status()

/**
* Persist the wizard's `choice` answer.
*
* @return JSONResponse `{ success, config }`.
*
* @spec exclude Setup config write; ADR-042 contract, no per-app behavioural spec.
*/
#[AuthorizedAdminSetting(IntegriqAdmin::class)]
public function saveConfig(): JSONResponse {
// 🔴 ONE NAMED KEY, NEVER A CALLER-SUPPLIED ONE. The body arrives from
// the browser and this app's own settings share the appconfig namespace,
// so looping over the posted keys would let this endpoint write any of
// them. The key is written in the source; only its value comes from the
// request.
$value = $this->request->getParam(self::DATASET_KEY);
if ($value === null) {
return new JSONResponse(data: ['success' => true, 'config' => []]);
}

// The step is not `multiple`, but the wizard's contract allows a list, so
// both shapes are read rather than one of them reaching `(string)`.
$submitted = $value;
if (is_array($value) === true) {
$submitted = ($value[0] ?? null);
}

if (is_scalar($submitted) === false) {
return new JSONResponse(
data: ['success' => false, 'message' => 'A dataset is named by a string.'],
statusCode: Http::STATUS_BAD_REQUEST,
);
}

$datasetId = (string)$submitted;
$known = array_column($this->demoDataService->listChoices(), 'id');
if (in_array($datasetId, $known, true) === false) {
return new JSONResponse(
data: ['success' => false, 'message' => 'No dataset is called "' . $datasetId . '".'],
statusCode: Http::STATUS_BAD_REQUEST,
);
}

$this->appConfig->setValueString(Application::APP_ID, self::DATASET_KEY, $datasetId);

return new JSONResponse(data: ['success' => true, 'config' => [self::DATASET_KEY => $datasetId]]);

}//end saveConfig()

/**
* Run a privileged server-side setup action.
*
Expand All @@ -125,15 +196,23 @@ public function status(): JSONResponse {
*/
#[AuthorizedAdminSetting(IntegriqAdmin::class)]
public function runAction(string $actionId): JSONResponse {
if ($actionId === 'install-demo-data') {
return $this->installDemoData();
// `install-demo-data` is the id the step used before it asked WHICH
// dataset, and it still means "import the one this app ships". Kept so
// an older manifest, a runbook or a script that posts it keeps working.
if ($actionId === 'load-demo-data' || $actionId === 'install-demo-data') {
return $this->loadDataset(actionId: $actionId);
}

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

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

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

/**
* Import the shipped demo dataset.
* Import the dataset the operator picked in the previous step.
*
* @param string $actionId The action that asked, which decides whether an
* unanswered choice is refused or means the shipped set.
*
* Reports the FAILURE rather than a quiet success: an operator who asked for
* demo data and got none must be told, which is why DemoDataService::install()
* throws instead of returning an empty result.
*
* @return JSONResponse `{ success, message }`.
*/
private function installDemoData(): JSONResponse {
private function loadDataset(string $actionId): JSONResponse {
$picked = $this->appConfig->getValueString(Application::APP_ID, self::DATASET_KEY, '');

// The legacy id carries no answer, so it means the shipped dataset. A
// caller that posts it has said which one by posting it.
if ($actionId === 'install-demo-data' && $picked === '') {
$picked = DemoDataService::DEMO_DATASET;
}

// 🔴 NO SILENT DEFAULT. Importing here because the operator clicked Run
// one step early would plant example objects nobody asked for, which is
// the failure this whole step exists to avoid.
if ($picked === '') {
return new JSONResponse(
data: ['success' => false, 'message' => 'Pick a dataset first.'],
statusCode: Http::STATUS_BAD_REQUEST,
);
}

if ($picked === DemoDataService::NONE_DATASET) {
$this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'skipped');

return new JSONResponse(data: ['success' => true, 'message' => 'No example data was loaded.']);
}

try {
$imported = $this->demoDataService->install();
} catch (\Throwable $e) {
Expand All @@ -176,5 +282,5 @@ private function installDemoData(): JSONResponse {
]
);

}//end installDemoData()
}//end loadDataset()
}//end class
102 changes: 102 additions & 0 deletions lib/Service/DemoDataService.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,108 @@ public function isAvailable(): bool {
return is_file($this->descriptorPath()) === true;
}//end isAvailable()

/**
* The answer that means "plant nothing".
*
* 🔴 NOT THE ABSENCE OF AN ANSWER. An operator who declines has FINISHED the
* step; a step that can never be marked done reopens the wizard over every
* page (nextcloud-vue#806).
*
* @var string
*/
public const NONE_DATASET = 'none';

/**
* The id of the dataset this app ships.
*
* @var string
*/
public const DEMO_DATASET = 'demo';

/**
* Every answer the wizard's choice step may offer, declining included.
*
* 🔴 THE SERVER OWNS THIS LIST, AND THAT IS THE POINT. The step declares
* `optionsSource: datasets` and no options of its own, so the label, the
* description and the object count come from the descriptor that will
* actually be imported. A manifest that restated them could disagree with
* what lands, and nothing would notice.
*
* @return array<int, array{id: string, label: string, description: string, objectCount: integer, icon: string}> The answers.
*
* @spec exclude Demo-data choice list; ADR-111 rule 1 has no per-app behavioural spec.
*/
public function listChoices(): array {
$choices = [
[
'id' => self::NONE_DATASET,
'label' => 'None, I will set this up myself',
'description' => 'Nothing is imported. You start with an empty app and add your own data.',
'objectCount' => 0,
'icon' => 'CloseCircleOutline',
],
];

$objects = $this->shippedObjectCount();
if ($objects !== null) {
$choices[] = [
'id' => self::DEMO_DATASET,
'label' => 'Example data',
// 🔴 NO NUMBER IN THIS SENTENCE. The wizard runs a card's
// description through the app's translation function, which is a
// literal lookup, so an interpolated count would make the string
// untranslatable and 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.
'description' => (
'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.'
),
'objectCount' => $objects,
'icon' => 'DatabaseOutline',
];
}

return $choices;

}//end listChoices()

/**
* How many objects the shipped descriptor carries, or null when it ships none.
*
* Counted from the FILE, so the card promises the number that will actually
* be imported. A missing or malformed descriptor returns null and the app
* then offers only "None" — honest, rather than an import that cannot run.
*
* @return integer|null The object count, or null when there is no usable descriptor.
*/
private function shippedObjectCount(): ?int {
$path = $this->descriptorPath();
if (is_file($path) === false) {
return null;
}

$raw = file_get_contents($path);
if ($raw === false) {
return null;
}

$data = json_decode($raw, true);
if (is_array($data) === false) {
return null;
}

$components = ($data['components'] ?? []);
if (is_array($components) === false || is_array(($components['objects'] ?? null)) === false) {
return 0;
}

return count($components['objects']);

}//end shippedObjectCount()

/**
* Import the demo dataset.
*
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
],
"dependencies": {
"@codemirror/lang-json": "^6.0.1",
"@conduction/nextcloud-vue": "^2.31.1",
"@conduction/nextcloud-vue": "^2.37.0",
"@mdi/js": "^7.4.47",
"@nextcloud/auth": "^2.6.0",
"@nextcloud/axios": "~2.6.0",
Expand Down
Loading
Loading