Skip to content
Open
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
16 changes: 14 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,17 @@ Project Lead
([export/tdei.vue](pages/workspace/[id]/export/tdei.vue)). A Lead without one
of those TDEI roles cannot export; a non-lead with `poc` can.
* Validate Changeset — ✅ enforced (`isValidator`, [review/Toolbar.vue](components/review/Toolbar.vue))
* Merge Changes From Another Workspace: ⚠️ enforced in the UI only (`isLead`,
on the menu entry in [Toolbar.vue](components/dashboard/Toolbar.vue) and on
both [merge/index.vue](pages/workspace/[id]/merge/index.vue) and
[merge/conflicts.vue](pages/workspace/[id]/merge/conflicts.vue)). **No
server-side merge endpoint exists**, so the merge runs entirely in the browser
and nothing checks the `lead` role on the server. The one backend control on
the path is `POST /api/v1/workspaces`, which rejects a project group the
caller does not belong to. Merge targets are restricted to workspaces built from the same TDEI dataset
**and** in the same project group, and the destination picker is constrained
to the workspace's own project group, so a merge cannot move data across a
tenant boundary.
* Move Workspace from Project Group to Project Group — ❌ **not implemented.**
No `move`/`transfer`/`changeProjectGroup` code exists anywhere in the frontend.
* Edit POSM Element — ✅ available, but **not role-gated** (see note below)
Expand All @@ -190,8 +201,9 @@ would have to be enforced by the backend / changeset-validation flow.

## Test status

The e2e suite currently passes aside from **6 intentionally-skipped tests**: 6
`test.fixme` dashboard flows blocked by maplibre/external-editor rendering. Some
The e2e suite currently passes aside from **7 intentionally-skipped tests**: 6
`test.fixme` dashboard flows blocked by maplibre/external-editor rendering, and
the merge conflicts map-and-tag-table flow, which needs WebGL. Some
heavy authed pages (e.g. `settings/members`) can flake under high parallelism
(the lazy-compile issue above) — run serially (`--workers=1`) for deterministic
results.
11 changes: 11 additions & 0 deletions components/dashboard/Toolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@
<app-icon variant="drive_folder_upload" />
Export
</b-dropdown-item>
<b-dropdown-item
:to="mergeRoute"
:disabled="!isLead"
:title="isLead ? undefined : 'Only workspace owners can merge workspaces'"
>
<app-icon variant="merge" />
Merge
</b-dropdown-item>
<b-dropdown-divider />
<b-dropdown-item :to="settingsRoute">
<app-icon variant="settings" />
Settings
Expand Down Expand Up @@ -178,6 +187,7 @@ const editHash = computed(() => {
});

