fix: support radio inputs in admin system config form #724

Merged
jmiller merged 5 commits from fix/admin-config-radio into main 2026-07-04 19:01:40 +00:00
7 changed files with 61 additions and 13 deletions
+3
View File
@@ -120,3 +120,6 @@ prime/
# A Makefile for custom make targets
Makefile.local
# Local clone of the MCP server (separate repo, not a submodule of this project)
/mcp-mokogitea-api/
+12 -3
View File
@@ -47,6 +47,15 @@ jobs:
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
# This RC flow drives a Joomla-style update stream (updates.xml). Repos that don't ship
# one (e.g. generic Go/TS) have nothing to package here, so no-op cleanly instead of
# aborting under `set -e` when the file is absent.
if [ ! -f updates.xml ]; then
echo "has_updates=false" >> "$GITHUB_OUTPUT"
echo "No updates.xml in this repo — skipping RC update-stream packaging"
exit 0
fi
echo "has_updates=true" >> "$GITHUB_OUTPUT"
BASE_VERSION=$(sed -n 's/.*<version>\(.*\)<\/version>.*/\1/p' updates.xml | head -1)
[ -z "$BASE_VERSION" ] && BASE_VERSION="04.00.00"
RC_VERSION="${BASE_VERSION}-rc.${PR_NUMBER}"
@@ -56,7 +65,7 @@ jobs:
echo "RC version: $RC_VERSION (tag: $RC_TAG)"
- name: Update updates.xml RC channel
if: steps.guard.outputs.skip != 'true'
if: steps.guard.outputs.skip != 'true' && steps.version.outputs.has_updates == 'true'
env:
RC_VERSION: ${{ steps.version.outputs.version }}
RC_TAG: ${{ steps.version.outputs.tag }}
@@ -106,7 +115,7 @@ jobs:
PYEOF
- name: Create RC release
if: steps.guard.outputs.skip != 'true'
if: steps.guard.outputs.skip != 'true' && steps.version.outputs.has_updates == 'true'
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
RC_TAG: ${{ steps.version.outputs.tag }}
@@ -153,7 +162,7 @@ jobs:
PYEOF
- name: Commit updates.xml
if: steps.guard.outputs.skip != 'true'
if: steps.guard.outputs.skip != 'true' && steps.version.outputs.has_updates == 'true'
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
+14 -9
View File
@@ -47,15 +47,15 @@ jobs:
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
REASON="Fix branches must target 'dev' or 'main', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
REASON="Patch branches must target 'dev', 'rc', or 'main', not '${BASE}'"
fi
;;
hotfix/*)
@@ -86,7 +86,8 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`patch/*\` → \`dev\`, \`rc\`, or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
@@ -147,10 +148,14 @@ jobs:
- name: Detect platform
id: platform
run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
# Read platform from XML manifest (<platform> tag) if present; otherwise default to generic.
# manifest.xml was removed in favor of the metadata API, so guard for its absence to avoid
# aborting under `set -e` when the file is missing.
PLATFORM="generic"
if [ -f .mokogitea/manifest.xml ]; then
DETECTED=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml | head -1)
[ -n "$DETECTED" ] && PLATFORM="$DETECTED"
fi
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
+5
View File
@@ -57,6 +57,11 @@
- Cherry-pick upstream v1.26.4: walk git log context error handling — regression fix (#38185)
### Fixed
- Admin config form: radio inputs (e.g. instance landing page Mode) no longer throw "Unsupported config form value mapping", which had aborted all JS init on the admin settings page
- PR check branch policy: allow `fix/*``main` and `patch/*``main` to match documented policy (was rejecting fix/patch PRs to main)
- PR check platform detection: guard for missing `.mokogitea/manifest.xml` so the Validate PR job no longer aborts under `set -e` (manifest replaced by metadata API)
- Remove dangling `mcp-mokogitea-api` submodule gitlink (no `.gitmodules` entry) that broke `submodule update --init` at checkout, failing all PR build/release jobs; ignore the local clone path
- PR RC Release workflow: no-op cleanly when `updates.xml` is absent (generic repos) instead of aborting the "Determine RC version" step under `set -e`
- PR check: platform detection now queries metadata API instead of removed manifest.xml
- Cherry-pick upstream v1.26.2: handle empty pull request files view to allow reviews (#37783)
- Cherry-pick upstream v1.26.2: fix "run as root" check with snap container detection (#37622)
Submodule mcp-mokogitea-api deleted from dbaf91546e
+13
View File
@@ -21,6 +21,17 @@ test('ConfigFormValueMapper', () => {
<input name="struct.SubBoolean" type="checkbox" data-config-value-type="boolean">
<input name="struct.SubTimestamp" type="datetime-local" data-config-value-type="timestamp">
<textarea name="struct.NewKey">new-value</textarea>
<!-- radio group (sub key): only the option matching the config value should be checked and collected -->
<input type="hidden" data-config-dyn-key="landing" data-config-value-json='{"Mode": "explore"}'>
<input name="landing.Mode" type="radio" value="home">
<input name="landing.Mode" type="radio" value="explore">
<input name="landing.Mode" type="radio" value="login">
<!-- radio group with empty value: the server-rendered default (home) must be preserved, not unchecked -->
<input type="hidden" data-config-dyn-key="landingDefault" data-config-value-json='{"Mode": ""}'>
<input name="landingDefault.Mode" type="radio" value="home" checked>
<input name="landingDefault.Mode" type="radio" value="explore">
</form>
`;
@@ -44,5 +55,7 @@ test('ConfigFormValueMapper', () => {
'k-flipped-true': 'true',
'repository.open-with.editor-apps': '[{"DisplayName":"a","OpenURL":"b"}]', // TODO: OPEN-WITH-EDITOR-APP-JSON: it must match backend
'struct': '{"SubBoolean":true,"SubTimestamp":123456780,"OtherKey":"other-value","NewKey":"new-value"}',
'landing': '{"Mode":"explore"}',
'landingDefault': '{"Mode":"home"}',
});
});
+14
View File
@@ -99,6 +99,10 @@ export class ConfigFormValueMapper {
if (el.matches('[type="checkbox"]')) {
if (valType !== 'boolean') requireExplicitValueType(el);
el.checked = Boolean(val ?? el.checked);
} else if (el.matches('[type="radio"]')) {
// a radio group shares one name; check only the option whose value equals the config value.
// when the value is empty (unset), leave the server-rendered default selection untouched.
if (String(val) !== '') el.checked = el.value === String(val);
} else if (el.matches('[type="datetime-local"]')) {
if (valType !== 'timestamp') requireExplicitValueType(el);
if (val) el.value = toDatetimeLocalValue(val);
@@ -120,6 +124,9 @@ export class ConfigFormValueMapper {
// it needs to iterate the "namedElems" to find all the checkboxes with the same name and collect values accordingly,
// and set the namedElems[matchedIdx] to null to avoid duplicate processing.
val = collectCheckboxBooleanValue(el);
} else if (el.matches('[type="radio"]')) {
// only the checked radio of a group reaches here (callers skip unchecked ones); its value is the selection
val = el.value;
} else if (el.matches('[type="datetime-local"]')) {
if (valType !== 'timestamp') requireExplicitValueType(el);
val = Math.floor(new Date(el.value).getTime() / 1000) ?? 0; // NaN is fine to JSON.stringify, it becomes null.
@@ -139,6 +146,12 @@ export class ConfigFormValueMapper {
if (!el) continue;
const subKey = extractElemConfigSubKey(el, dynKey);
if (!subKey) continue; // if not match, skip
// a radio group has N elements sharing the same name; only the checked one carries the value.
// drop the unchecked ones so they neither overwrite the selection here nor leak into the fallback loop.
if (el.matches('[type="radio"]') && !el.checked) {
namedElems[idx] = null;
continue;
}
cfgVal[subKey] = this.collectConfigValueFromElement(el);
namedElems[idx] = null;
}
@@ -194,6 +207,7 @@ export class ConfigFormValueMapper {
// "foo.enabled" => "true"
for (const el of namedElems) {
if (!el) continue;
if (el.matches('[type="radio"]') && !el.checked) continue; // skip unchecked radios of a top-level group
const dynKey = el.name;
const newVal = this.collectConfigValueFromElement(el);
formData.append('key', dynKey);