@rapidmx/web-client 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,7 @@ import {
15
15
  planPluginChange,
16
16
  Plugin,
17
17
  PluginChangePlan,
18
+ PluginConfiguredSetting,
18
19
  PluginInstanceStatus,
19
20
  PluginNamespace,
20
21
  PluginPurgeInfo,
@@ -1315,13 +1316,39 @@ function suggestedHost(definition: PluginSettingDefinition, saved: PluginSetting
1315
1316
  return unset ? definition.default.split(HOST_PLACEHOLDER).join(window.location.host) : undefined;
1316
1317
  }
1317
1318
 
1318
- /** A setting's current form value: its saved value, else its default - or, for a required select with neither, its
1319
- * first option. Kept as a string for text inputs. */
1320
- function initialValue(definition: PluginSettingDefinition, saved: PluginSettingValue | undefined): PluginSettingValue | "" {
1319
+ /** What the deployment configures for a setting, as a form value: nothing for a secret, whose value the server keeps to itself. */
1320
+ function configuredValue(definition: PluginSettingDefinition, configured: PluginConfiguredSetting): PluginSettingValue | "" {
1321
+ if (definition.type === "boolean") {
1322
+ return configured.value === true;
1323
+ }
1324
+ return configured.value === undefined ? "" : String(configured.value);
1325
+ }
1326
+
1327
+ /** Whether a saved value is absent - nothing, or the empty value a plugin's own "" default is saved as. */
1328
+ function isUnsaved(saved: PluginSettingValue | undefined): boolean {
1329
+ return saved === undefined || saved === "";
1330
+ }
1331
+
1332
+ /** The suggestion for the host, unless the deployment configures the setting, which is offered instead. */
1333
+ function hostSuggestion(definition: PluginSettingDefinition, saved: PluginSettingValue | undefined, configured: PluginConfiguredSetting | undefined): string | undefined {
1334
+ return configured ? undefined : suggestedHost(definition, saved);
1335
+ }
1336
+
1337
+ /** A setting's current form value: its saved value (which wins over the deployment's configuration); else what the
1338
+ * deployment configures; else the setting's own default - or, for a required select with none of those, its first
1339
+ * option. Kept as a string for text inputs. */
1340
+ function initialValue(
1341
+ definition: PluginSettingDefinition,
1342
+ saved: PluginSettingValue | undefined,
1343
+ configured?: PluginConfiguredSetting,
1344
+ ): PluginSettingValue | "" {
1345
+ if (configured && isUnsaved(saved)) {
1346
+ return configuredValue(definition, configured);
1347
+ }
1321
1348
  if (needsSelection(definition, saved)) {
1322
1349
  return definition.options![0].value;
1323
1350
  }
1324
- const suggestion = suggestedHost(definition, saved);
1351
+ const suggestion = hostSuggestion(definition, saved, configured);
1325
1352
  if (suggestion !== undefined) {
1326
1353
  return suggestion;
1327
1354
  }
@@ -1335,29 +1362,53 @@ function initialValue(definition: PluginSettingDefinition, saved: PluginSettingV
1335
1362
  function SettingsModal({ plugin, onClose, onSaved }: { plugin: Plugin; onClose: () => void; onSaved: (plugin: Plugin) => void }) {
1336
1363
  // Only opened from the Settings button, which is only shown for a plugin that declares settings.
1337
1364
  const definitions: PluginSettingDefinition[] = plugin.manifest.settings!;
1338
- const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key])])));
1365
+ const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key], plugin.configured?.[d.key])])));
1339
1366
  const [values, setValues] = useState<Record<string, PluginSettingValue | "">>(initial.current);
1367
+ // Set by Reset: saving then drops every saved value, so the deployment's configuration (or the default) applies again.
1368
+ const [resetting, setResetting] = useState(false);
1340
1369
  const [error, setError] = useState<string | null>(null);
1341
1370
  const [busy, setBusy] = useState(false);
1342
1371
 
