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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The App, Widget and Folder tiles under Add New show their names instead of
their internal keys.

- The Backup widget shows the job picker for the provider you picked. Kopia
showed the Duplicati picker as well, and Duplicati showed no picker at all.

## [1.6.0] - 2026-08-13

### Added
Expand Down
40 changes: 29 additions & 11 deletions ui/js/admin-logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,26 +30,44 @@ export function showIfMatches(cond, current) {
}

/* Follow showIf chains. A hidden controller still holds a value underneath, and
its dependants would show themselves against it. */
export function visibleFieldKeys(fields, readValue) {
const byKey = new Map(fields.map(f => [f.key, f]));
its dependants would show themselves against it.

Resolve each field by its position. Two fields may share a key, one per
branch of a showIf, and resolving by key gives both the same answer: the
wrong branch is shown, or both branches are hidden. */
export function visibleFieldFlags(fields, readValue) {
const idxsByKey = new Map();
fields.forEach((f, i) => {
if (!f || f.key == null) return;
const at = idxsByKey.get(f.key);
if (at) at.push(i);
else idxsByKey.set(f.key, [i]);
});
const memo = new Map();
const isShown = key => {
if (memo.has(key)) return memo.get(key);
memo.set(key, false); /* guard against a cycle in malformed manifests */
const f = byKey.get(key);
const isShown = i => {
if (memo.has(i)) return memo.get(i);
memo.set(i, false); /* guard against a cycle in malformed manifests */
const f = fields[i];
let ok = true;
if (f && f.showIf) {
const dep = f.showIf.field;
ok = byKey.has(dep)
? isShown(dep) && showIfMatches(f.showIf, readValue(dep))
const depIdxs = idxsByKey.get(dep);
ok = depIdxs
? depIdxs.some(j => isShown(j)) && showIfMatches(f.showIf, readValue(dep))
: showIfMatches(f.showIf, readValue(dep));
}
memo.set(key, ok);
memo.set(i, ok);
return ok;
};
return fields.map((f, i) => (f && f.key != null ? isShown(i) : false));
}

export function visibleFieldKeys(fields, readValue) {
const flags = visibleFieldFlags(fields, readValue);
const out = new Set();
for (const f of fields) if (f.key != null && isShown(f.key)) out.add(f.key);
fields.forEach((f, i) => {
if (flags[i]) out.add(f.key);
});
return out;
}

Expand Down
12 changes: 6 additions & 6 deletions ui/js/widget-config-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
collectFieldValues,
requiredFieldMissing,
groupBounds,
visibleFieldKeys,
visibleFieldFlags,
} from '/js/admin-logic.js?v=056a11e9';
import { optionsErrorText } from '/js/admin-error.js?v=af729113';
import { qi } from '/js/utils.js?v=84d58686';
Expand Down Expand Up @@ -489,11 +489,11 @@ function _wireShowIf(built) {
const fields = built.map(b => b.field);
const readValue = key => (liveByKey[key] ? liveByKey[key]() : undefined);
const apply = () => {
const shown = visibleFieldKeys(fields, readValue);
for (const b of built) {
if (!b.field.showIf) continue;
b.el.style.display = shown.has(b.field.key) ? '' : 'none';
}
const shown = visibleFieldFlags(fields, readValue);
built.forEach((b, i) => {
if (!b.field.showIf) return;
b.el.style.display = shown[i] ? '' : 'none';
});
};
for (const b of built) {
if (b.control) b.control.addEventListener('change', apply);
Expand Down
19 changes: 17 additions & 2 deletions ui/test/config-card-visibility.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { register } from 'node:module';
register('./js-root-hooks.mjs', import.meta.url);

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const { visibleFieldKeys } = await import('../js/admin-logic.js');
const { visibleFieldKeys, visibleFieldFlags } = await import('../js/admin-logic.js');

const widgets = fs
.readdirSync(path.join(root, 'widgets'), { withFileTypes: true })
Expand Down Expand Up @@ -149,5 +149,20 @@ test('a field is hidden when its condition is not met', () => {

test('the form applies that to the rendered rows', () => {
const src = fs.readFileSync(path.join(root, 'js/widget-config-form.js'), 'utf8');
assert.match(src, /b\.el\.style\.display = shown\.has\(b\.field\.key\) \? '' : 'none'/);
assert.match(src, /b\.el\.style\.display = shown\[i\] \? '' : 'none'/);
});

/* Two fields may share a key, one per branch. Resolving by key gave both the
same answer: with kopia picked the duplicati job picker showed too, and with
duplicati picked neither showed. */
test('fields sharing a key follow their own condition', () => {
const fields = [
{ key: 'provider', type: 'select' },
{ key: 'jobId', type: 'select', optionsFrom: 'duplicati-jobs', showIf: { field: 'provider', equals: 'duplicati' } },
{ key: 'jobId', type: 'select', optionsFrom: 'kopia-sources', showIf: { field: 'provider', equals: 'kopia' } },
];
const at = provider => visibleFieldFlags(fields, k => (k === 'provider' ? provider : undefined));
assert.deepEqual(at('kopia'), [true, false, true]);
assert.deepEqual(at('duplicati'), [true, true, false]);
assert.deepEqual(at(''), [true, false, false]);
});
Loading