const isOsw = computed(() => props.workspace.type === 'osw');
const isLead = computed(() => props.workspace.role === 'lead');
const editorLabel = computed(() => {
switch (props.workspace.type) {
case 'osw':
Expand All @@ -202,6 +212,7 @@ const editRouteRapid3 = computed(() => ({
const reviewRoute = computed(() => workspacePath('review'));
const projectsRoute = computed(() => workspacePath('projects'));
const exportRoute = computed(() => workspacePath('export'));
const mergeRoute = computed(() => workspacePath('merge'));
const settingsRoute = computed(() => workspacePath('settings'));

function workspacePath(page: string): string {
Expand Down
177 changes: 177 additions & 0 deletions components/merge/ConflictDetail.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<template>
<section class="conflicts-main">
<div
v-if="conflict && resolution"
class="d-flex flex-column h-100"
>
<div class="conflict-map-container">
<merge-conflict-map
v-if="lookup"
ref="conflictMapRef"
:conflict="conflict"
:lookup="lookup"
:fallback="ancestor"
/>
</div>

<b-card
class="conflict-detail"
no-body
>
<div class="detail-toolbar d-flex align-items-center gap-2 p-2 border-bottom bg-light">
<span class="fw-bold small">
{{ conflict.type }} {{ conflict.elementId }}
</span>
<span
v-if="conflict.kind !== 'both-modified'"
class="small text-body-secondary"
>
{{ deletionNotice(conflict) }}
</span>
<div class="ms-auto d-flex gap-2">
<b-button
v-if="conflict.kind !== 'deleted-in-a'"
variant="outline-danger"
size="sm"
@click="emit('keep-side', 'a')"
>
Keep A
</b-button>
<b-button
v-if="conflict.kind !== 'deleted-in-b'"
variant="outline-primary"
size="sm"
@click="emit('keep-side', 'b')"
>
Keep B
</b-button>
<b-button
v-if="conflict.kind !== 'both-modified'"
variant="outline-dark"
size="sm"
@click="emit('drop-element')"
>
Delete Element
</b-button>
<b-button
variant="outline-secondary"
size="sm"
title="Opens workspace A in the editor for reference. The merge is not saved, so leaving discards your resolutions."
:disabled="committing"
@click="emit('inspect')"
>
<app-icon
variant="edit_location_alt"
size="16"
/>
Inspect in Editor
</b-button>
</div>
</div>

<div
v-if="resolution.chosenSide === 'delete'"
class="p-3 text-body-secondary"
>
This element will be left out of the merged workspace.
</div>
<merge-conflict-tag-table
v-else
:conflict="conflict"
:resolution="resolution"
@choose-side="emit('choose-side', $event)"
@set-tag="(key, value) => emit('set-tag', key, value)"
/>
</b-card>
</div>

<div
v-else
class="d-flex align-items-center justify-content-center h-100 text-muted"
>
<div class="text-center">
<app-icon
variant="touch_app"
size="48"
class="d-block mb-2 opacity-50"
/>
Select a conflict from the list to begin resolving.
</div>
</div>
</section>
</template>

<script setup lang="ts">
import type ConflictMap from '~/components/merge/ConflictMap.vue';
import type { ResolutionState } from '~/composables/useConflictResolutions';
import type { MergeConflict } from '~/services/merge';
import type { OsmElementLookup } from '~/util/osm';

interface Props {
conflict?: MergeConflict;
resolution?: ResolutionState;

/** The merged elements. The map is not shown without them. */
lookup?: OsmElementLookup;

/** The common ancestor, which the map falls back to. */
ancestor?: OsmElementLookup;
committing: boolean;
}

defineProps<Props>();

const emit = defineEmits<{
'keep-side': [side: 'a' | 'b'];
'drop-element': [];
'choose-side': [side: 'a' | 'b'];
'set-tag': [key: string, value: string];
'inspect': [];
}>();

const conflictMapRef = useTemplateRef<InstanceType<typeof ConflictMap>>('conflictMapRef');

/** The map's current view, or undefined when the map is not shown. */
function getLatLonZoom() {
return conflictMapRef.value?.getLatLonZoom();
}

defineExpose({ getLatLonZoom });

function deletionNotice(conflict: MergeConflict): string {
return conflict.kind === 'deleted-in-a'
? 'Deleted in this workspace, edited in the other.'
: 'Edited in this workspace, deleted in the other.';
}
</script>

<style scoped lang="scss">
@import "assets/scss/theme.scss";

.conflicts-main {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;

.conflict-map-container {
flex: 1 1 0;
min-height: 0;
background-color: $gray-200;
}

.conflict-detail {
flex-shrink: 0;
max-height: 40%;
overflow-y: auto;
border-top: 1px solid $border-color;

.detail-toolbar {
position: sticky;
top: 0;
z-index: 1;
}
}
}
</style>
71 changes: 71 additions & 0 deletions components/merge/ConflictList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<template>
<b-list-group flush>
<b-list-group-item
v-for="(conflict, i) in conflicts"
:key="conflict.number"
:active="modelValue === i"
button
class="conflict-item"
@click="modelValue = i"
>
<div class="d-flex align-items-start gap-2">
<app-icon
:variant="resolved[i] ? 'check_circle' : 'error'"
:class="resolved[i] ? 'text-success' : 'text-danger'"
class="flex-shrink-0 mt-1"
/>
<div class="flex-grow-1 overflow-hidden">
<div class="fw-bold text-truncate">
{{ conflict.type }} {{ conflict.elementId }}
</div>
<div class="mt-1 d-flex gap-1 flex-wrap">
<b-badge
v-if="conflict.kind !== 'both-modified'"
variant="danger"
class="text-uppercase"
>
deleted
</b-badge>
<b-badge
v-if="conflict.geometryConflict"
variant="warning"
class="text-uppercase"
>
geometry
</b-badge>
<b-badge
v-if="conflict.tagConflicts.length"
variant="info"
class="text-uppercase"
>
tags
</b-badge>
</div>
</div>
</div>
</b-list-group-item>
</b-list-group>
</template>

<script setup lang="ts">
import type { MergeConflict } from '~/services/merge';

interface Props {
conflicts: MergeConflict[];
resolved: boolean[];
}

defineProps<Props>();

const modelValue = defineModel<number>({ required: true });
</script>

<style scoped lang="scss">
.conflict-item {
cursor: pointer;

:deep(.badge) {
font-size: 0.65em;
}
}
</style>
Loading
Loading