Skip to content

Commit 9d5c818

Browse files
feat(applications): guide users to set the metadata methods expect (#22)
* feat(applications): method cards with live readiness and example trials * feat(applications): collapsible list and method accordion Only the application page scrolls now, keeping the strip and status in view. Assisted-by: Claude Opus 5.5 * feat(applications): guide users to set the metadata methods expect Assisted-by: Claude Opus 5.5
1 parent 77517d9 commit 9d5c818

9 files changed

Lines changed: 133 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
2323
- **Applications:** each method now shows the inputs it expects and a live status telling whether the current selection can be analyzed, and why not. _Run on selection…_ runs a ready method directly and asks for the input assignment only when it is ambiguous or invalid; the assignment dialog describes each input, flags objects lacking required metadata, and checks the chosen objects before _Continue_. Examples are listed under the methods they are designed for: _Try with this example_ opens the example and runs the method with prefilled parameters. One example may serve several methods, and examples designed for no method are listed as datasets.
2424
- **Applications window:** the strip between the application list and the application page hides the list, or shows it again; the window shrinks or grows accordingly and remembers this choice. The application page now scrolls on its own, so the list and the run status stay in view.
2525
- **Application methods:** the methods and datasets of an application now form an accordion. One section is open at a time (the deep-linked method, or else the first one), and a colored dot shows whether each method can run on the current selection without opening it.
26+
- **Application metadata:** when required metadata are missing, the status of a method points to _Edit > Metadata > Add metadata…_, whose **Known keys** list now also offers the keys expected by application methods. The status is updated as soon as objects are modified, and long keys no longer widen parameter dialogs.
2627
- **Welcome page:** the "NEW" badge on the release notes entry has been removed.
2728
- **Histogram range editors:** generic parameter intervals now respect linked numeric constraints and read-only states. Brightness and contrast retains its specialized controls, including exact float64 window editing outside the source image's observed range.
2829
- **Guidata compatibility:** browser runtimes now require guidata 3.15 and use

‎src/components/ApplicationsDialog.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,15 @@ export function ApplicationsDialog({
199199
const selectedPluginId = selected?.plugin_id ?? null;
200200
const recipeKey = selected?.recipes.map((recipe) => recipe.id).join("\n");
201201
const candidateKey = candidateIds.join("\n");
202+
// Edits such as Add metadata change readiness without changing the selection
203+
const [objectsRevision, setObjectsRevision] = useState(0);
204+
useEffect(
205+
() =>
206+
runtime?.onWorkspaceMutation(() =>
207+
setObjectsRevision((revision) => revision + 1),
208+
),
209+
[runtime],
210+
);
202211
useEffect(() => {
203212
if (!runtime || !selectedPluginId || !recipeKey || busy) return;
204213
let cancelled = false;
@@ -235,6 +244,7 @@ export function ApplicationsDialog({
235244
busy,
236245
candidateKey,
237246
exampleContext,
247+
objectsRevision,
238248
recipeKey,
239249
runtime,
240250
selectedPluginId,

‎src/locales/fr.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,6 +1069,7 @@
10691069
"Time deviation…": "Ecart-type temporel…",
10701070
"Title": "Titre",
10711071
"to load an HDF5 file.": "pour charger un fichier HDF5.",
1072+
"To set it, use Edit > Metadata > Add metadata…": "Pour la définir, utilisez Édition > Métadonnées > Ajouter des métadonnées…",
10721073
"Toolbar": "Barre d'outils",
10731074
"Total variance": "Variance totale",
10741075
"Total variance…": "Variance totale…",

‎src/runtime/bootstrap.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2045,8 +2045,11 @@ class AddMetadataParam(
20452045

20462046
def _metadata_key_suggestions(objs: list[Any]) -> list[tuple[str, str]]:
20472047
"""Return the user-visible scalar metadata keys of *objs*, with an example
2048-
value (mirrors Qt's ``collect_metadata_keys``)."""
2048+
value, then the keys expected by application methods (mirrors Qt's
2049+
``BaseDataPanel.get_known_metadata_keys``)."""
2050+
import dlw_plugins
20492051
from sigima.config import _
2052+
from sigima.objects import SignalObj
20502053
from sigima.objects.base import ROI_KEY
20512054

20522055
examples: dict[str, Any] = {}
@@ -2058,7 +2061,15 @@ def _metadata_key_suggestions(objs: list[Any]) -> list[tuple[str, str]]:
20582061
continue
20592062
if isinstance(value, (str, bool, int, float, np.integer, np.floating)):
20602063
examples[key] = value
2061-
return [(key, _("e.g. %s") % repr(examples[key])) for key in sorted(examples)]
2064+
keys = [(key, _("e.g. %s") % repr(examples[key])) for key in sorted(examples)]
2065+
kinds = {"signal" if isinstance(obj, SignalObj) else "image" for obj in objs}
2066+
if len(kinds) == 1:
2067+
keys.extend(
2068+
(key, description)
2069+
for key, description in dlw_plugins.declared_metadata_keys(kinds.pop())
2070+
if key not in examples
2071+
)
2072+
return keys
20622073

20632074

20642075
def _build_metadata_values(objs: list[Any], param: Any) -> list[Any]:

‎src/runtime/dlw_plugins.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
# pattern that lets ``install_main`` swap the live bridge after HMR.
3535
# pylint: disable=import-error,global-statement,broad-exception-caught
3636
from datalab import registries # noqa: F401 # used elsewhere in module
37-
from datalab.plugins import PluginBase, PluginRegistry # noqa: F401
37+
from datalab.plugins import PluginBase, PluginCapability, PluginRegistry # noqa: F401
3838
from dlw_wheels import inspect_wheel
3939

4040
PLUGINS_ROOT = "/home/pyodide/dlw_plugins"
@@ -630,6 +630,34 @@ def get_plugin_class(
630630
return record.classes[0]
631631

632632

633+
def declared_metadata_keys(object_type: str) -> list[tuple[str, str]]:
634+
"""Return the metadata keys expected by the methods of active applications.
635+
636+
Args:
637+
object_type: type of the objects carrying the metadata ("signal" or
638+
"image")
639+
640+
Returns:
641+
``(key, description)`` pairs, in declaration order, without duplicates
642+
"""
643+
keys: dict[str, str] = {}
644+
for record in _RECORDS.values():
645+
instance = record.instance
646+
if not record.enabled or instance is None or not record.classes:
647+
continue
648+
if instance.info is None or (
649+
PluginCapability.APPLICATION not in instance.info.capabilities
650+
):
651+
continue
652+
for recipe in record.classes[0].get_recipes():
653+
for slot in recipe.inputs:
654+
if slot.object_type.value != object_type:
655+
continue
656+
for requirement in slot.metadata:
657+
keys.setdefault(requirement.key, requirement.description)
658+
return list(keys.items())
659+
660+
633661
def slot_payload(slot: Any) -> dict[str, Any]:
634662
"""Return the JSON description of one recipe input slot."""
635663
return {
@@ -738,6 +766,7 @@ def _record_payload(record: PluginRecord) -> dict[str, Any]:
738766
"PLUGINS_ROOT",
739767
"PluginRecord",
740768
"add_change_listener",
769+
"declared_metadata_keys",
741770
"discover_plugins_in_dir",
742771
"get_plugin_class",
743772
"inspect_plugin_wheel",

‎src/styles.css‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3879,6 +3879,8 @@ input {
38793879
display: flex;
38803880
align-items: center;
38813881
gap: 6px;
3882+
/* Long choices (e.g. plugin metadata keys) must not widen the form. */
3883+
min-width: 0;
38823884
}
38833885

38843886
.dataset-form-control > input,

‎src/utils/recipeInputs.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ export function formatReadiness(
100100
const reasons = assessment.issues.map((issue) =>
101101
formatInputIssue(issue, slots),
102102
);
103+
if (assessment.issues.some((issue) => issue.code === "missing_metadata")) {
104+
reasons.push(t("To set it, use Edit > Metadata > Add metadata…"));
105+
}
103106
if (assessment.status !== "no_input") {
104107
reasons.push(...assessment.diagnostics.map(formatDiagnostic));
105108
}

‎tests/python/test_applications.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from __future__ import annotations
66

7+
import dataclasses
8+
79
import dlw_applications
810
import dlw_plugins
911
import numpy as np
@@ -136,6 +138,31 @@ def _add_input_signal(bootstrap) -> str:
136138
return bootstrap.add_signal_from_arrays("Input", [0.0, 1.0], [1.0, 3.0])
137139

138140

141+
def test_add_metadata_suggests_keys_declared_by_applications(
142+
applications_env, monkeypatch
143+
) -> None:
144+
bootstrap, record = applications_env
145+
key = "plugin.org.example.generic-application.gain"
146+
slot = RecipeInputSlot(
147+
"source",
148+
RecipeObjectType.SIGNAL,
149+
RecipeCardinality.ONE,
150+
metadata=(RecipeMetadataRequirement(key, "Detector gain"),),
151+
)
152+
recipe = dataclasses.replace(GENERIC_RECIPE, inputs=(slot,))
153+
monkeypatch.setattr(GenericApplicationPlugin, "RECIPES", (recipe,))
154+
signal = bootstrap._MODEL.get(_add_input_signal(bootstrap))
155+
signal.metadata["operator"] = "Ada"
156+
157+
assert bootstrap._metadata_key_suggestions([signal]) == [
158+
("operator", "e.g. 'Ada'"),
159+
(key, "Detector gain"),
160+
]
161+
assert dlw_plugins.declared_metadata_keys("image") == []
162+
record.enabled = False
163+
assert dlw_plugins.declared_metadata_keys("signal") == []
164+
165+
139166
def test_prepare_recipe_returns_schema_and_unambiguous_binding(
140167
applications_env,
141168
) -> None:

‎tests/ts/components/ApplicationsDialog.test.tsx‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
act,
23
fireEvent,
34
render,
45
screen,
@@ -30,8 +31,11 @@ const runtimeMock = vi.hoisted(() => ({
3031
resolvePluginRecipeChoices: vi.fn(),
3132
resolvePluginRecipeCallbacks: vi.fn(),
3233
resolvePluginRecipeActive: vi.fn(),
34+
onWorkspaceMutation: vi.fn(),
3335
}));
3436

37+
const mutationListeners: ((name: string) => void)[] = [];
38+
3539
vi.mock("../../../src/runtime/RuntimeContext", () => ({
3640
useRuntime: () => ({ runtime: runtimeMock as unknown as RuntimeApi }),
3741
}));
@@ -224,6 +228,15 @@ function renderDialog(
224228

225229
beforeEach(() => {
226230
vi.clearAllMocks();
231+
mutationListeners.length = 0;
232+
runtimeMock.onWorkspaceMutation.mockImplementation(
233+
(listener: (name: string) => void) => {
234+
mutationListeners.push(listener);
235+
return () => {
236+
mutationListeners.splice(mutationListeners.indexOf(listener), 1);
237+
};
238+
},
239+
);
227240
runtimeMock.listPlugins.mockResolvedValue([APPLICATION]);
228241
runtimeMock.assessPluginRecipes.mockResolvedValue({ [RECIPE_ID]: READY });
229242
runtimeMock.preparePluginRecipe.mockResolvedValue(PREPARATION);
@@ -321,6 +334,39 @@ describe("ApplicationsDialog", () => {
321334
);
322335
});
323336

337+
it("re-assesses the methods when workspace objects change", async () => {
338+
renderDialog();
339+
await screen.findByText("Ready to run on the current selection");
340+
const assessments = runtimeMock.assessPluginRecipes.mock.calls.length;
341+
342+
runtimeMock.assessPluginRecipes.mockResolvedValueOnce({
343+
[RECIPE_ID]: {
344+
status: "not_ready",
345+
bindings: { frames: ["image-1", "image-2"] },
346+
issues: [
347+
{
348+
code: "missing_metadata",
349+
slot_id: "frames",
350+
details: { key: "exposure_time", count: 2, titles: ["A", "B"] },
351+
},
352+
],
353+
diagnostics: [],
354+
},
355+
});
356+
act(() => {
357+
for (const listener of [...mutationListeners]) {
358+
listener("delete_object_metadata");
359+
}
360+
});
361+
362+
expect(
363+
await screen.findByText("To set it, use Edit > Metadata > Add metadata…"),
364+
).toBeInTheDocument();
365+
expect(runtimeMock.assessPluginRecipes).toHaveBeenCalledTimes(
366+
assessments + 1,
367+
);
368+
});
369+
324370
it("runs a ready method directly on the current selection", async () => {
325371
const onCommitted = vi.fn();
326372
renderDialog({ onCommitted });

0 commit comments

Comments
 (0)