1372
+ /** Puts every field back to what it shows with nothing saved. Nothing is stored until the form is saved. */
1373
+ function reset() {
1374
+ setValues(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, undefined, plugin.configured?.[d.key])])));
1375
+ setResetting(true);
1376
+ setError(null);
1377
+ }
1378
+
1343
1379
  async function save(e: FormEvent) {
1344
1380
  e.preventDefault();
1345
1381
  // The server replaces the whole settings object, so every setting is sent. One left alone is sent as it's saved
1346
1382
  // - `null` when nothing is, so it keeps following the plugin's default rather than pinning the default shown.
1347
1383
  // A required select with nothing to fall back on, and a setting offering this console's host, are sent as the
1348
- // form shows them.
1384
+ // form shows them. What the deployment configures is never saved as a copy, which would outrank it when it changes
1385
+ // - a field that shows it is sent as nothing.
1349
1386
  const settings: Record<string, PluginSettingValue | null> = {};
1350
1387
  let changed = false;
1351
1388
  for (const definition of definitions) {
1352
1389
  const key: string = definition.key;
1390
+ const configured: PluginConfiguredSetting | undefined = plugin.configured?.[key];
1391
+ const saved: PluginSettingValue | undefined = plugin.settings[key];
1353
1392
  const value = values[key];
1354
- if (value === initial.current[key] && !needsSelection(definition, plugin.settings[key]) && suggestedHost(definition, plugin.settings[key]) === undefined) {
1355
- settings[key] = plugin.settings[key] ?? null;
1393
+ if (resetting && value === initialValue(definition, undefined, configured)) {
1394
+ // What a fresh install saves: only a default naming this console's host has to be, for the plugin to work.
1395
+ // A saved value that already is the default shown stays, since dropping it changes nothing.
1396
+ settings[key] = !configured && saved !== undefined && String(saved) === String(value) ? saved : (hostSuggestion(definition, undefined, configured) ?? null);
1397
+ changed ||= settings[key] !== (saved ?? null);
1398
+ continue;
1399
+ }
1400
+ if (!resetting && value === initial.current[key] && !needsSelection(definition, saved) && hostSuggestion(definition, saved, configured) === undefined) {
1401
+ settings[key] = saved ?? null;
1356
1402
  continue;
1357
1403
  }
1358
1404
  changed = true;
1359
1405
  // A number input only ever reports a valid number or an empty string.
1360
- settings[key] = value === "" ? null : definition.type === "number" ? Number(value) : value;
1406
+ settings[key] =
1407
+ value === "" || (configured && !configured.secret && String(value) === String(configured.value))
1408
+ ? null
1409
+ : definition.type === "number"
1410
+ ? Number(value)
1411
+ : value;
1361
1412
  }
1362
1413
  if (!changed) {
1363
1414
  onClose();
@@ -1383,14 +1434,22 @@ function SettingsModal({ plugin, onClose, onSaved }: { plugin: Plugin; onClose:
1383
1434
  key={definition.key}
1384
1435
  definition={definition}
1385
1436
  value={values[definition.key]}
1437
+ configured={plugin.configured?.[definition.key]}
1386
1438
  onChange={(value) => setValues((prev) => ({ ...prev, [definition.key]: value }))}
1387
1439
  />
1388
1440
  ))}
1389
1441
  {/* Kept in view at the bottom of the dialog while a long list of settings scrolls. The dialog scrolls
1390
1442
  inside its padding, so the bar reaches past it to the dialog's edges. */}
1391
1443
  <div className="sticky -bottom-7 -mx-7 -mb-7 px-7 py-4 bg-surface border-t border-border flex flex-col gap-3">
1392
- <p className="text-xs text-text-muted">Saving restarts the servers one at a time to apply the new settings.</p>
1444
+ <p className="text-xs text-text-muted">
1445
+ {resetting
1446
+ ? "Every setting is back to what this deployment's configuration or the plugin's default gives. Save to apply that."
1447
+ : "Saving restarts the servers one at a time to apply the new settings."}
1448
+ </p>
1393
1449
  <div className="flex gap-2 justify-end">
1450
+ <Button type="button" variant="secondary" className="!w-auto mr-auto" onClick={reset} disabled={busy}>
1451
+ Reset
1452
+ </Button>
1394
1453
  <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
1395
1454
  Cancel
1396
1455
  </Button>
@@ -1407,13 +1466,28 @@ function SettingsModal({ plugin, onClose, onSaved }: { plugin: Plugin; onClose:
1407
1466
  function SettingField({
1408
1467
  definition,
1409
1468
  value,
1469
+ configured,
1410
1470
  onChange,
1411
1471
  }: {
1412
1472
  definition: PluginSettingDefinition;
1413
1473
  value: PluginSettingValue | "";
1474
+ configured?: PluginConfiguredSetting;
1414
1475
  onChange: (value: PluginSettingValue | "") => void;
1415
1476
  }) {
1416
- const help = definition.help && <span className="text-xs text-text-muted">{definition.help}</span>;
1477
+ // What applies without a saved value; one saved here replaces it, and Reset goes back to it.
1478
+ const note = configured && (
1479
+ <span className="text-xs text-text-muted">
1480
+ This deployment&apos;s configuration provides {configured.secret ? "a value" : "the value shown"} unless one is saved here.
1481
+ </span>
1482
+ );
1483
+ // A secret's value never reaches the browser, so the field only says that it is set.
1484
+ const placeholder: string | undefined = configured?.secret ? "Set by this deployment" : undefined;
1485
+ const help = (
1486
+ <>
1487
+ {definition.help && <span className="text-xs text-text-muted">{definition.help}</span>}
1488
+ {note}
1489
+ </>
1490
+ );
1417
1491
  if (definition.type === "boolean") {
1418
1492
  // Laid out like the admin policy forms' checkboxes: the help sits under the label, beside the box.
1419
1493
  return (
@@ -1445,7 +1519,8 @@ function SettingField({
1445
1519
  type={definition.type === "number" ? "number" : "text"}
1446
1520
  min={definition.min}
1447
1521
  max={definition.max}
1448
- required={definition.required}
1522
+ required={definition.required && !configured}
1523
+ placeholder={placeholder}
1449
1524
  value={String(value)}
1450
1525
  onChange={(e) => onChange(e.target.value)}
1451
1526
  />
@@ -616,13 +616,32 @@ function suggestedHost(definition, saved) {
616
616
  const unset = saved === undefined || saved === "" || (typeof saved === "string" && saved.includes(HOST_PLACEHOLDER));
617
617
  return unset ? definition.default.split(HOST_PLACEHOLDER).join(window.location.host) : undefined;
618
618
  }
619
- /** A setting's current form value: its saved value, else its default - or, for a required select with neither, its
620
- * first option. Kept as a string for text inputs. */
621
- function initialValue(definition, saved) {
619
+ /** What the deployment configures for a setting, as a form value: nothing for a secret, whose value the server keeps to itself. */
620
+ function configuredValue(definition, configured) {
621
+ if (definition.type === "boolean") {
622
+ return configured.value === true;
623
+ }
624
+ return configured.value === undefined ? "" : String(configured.value);
625
+ }
626
+ /** Whether a saved value is absent - nothing, or the empty value a plugin's own "" default is saved as. */
627
+ function isUnsaved(saved) {
628
+ return saved === undefined || saved === "";
629
+ }
630
+ /** The suggestion for the host, unless the deployment configures the setting, which is offered instead. */
631
+ function hostSuggestion(definition, saved, configured) {
632
+ return configured ? undefined : suggestedHost(definition, saved);
633
+ }
634
+ /** A setting's current form value: its saved value (which wins over the deployment's configuration); else what the
635
+ * deployment configures; else the setting's own default - or, for a required select with none of those, its first
636
+ * option. Kept as a string for text inputs. */
637
+ function initialValue(definition, saved, configured) {
638
+ if (configured && isUnsaved(saved)) {
639
+ return configuredValue(definition, configured);
640
+ }
622
641
  if (needsSelection(definition, saved)) {
623
642
  return definition.options[0].value;
624
643
  }
625
- const suggestion = suggestedHost(definition, saved);
644
+ const suggestion = hostSuggestion(definition, saved, configured);
626
645
  if (suggestion !== undefined) {
627
646
  return suggestion;
628
647
  }
@@ -635,28 +654,51 @@ function initialValue(definition, saved) {
635
654
  function SettingsModal({ plugin, onClose, onSaved }) {
636
655
  // Only opened from the Settings button, which is only shown for a plugin that declares settings.
637
656
  const definitions = plugin.manifest.settings;
638
- const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key])])));
657
+ const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key], plugin.configured?.[d.key])])));
639
658
  const [values, setValues] = useState(initial.current);
659
+ // Set by Reset: saving then drops every saved value, so the deployment's configuration (or the default) applies again.
660
+ const [resetting, setResetting] = useState(false);
640
661
  const [error, setError] = useState(null);
641
662
  const [busy, setBusy] = useState(false);
663
+ /** Puts every field back to what it shows with nothing saved. Nothing is stored until the form is saved. */
664
+ function reset() {
665
+ setValues(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, undefined, plugin.configured?.[d.key])])));
666
+ setResetting(true);
667
+ setError(null);
668
+ }
642
669
  async function save(e) {
643
670
  e.preventDefault();
644
671
  // The server replaces the whole settings object, so every setting is sent. One left alone is sent as it's saved
645
672
  // - `null` when nothing is, so it keeps following the plugin's default rather than pinning the default shown.
646
673
  // A required select with nothing to fall back on, and a setting offering this console's host, are sent as the
647
- // form shows them.
674
+ // form shows them. What the deployment configures is never saved as a copy, which would outrank it when it changes
675
+ // - a field that shows it is sent as nothing.
648
676
  const settings = {};
649
677
  let changed = false;
650
678
  for (const definition of definitions) {
651
679
  const key = definition.key;
680
+ const configured = plugin.configured?.[key];
681
+ const saved = plugin.settings[key];
652
682
  const value = values[key];
653
- if (value === initial.current[key] && !needsSelection(definition, plugin.settings[key]) && suggestedHost(definition, plugin.settings[key]) === undefined) {
654
- settings[key] = plugin.settings[key] ?? null;
683
+ if (resetting && value === initialValue(definition, undefined, configured)) {
684
+ // What a fresh install saves: only a default naming this console's host has to be, for the plugin to work.
685
+ // A saved value that already is the default shown stays, since dropping it changes nothing.
686
+ settings[key] = !configured && saved !== undefined && String(saved) === String(value) ? saved : (hostSuggestion(definition, undefined, configured) ?? null);
687
+ changed || (changed = settings[key] !== (saved ?? null));
688
+ continue;
689
+ }
690
+ if (!resetting && value === initial.current[key] && !needsSelection(definition, saved) && hostSuggestion(definition, saved, configured) === undefined) {
691
+ settings[key] = saved ?? null;
655
692
  continue;
656
693
  }
657
694
  changed = true;
658
695
  // A number input only ever reports a valid number or an empty string.
659
- settings[key] = value === "" ? null : definition.type === "number" ? Number(value) : value;
696
+ settings[key] =
697
+ value === "" || (configured && !configured.secret && String(value) === String(configured.value))
698
+ ? null
699
+ : definition.type === "number"
700
+ ? Number(value)
701
+ : value;
660
702
  }
661
703
  if (!changed) {
662
704
  onClose();
@@ -674,15 +716,21 @@ function SettingsModal({ plugin, onClose, onSaved }) {
674
716
  setBusy(false);
675
717
  }
676
718
  }
677
- return (_jsxs(Modal, { open: true, onClose: onClose, title: `${plugin.manifest.displayName} settings`, children: [error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: save, className: "flex flex-col gap-5", children: [definitions.map((definition) => (_jsx(SettingField, { definition: definition, value: values[definition.key], onChange: (value) => setValues((prev) => ({ ...prev, [definition.key]: value })) }, definition.key))), _jsxs("div", { className: "sticky -bottom-7 -mx-7 -mb-7 px-7 py-4 bg-surface border-t border-border flex flex-col gap-3", children: [_jsx("p", { className: "text-xs text-text-muted", children: "Saving restarts the servers one at a time to apply the new settings." }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", className: "!w-auto", loading: busy, disabled: busy, children: "Save" })] })] })] })] }));
719
+ return (_jsxs(Modal, { open: true, onClose: onClose, title: `${plugin.manifest.displayName} settings`, children: [error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: save, className: "flex flex-col gap-5", children: [definitions.map((definition) => (_jsx(SettingField, { definition: definition, value: values[definition.key], configured: plugin.configured?.[definition.key], onChange: (value) => setValues((prev) => ({ ...prev, [definition.key]: value })) }, definition.key))), _jsxs("div", { className: "sticky -bottom-7 -mx-7 -mb-7 px-7 py-4 bg-surface border-t border-border flex flex-col gap-3", children: [_jsx("p", { className: "text-xs text-text-muted", children: resetting
720
+ ? "Every setting is back to what this deployment's configuration or the plugin's default gives. Save to apply that."
721
+ : "Saving restarts the servers one at a time to apply the new settings." }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto mr-auto", onClick: reset, disabled: busy, children: "Reset" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", className: "!w-auto", loading: busy, disabled: busy, children: "Save" })] })] })] })] }));
678
722
  }
679
- function SettingField({ definition, value, onChange, }) {
680
- const help = definition.help && _jsx("span", { className: "text-xs text-text-muted", children: definition.help });
723
+ function SettingField({ definition, value, configured, onChange, }) {
724
+ // What applies without a saved value; one saved here replaces it, and Reset goes back to it.
725
+ const note = configured && (_jsxs("span", { className: "text-xs text-text-muted", children: ["This deployment's configuration provides ", configured.secret ? "a value" : "the value shown", " unless one is saved here."] }));
726
+ // A secret's value never reaches the browser, so the field only says that it is set.
727
+ const placeholder = configured?.secret ? "Set by this deployment" : undefined;
728
+ const help = (_jsxs(_Fragment, { children: [definition.help && _jsx("span", { className: "text-xs text-text-muted", children: definition.help }), note] }));
681
729
  if (definition.type === "boolean") {
682
730
  // Laid out like the admin policy forms' checkboxes: the help sits under the label, beside the box.
683
731
  return (_jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", className: "mt-1", checked: value === true, onChange: (e) => onChange(e.target.checked) }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "font-semibold", children: definition.label }), help] })] }));
684
732
  }
685
- return (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: definition.label }), definition.type === "select" ? (_jsxs("select", { "aria-label": definition.label, className: INPUT_CLASS, value: String(value), onChange: (e) => onChange(e.target.value), children: [!definition.required && _jsx("option", { value: "", children: "Default" }), (definition.options ?? []).map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value)))] })) : (_jsx("input", { "aria-label": definition.label, className: INPUT_CLASS, type: definition.type === "number" ? "number" : "text", min: definition.min, max: definition.max, required: definition.required, value: String(value), onChange: (e) => onChange(e.target.value) })), help] }));
733
+ return (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: definition.label }), definition.type === "select" ? (_jsxs("select", { "aria-label": definition.label, className: INPUT_CLASS, value: String(value), onChange: (e) => onChange(e.target.value), children: [!definition.required && _jsx("option", { value: "", children: "Default" }), (definition.options ?? []).map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value)))] })) : (_jsx("input", { "aria-label": definition.label, className: INPUT_CLASS, type: definition.type === "number" ? "number" : "text", min: definition.min, max: definition.max, required: definition.required && !configured, placeholder: placeholder, value: String(value), onChange: (e) => onChange(e.target.value) })), help] }));
686
734
  }
687
735
  function RemoveModal({ plugin, onClose, onRemoved }) {
688
736
  const [error, setError] = useState(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/web-client",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "RapidMX's webmail and admin console React UI for web-based clients.",
5
5
  "type": "module",
6
6
  "repository": "https://github.com/rapidmx/web-client",
@@ -40,7 +40,7 @@
40
40
  "@dnd-kit/core": "^6.3.1",
41
41
  "@journeyapps/wa-sqlite": "^2.0.4",
42
42
  "@peculiar/x509": "^2.1.0",
43
- "@rapidmx/react-shared": "^0.19.0",
43
+ "@rapidmx/react-shared": "^0.20.0",
44
44
  "@tiptap/core": "^3.31.3",
45
45
  "@tiptap/extension-highlight": "^3.31.3",
46
46
  "@tiptap/extension-image": "^3.31.3",