Polish data workspace density
This commit is contained in:
@@ -7,6 +7,14 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## Sprint 84 Data workspace density polish (2026-06-20)
|
||||
|
||||
- Added selected-summary regions to Project, AOI and Dataset panels so active context is visible before forms.
|
||||
- Split Data workspace panels into named form/list/catalog blocks to reduce form-first scanning friction.
|
||||
- Restyled dataset upload as an embedded source-data block while preserving the existing upload flow.
|
||||
- Added static regression coverage for Data workspace selected-summary, upload and catalog density contracts.
|
||||
- No API contracts, migrations, backend behavior, provider fetching or AI model behavior changed.
|
||||
|
||||
## Sprint 83 Workspace panel hierarchy polish (2026-06-20)
|
||||
|
||||
- Made the Overview readiness strip a calmer section surface with tighter status tiles.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_project_and_area_panels_expose_selected_summary_regions() -> None:
|
||||
project_panel = (ROOT / "frontend" / "src" / "components" / "project" / "ProjectPanel.tsx").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
area_panel = (ROOT / "frontend" / "src" / "components" / "project" / "AreaPanel.tsx").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert "const selectedProject = projects.find" in project_panel
|
||||
assert 'className="data-selection-summary data-selection-summary-project"' in project_panel
|
||||
assert 'className="data-panel-form-block"' in project_panel
|
||||
assert 'className="data-panel-list-block"' in project_panel
|
||||
assert "const selectedArea = areas.find" in area_panel
|
||||
assert 'className="data-selection-summary data-selection-summary-area"' in area_panel
|
||||
assert 'className="data-panel-form-block"' in area_panel
|
||||
assert 'className="data-panel-list-block"' in area_panel
|
||||
|
||||
|
||||
def test_dataset_panel_separates_selected_upload_and_catalog_regions() -> None:
|
||||
dataset_panel = (ROOT / "frontend" / "src" / "components" / "datasets" / "DatasetPanel.tsx").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert "const selectedDataset = datasets.find" in dataset_panel
|
||||
assert 'className="data-selection-summary data-selection-summary-dataset"' in dataset_panel
|
||||
assert 'className="dataset-upload-block data-panel-form-block"' in dataset_panel
|
||||
assert 'className="data-panel-list-block dataset-catalog-block"' in dataset_panel
|
||||
assert 'className="data-section-label"' in dataset_panel
|
||||
|
||||
|
||||
def test_data_workspace_density_css_contracts() -> None:
|
||||
css = (ROOT / "frontend" / "src" / "styles" / "app.css").read_text(encoding="utf-8")
|
||||
|
||||
assert ".data-selection-summary" in css
|
||||
assert ".data-selection-summary strong" in css
|
||||
assert ".data-panel-form-block" in css
|
||||
assert ".data-panel-list-block" in css
|
||||
assert ".data-section-label" in css
|
||||
assert "grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));" in css
|
||||
assert "background: linear-gradient(180deg, #ffffff, #f7fbf8);" in css
|
||||
assert "border-left: 4px solid var(--accent);" in css
|
||||
@@ -3044,3 +3044,30 @@ Limitations:
|
||||
|
||||
Next recommended pass:
|
||||
- Continue with Data workspace panel hierarchy and selected-item density after this pass is deployed and visually checked.
|
||||
|
||||
## Sprint 84 Data workspace density polish (2026-06-20)
|
||||
|
||||
Changed:
|
||||
- Added selected-summary regions to Project, AOI and Dataset panels so the active data context appears before creation/upload forms.
|
||||
- Split Project and AOI panels into `data-panel-form-block` and `data-panel-list-block` regions.
|
||||
- Split Dataset catalog into selected dataset, upload source data and available dataset regions.
|
||||
- Restyled dataset upload as an embedded source-data block while preserving the existing upload flow.
|
||||
- Added `backend/tests/test_sprint84_data_workspace_density.py`.
|
||||
- Updated `frontend/README.md`, `docs/TODO.md` and `CHANGELOG.md`.
|
||||
|
||||
Tested:
|
||||
- Live browser pre-check against `http://192.168.10.150:1202` showed Data workspace had no console errors or horizontal overflow, but the first Data viewport was still form-first and visually heavy.
|
||||
- Red step: `python -m pytest backend/tests/test_sprint84_data_workspace_density.py -q` failed on missing selected-summary and named form/list/catalog regions.
|
||||
- `python -m pytest backend/tests/test_sprint84_data_workspace_density.py -q` (`3 passed`)
|
||||
- `python -m pytest backend/tests/test_sprint84_data_workspace_density.py backend/tests/test_sprint83_workspace_panel_hierarchy.py backend/tests/test_sprint50_workspace_usability_polish.py backend/tests/test_sprint29_dataset_components.py backend/tests/test_sprint68_dataset_catalog_density.py backend/tests/test_sprint69_dataset_action_polish.py backend/tests/test_sprint47_workbench_interaction_smoke.py -q` (`20 passed`)
|
||||
- `cd frontend && npm run typecheck`
|
||||
- `cd frontend && npm run build`
|
||||
|
||||
Open:
|
||||
- Full readiness, deployment and live browser verification still need to run for this pass.
|
||||
|
||||
Limitations:
|
||||
- Frontend Data workspace presentation hierarchy only; no upload behavior, API contract, persistence, migration, provider fetching or AI/model changes.
|
||||
|
||||
Next recommended pass:
|
||||
- Continue with Map workspace panel hierarchy and layer-control density after this pass is deployed and visually checked.
|
||||
|
||||
@@ -73,6 +73,7 @@ This file now starts with the current implementation status. Older preparation/b
|
||||
- [x] Add compact loading/error/empty/result state polish across QA, exports and AI labs.
|
||||
- [x] Add compact shell density polish for topbar context, mobile navigation and workspace skip flow.
|
||||
- [x] Add Overview workspace panel hierarchy polish for readiness and recommended-action regions.
|
||||
- [x] Add Data workspace selected-summary and panel density polish.
|
||||
|
||||
## Sprint 8 status
|
||||
|
||||
|
||||
@@ -238,6 +238,7 @@ QA/QC, exports and AI lab result panels use shared loading, error, empty and rea
|
||||
- The workbench shell includes a compact command bar, consistent raised/sunken surfaces, structured empty states and scroll-safe AI result tables to keep the V1 workflow usable across desktop and mobile widths.
|
||||
- The shell includes a keyboard skip link, an explicit primary workspace navigation label and compact horizontal context/navigation rails on narrow screens so active workspace content is reachable without a tall preamble.
|
||||
- The Overview workspace separates readiness, status tiles and recommended next actions into distinct visual hierarchy regions, keeping the first screen scannable without changing workflow behavior.
|
||||
- The Data workspace surfaces selected project, AOI and dataset context before creation/upload forms, then separates form and catalog/list regions for faster scanning.
|
||||
- The Export Center includes a handoff readiness summary, grouped artifact actions and provenance-rich export cards so report/GeoJSON handoff stays understandable in long-running demo projects.
|
||||
|
||||
## Workbench shell refactor
|
||||
|
||||
@@ -105,6 +105,7 @@ export function DatasetPanel({
|
||||
onOpenDatasetExport,
|
||||
}: DatasetPanelProps) {
|
||||
const readyDatasets = datasets.filter((dataset) => dataset.status === 'ready').length
|
||||
const selectedDataset = datasets.find((dataset) => dataset.id === selectedDatasetId)
|
||||
const roleSummaries = [
|
||||
{
|
||||
key: 'selected',
|
||||
@@ -146,52 +147,61 @@ export function DatasetPanel({
|
||||
<span className="count-pill">{readyDatasets}/{datasets.length} ready</span>
|
||||
</div>
|
||||
|
||||
<form className="dataset-upload-form" onSubmit={onUploadDataset}>
|
||||
<label>
|
||||
Type
|
||||
<select
|
||||
value={datasetForm.datasetType}
|
||||
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, datasetType: event.target.value }))}
|
||||
>
|
||||
<option value="vector">vector</option>
|
||||
<option value="geojson">geojson</option>
|
||||
<option value="raster">raster</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Source
|
||||
<input
|
||||
value={datasetForm.source}
|
||||
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, source: event.target.value }))}
|
||||
placeholder="user_upload"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
AOI
|
||||
<select
|
||||
value={datasetForm.areaId}
|
||||
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, areaId: event.target.value }))}
|
||||
>
|
||||
<option value="">No area</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
{area.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="file-input-label">
|
||||
File
|
||||
<input
|
||||
type="file"
|
||||
accept=".geojson,.json,.tif,.tiff,.geotiff"
|
||||
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, file: event.target.files?.[0] ?? null }))}
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-action" type="submit" disabled={!selectedProjectId}>
|
||||
Upload dataset
|
||||
</button>
|
||||
</form>
|
||||
<div className="data-selection-summary data-selection-summary-dataset">
|
||||
<span>Selected dataset</span>
|
||||
<strong>{selectedDataset?.name ?? 'No dataset selected'}</strong>
|
||||
<small>{selectedDataset ? `${datasetRoleLabel(normalizeDatasetRole(selectedDataset))} / ${selectedDataset.status}` : 'Inspect a dataset to load details and tools.'}</small>
|
||||
</div>
|
||||
|
||||
<div className="dataset-upload-block data-panel-form-block">
|
||||
<p className="data-section-label">Upload source data</p>
|
||||
<form className="dataset-upload-form" onSubmit={onUploadDataset}>
|
||||
<label>
|
||||
Type
|
||||
<select
|
||||
value={datasetForm.datasetType}
|
||||
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, datasetType: event.target.value }))}
|
||||
>
|
||||
<option value="vector">vector</option>
|
||||
<option value="geojson">geojson</option>
|
||||
<option value="raster">raster</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Source
|
||||
<input
|
||||
value={datasetForm.source}
|
||||
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, source: event.target.value }))}
|
||||
placeholder="user_upload"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
AOI
|
||||
<select
|
||||
value={datasetForm.areaId}
|
||||
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, areaId: event.target.value }))}
|
||||
>
|
||||
<option value="">No area</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
{area.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="file-input-label">
|
||||
File
|
||||
<input
|
||||
type="file"
|
||||
accept=".geojson,.json,.tif,.tiff,.geotiff"
|
||||
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, file: event.target.files?.[0] ?? null }))}
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-action" type="submit" disabled={!selectedProjectId}>
|
||||
Upload dataset
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="dataset-role-summary-grid" aria-label="Dataset role summary">
|
||||
{roleSummaries.map((summary) => (
|
||||
@@ -210,78 +220,81 @@ export function DatasetPanel({
|
||||
<p>Upload vector, GeoJSON or raster data after selecting a project.</p>
|
||||
</div>
|
||||
) : null}
|
||||
<ul className="dataset-list">
|
||||
{datasets.map((dataset) => {
|
||||
const datasetRole = normalizeDatasetRole(dataset)
|
||||
const roleLabel = datasetRoleLabel(datasetRole)
|
||||
return (
|
||||
<li className={dataset.id === selectedDatasetId ? 'dataset-card dataset-card-active' : 'dataset-card'} key={dataset.id}>
|
||||
<div className="dataset-card-header">
|
||||
<div>
|
||||
<div className="dataset-card-kicker">
|
||||
<span className={`dataset-role-badge dataset-role-${datasetRole}`}>{roleLabel}</span>
|
||||
<span>{dataset.dataset_type}</span>
|
||||
</div>
|
||||
<strong className="dataset-card-title">{dataset.name}</strong>
|
||||
<div className="entity-meta">
|
||||
<span>Source: {dataset.source_name ?? dataset.source}</span>
|
||||
{dataset.reference_layer_name ? <span>Layer: {dataset.reference_layer_name}</span> : null}
|
||||
<span>CRS: {dataset.crs ?? dataset.vector_summary?.crs ?? 'n/a'}</span>
|
||||
<div className="data-panel-list-block dataset-catalog-block">
|
||||
<p className="data-section-label">Available datasets</p>
|
||||
<ul className="dataset-list">
|
||||
{datasets.map((dataset) => {
|
||||
const datasetRole = normalizeDatasetRole(dataset)
|
||||
const roleLabel = datasetRoleLabel(datasetRole)
|
||||
return (
|
||||
<li className={dataset.id === selectedDatasetId ? 'dataset-card dataset-card-active' : 'dataset-card'} key={dataset.id}>
|
||||
<div className="dataset-card-header">
|
||||
<div>
|
||||
<div className="dataset-card-kicker">
|
||||
<span className={`dataset-role-badge dataset-role-${datasetRole}`}>{roleLabel}</span>
|
||||
<span>{dataset.dataset_type}</span>
|
||||
</div>
|
||||
<strong className="dataset-card-title">{dataset.name}</strong>
|
||||
<div className="entity-meta">
|
||||
<span>Source: {dataset.source_name ?? dataset.source}</span>
|
||||
{dataset.reference_layer_name ? <span>Layer: {dataset.reference_layer_name}</span> : null}
|
||||
<span>CRS: {dataset.crs ?? dataset.vector_summary?.crs ?? 'n/a'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={dataset.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>
|
||||
{dataset.status === 'ready' ? 'ready' : dataset.status === 'failed' ? 'failed' : 'pending'}
|
||||
</span>
|
||||
</div>
|
||||
<span className={dataset.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>
|
||||
{dataset.status === 'ready' ? 'ready' : dataset.status === 'failed' ? 'failed' : 'pending'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="dataset-metrics data-density-grid">
|
||||
<span>size: {formatBytes(dataset.size_bytes)}</span>
|
||||
<span>features: {dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 'n/a'}</span>
|
||||
<span>bbox: {formatBounds(dataset.bounds_json ?? dataset.vector_summary?.bounds_json)}</span>
|
||||
</div>
|
||||
<p className="dataset-action-hint">{datasetActionHint(dataset, datasetRole)}</p>
|
||||
<div className="dataset-action-grid">
|
||||
<button
|
||||
className="primary-action dataset-action-button"
|
||||
type="button"
|
||||
onClick={() => onLoadDatasetDetails(selectedProjectId ?? '', dataset)}
|
||||
data-testid={`dataset-select-${dataset.id}`}
|
||||
>
|
||||
<span>Inspect</span>
|
||||
<small>Select / details</small>
|
||||
</button>
|
||||
<button
|
||||
className="secondary-action dataset-action-button"
|
||||
type="button"
|
||||
onClick={() => onOpenDatasetInMap(dataset)}
|
||||
aria-label="Open in map"
|
||||
>
|
||||
<span>Map</span>
|
||||
<small>Open layer</small>
|
||||
</button>
|
||||
<button
|
||||
className="secondary-action dataset-action-button"
|
||||
type="button"
|
||||
onClick={() => onOpenDatasetExport(dataset)}
|
||||
disabled={!(dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson')}
|
||||
>
|
||||
<span>Export / QA</span>
|
||||
<small>{dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson' ? 'Compare or export' : 'Vector/GeoJSON only'}</small>
|
||||
</button>
|
||||
<button
|
||||
className="secondary-action dataset-action-button"
|
||||
type="button"
|
||||
onClick={() => onRefreshMetadata(dataset.id)}
|
||||
disabled={dataset.dataset_type === 'raster'}
|
||||
aria-label="Refresh metadata"
|
||||
>
|
||||
<span>Metadata</span>
|
||||
<small>{dataset.dataset_type === 'raster' ? 'Raster metadata is automatic' : 'Refresh vector stats'}</small>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<div className="dataset-metrics data-density-grid">
|
||||
<span>size: {formatBytes(dataset.size_bytes)}</span>
|
||||
<span>features: {dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 'n/a'}</span>
|
||||
<span>bbox: {formatBounds(dataset.bounds_json ?? dataset.vector_summary?.bounds_json)}</span>
|
||||
</div>
|
||||
<p className="dataset-action-hint">{datasetActionHint(dataset, datasetRole)}</p>
|
||||
<div className="dataset-action-grid">
|
||||
<button
|
||||
className="primary-action dataset-action-button"
|
||||
type="button"
|
||||
onClick={() => onLoadDatasetDetails(selectedProjectId ?? '', dataset)}
|
||||
data-testid={`dataset-select-${dataset.id}`}
|
||||
>
|
||||
<span>Inspect</span>
|
||||
<small>Select / details</small>
|
||||
</button>
|
||||
<button
|
||||
className="secondary-action dataset-action-button"
|
||||
type="button"
|
||||
onClick={() => onOpenDatasetInMap(dataset)}
|
||||
aria-label="Open in map"
|
||||
>
|
||||
<span>Map</span>
|
||||
<small>Open layer</small>
|
||||
</button>
|
||||
<button
|
||||
className="secondary-action dataset-action-button"
|
||||
type="button"
|
||||
onClick={() => onOpenDatasetExport(dataset)}
|
||||
disabled={!(dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson')}
|
||||
>
|
||||
<span>Export / QA</span>
|
||||
<small>{dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson' ? 'Compare or export' : 'Vector/GeoJSON only'}</small>
|
||||
</button>
|
||||
<button
|
||||
className="secondary-action dataset-action-button"
|
||||
type="button"
|
||||
onClick={() => onRefreshMetadata(dataset.id)}
|
||||
disabled={dataset.dataset_type === 'raster'}
|
||||
aria-label="Refresh metadata"
|
||||
>
|
||||
<span>Metadata</span>
|
||||
<small>{dataset.dataset_type === 'raster' ? 'Raster metadata is automatic' : 'Refresh vector stats'}</small>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ export function AreaPanel({
|
||||
onUpdateAreaForm,
|
||||
onSelectMapArea,
|
||||
}: AreaPanelProps): JSX.Element {
|
||||
const selectedArea = areas.find((area) => area.id === selectedMapAreaId)
|
||||
|
||||
return (
|
||||
<section data-testid="area-panel">
|
||||
<div className="panel-title-row">
|
||||
@@ -41,58 +43,70 @@ export function AreaPanel({
|
||||
</div>
|
||||
<p className="muted">{selectedProject ? `Project: ${selectedProject.name}` : 'Select a project first'}</p>
|
||||
|
||||
<form className="compact-form" onSubmit={onCreateArea}>
|
||||
<label>
|
||||
AOI name
|
||||
<input
|
||||
value={areaForm.name}
|
||||
onChange={(event) => onUpdateAreaForm({ ...areaForm, name: event.target.value })}
|
||||
placeholder="AOI name"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
CRS
|
||||
<input
|
||||
value={areaForm.crs}
|
||||
onChange={(event) => onUpdateAreaForm({ ...areaForm, crs: event.target.value })}
|
||||
placeholder="EPSG:4326"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
GeoJSON geometry
|
||||
<textarea
|
||||
value={areaForm.geometry}
|
||||
onChange={(event) => onUpdateAreaForm({ ...areaForm, geometry: event.target.value })}
|
||||
rows={4}
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-action" type="submit" disabled={!selectedProjectId}>
|
||||
Create area
|
||||
</button>
|
||||
</form>
|
||||
<div className="data-selection-summary data-selection-summary-area">
|
||||
<span>Shown on map</span>
|
||||
<strong>{selectedArea?.name ?? 'No AOI selected'}</strong>
|
||||
<small>{selectedArea?.area_m2 ? `${selectedArea.area_m2.toFixed(2)} m2` : 'Select an AOI with geometry for spatial review.'}</small>
|
||||
</div>
|
||||
|
||||
<div className="data-panel-form-block">
|
||||
<p className="data-section-label">Create spatial scope</p>
|
||||
<form className="compact-form" onSubmit={onCreateArea}>
|
||||
<label>
|
||||
AOI name
|
||||
<input
|
||||
value={areaForm.name}
|
||||
onChange={(event) => onUpdateAreaForm({ ...areaForm, name: event.target.value })}
|
||||
placeholder="AOI name"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
CRS
|
||||
<input
|
||||
value={areaForm.crs}
|
||||
onChange={(event) => onUpdateAreaForm({ ...areaForm, crs: event.target.value })}
|
||||
placeholder="EPSG:4326"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
GeoJSON geometry
|
||||
<textarea
|
||||
value={areaForm.geometry}
|
||||
onChange={(event) => onUpdateAreaForm({ ...areaForm, geometry: event.target.value })}
|
||||
rows={4}
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-action" type="submit" disabled={!selectedProjectId}>
|
||||
Create area
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{loadingAreas ? <p>Loading areas...</p> : null}
|
||||
{areas.length === 0 ? <p>No areas yet</p> : null}
|
||||
<ul className="entity-list">
|
||||
{areas.map((area) => (
|
||||
<li className={selectedMapAreaId === area.id ? 'entity-card entity-card-active' : 'entity-card'} key={area.id}>
|
||||
<strong>{area.name}</strong>
|
||||
<div className="entity-meta">
|
||||
<span>{area.area_m2 ? `${area.area_m2.toFixed(2)} m2` : 'n/a'}</span>
|
||||
<span>{area.geometry?.type ?? area.geometry_type ?? 'n/a'}</span>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-action"
|
||||
type="button"
|
||||
onClick={() => onSelectMapArea(area.id)}
|
||||
disabled={!area.geometry}
|
||||
data-testid={`area-show-${area.id}`}
|
||||
>
|
||||
{selectedMapAreaId === area.id ? 'Shown on map' : 'Show on map'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="data-panel-list-block">
|
||||
<p className="data-section-label">Saved AOIs</p>
|
||||
<ul className="entity-list">
|
||||
{areas.map((area) => (
|
||||
<li className={selectedMapAreaId === area.id ? 'entity-card entity-card-active' : 'entity-card'} key={area.id}>
|
||||
<strong>{area.name}</strong>
|
||||
<div className="entity-meta">
|
||||
<span>{area.area_m2 ? `${area.area_m2.toFixed(2)} m2` : 'n/a'}</span>
|
||||
<span>{area.geometry?.type ?? area.geometry_type ?? 'n/a'}</span>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-action"
|
||||
type="button"
|
||||
onClick={() => onSelectMapArea(area.id)}
|
||||
disabled={!area.geometry}
|
||||
data-testid={`area-show-${area.id}`}
|
||||
>
|
||||
{selectedMapAreaId === area.id ? 'Shown on map' : 'Show on map'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export function ProjectPanel({
|
||||
onSelectProject,
|
||||
onLoadDemoWorkflow,
|
||||
}: ProjectPanelProps): JSX.Element {
|
||||
const selectedProject = projects.find((project) => project.id === selectedProjectId)
|
||||
|
||||
return (
|
||||
<section className="workspace-panel" data-testid="project-panel">
|
||||
<div className="panel-title-row">
|
||||
@@ -37,35 +39,44 @@ export function ProjectPanel({
|
||||
</div>
|
||||
{loadingProjects ? <p>Loading projects...</p> : null}
|
||||
|
||||
<form className="compact-form" onSubmit={onCreateProject}>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={projectForm.name}
|
||||
onChange={(event) => onUpdateProjectForm({ ...projectForm, name: event.target.value })}
|
||||
placeholder="Project name"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Description
|
||||
<input
|
||||
value={projectForm.description ?? ''}
|
||||
onChange={(event) => onUpdateProjectForm({ ...projectForm, description: event.target.value })}
|
||||
placeholder="Description"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Region
|
||||
<input
|
||||
value={projectForm.region ?? 'Kempen'}
|
||||
onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })}
|
||||
placeholder="Region"
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-action" type="submit">
|
||||
Create project
|
||||
</button>
|
||||
</form>
|
||||
<div className="data-selection-summary data-selection-summary-project">
|
||||
<span>Selected project</span>
|
||||
<strong>{selectedProject?.name ?? 'No active project'}</strong>
|
||||
<small>{selectedProject?.region ?? 'Create or load a project to start the workbench.'}</small>
|
||||
</div>
|
||||
|
||||
<div className="data-panel-form-block">
|
||||
<p className="data-section-label">Create context</p>
|
||||
<form className="compact-form" onSubmit={onCreateProject}>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={projectForm.name}
|
||||
onChange={(event) => onUpdateProjectForm({ ...projectForm, name: event.target.value })}
|
||||
placeholder="Project name"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Description
|
||||
<input
|
||||
value={projectForm.description ?? ''}
|
||||
onChange={(event) => onUpdateProjectForm({ ...projectForm, description: event.target.value })}
|
||||
placeholder="Description"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Region
|
||||
<input
|
||||
value={projectForm.region ?? 'Kempen'}
|
||||
onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })}
|
||||
placeholder="Region"
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-action" type="submit">
|
||||
Create project
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="demo-actions">
|
||||
<button className="secondary-action" type="button" onClick={onLoadDemoWorkflow} disabled={loadingDemoWorkflow} data-testid="load-demo-workflow">
|
||||
@@ -74,28 +85,31 @@ export function ProjectPanel({
|
||||
{demoWorkflowMessage ? <p>{demoWorkflowMessage}</p> : null}
|
||||
</div>
|
||||
|
||||
<ul className="entity-list">
|
||||
{projects.map((project) => (
|
||||
<li key={project.id}>
|
||||
<button
|
||||
className={project.id === selectedProjectId ? 'entity-card entity-card-active' : 'entity-card'}
|
||||
type="button"
|
||||
onClick={() => onSelectProject(project.id)}
|
||||
aria-pressed={project.id === selectedProjectId}
|
||||
data-testid={`project-select-${project.id}`}
|
||||
>
|
||||
<strong>{project.name}</strong>
|
||||
<span>{project.region || 'No region'}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{projects.length === 0 ? (
|
||||
<li className="empty-state">
|
||||
<strong>No projects yet</strong>
|
||||
<p>Create a project or load the offline demo workflow to populate the workbench.</p>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
<div className="data-panel-list-block">
|
||||
<p className="data-section-label">Saved projects</p>
|
||||
<ul className="entity-list">
|
||||
{projects.map((project) => (
|
||||
<li key={project.id}>
|
||||
<button
|
||||
className={project.id === selectedProjectId ? 'entity-card entity-card-active' : 'entity-card'}
|
||||
type="button"
|
||||
onClick={() => onSelectProject(project.id)}
|
||||
aria-pressed={project.id === selectedProjectId}
|
||||
data-testid={`project-select-${project.id}`}
|
||||
>
|
||||
<strong>{project.name}</strong>
|
||||
<span>{project.region || 'No region'}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{projects.length === 0 ? (
|
||||
<li className="empty-state">
|
||||
<strong>No projects yet</strong>
|
||||
<p>Create a project or load the offline demo workflow to populate the workbench.</p>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1019,15 +1019,68 @@ section th {
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.dataset-upload-form {
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
align-items: end;
|
||||
.data-selection-summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.16rem;
|
||||
border: 1px solid rgba(15, 118, 110, 0.26);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 0.68rem 0.75rem;
|
||||
background: linear-gradient(180deg, #ffffff, #f7fbf8);
|
||||
box-shadow: 0 1px 0 rgba(19, 32, 24, 0.03);
|
||||
}
|
||||
|
||||
.data-selection-summary span,
|
||||
.data-selection-summary small,
|
||||
.data-section-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.2;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.data-selection-summary strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.24;
|
||||
}
|
||||
|
||||
.data-selection-summary small {
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.data-panel-form-block,
|
||||
.data-panel-list-block {
|
||||
display: grid;
|
||||
gap: 0.56rem;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.85rem;
|
||||
padding: 0.72rem;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.data-panel-list-block {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.data-section-label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dataset-upload-form {
|
||||
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||
align-items: end;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dataset-upload-form label,
|
||||
.file-input-label {
|
||||
min-width: 0;
|
||||
|
||||
Reference in New Issue
Block a user