diff --git a/apps/qc-app/.env.example b/apps/qc-app/.env.example new file mode 100644 index 000000000..9774480cd --- /dev/null +++ b/apps/qc-app/.env.example @@ -0,0 +1,13 @@ +# Backend the dev server proxies /api to (e.g. a HydroServer instance). +VITE_APP_PROXY_BASE_URL= + +# Origin of the data-management app, which owns login/auth. In dev the two +# apps run on separate ports, so point this at data-management (e.g. +# http://127.0.0.1:1203). Leave unset in production, where the QC app is +# served by data-management under the same origin and /login resolves there. +VITE_APP_DATA_MANAGEMENT_URL= + +# Set to 1 to drop COOP/COEP headers when talking to a backend that doesn't +# serve Cross-Origin-Resource-Policy. Disables SharedArrayBuffer-backed +# workers (they fall back to inline execution). +VITE_APP_DISABLE_COOP= diff --git a/apps/qc-app/docs/API_REFERENCE.md b/apps/qc-app/docs/API_REFERENCE.md index c7a17541c..cefd5ae7a 100644 --- a/apps/qc-app/docs/API_REFERENCE.md +++ b/apps/qc-app/docs/API_REFERENCE.md @@ -112,6 +112,35 @@ await setSelected([0, 1, 2, 5]) // dispatches SELECTION await clearSelected({ recordHistory: false }) // skip history append on cleanup ``` +### `useResumeEditSession()` + +```ts +const { resume } = useResumeEditSession(enterEdit) +``` + +Reopens the editor after a page reload, using the persisted +`qcSession.resumeDatastreamId`: replots that datastream, makes it the QC +target, then calls the supplied `enterEdit`. The workspace catalog loads +asynchronously and is empty at mount, so it waits for the catalog to arrive +and resumes at most once. A pointer to a datastream missing from the catalog +(deleted, or another workspace) is dropped rather than retried. + +Note the watcher must not use Vue's `once` together with `immediate`: the +immediate call fires on the initial empty catalog and stops the watcher, so +the resume would never run on the cold reload it exists for. + +### `useUnsavedChangesWarning()` + +```ts +useUnsavedChangesWarning(hasUnsavedChanges) // Ref +``` + +Asks the browser for its native "leave site?" confirmation while the ref is +true, so a reload mid-session can't silently drop edits that never reached +the server. Registers on mount and removes the listener on unmount. Browsers +ignore any custom message and only honour the prompt once the user has +interacted with the page. + ### `useQcHistory()` ```ts @@ -139,6 +168,71 @@ history), serializes `[phenomenonTime, result]` rows, POSTs with `mode: 'replace'`, surfaces a Snackbar, and clears the history in place on success. +### `useEditSession()` + +Orchestrates the server-backed QC session workflow against the +`services/qualityControl/` glue: + +```ts +const { beginEditing, startSession, saveDraft, commit, needsSession, needsHistory } = + useEditSession() +``` + +- `beginEditing()` — resolves the QC history for the QC datastream, loads + its sessions, resumes the in-progress one (or sets `needsSession`); sets + `needsHistory` when the datastream isn't QC-managed yet. +- `startSession(spec)` — creates a session and copies the source window in. +- `saveDraft()` — persists the record's edit operations to the session + (append-only reconcile). +- `commit()` — saves, verifies checksum C, pushes observations + (`mode: 'replace'`), then locks the session. + +### `useCreateManagedDatastream()` + +```ts +const { create } = useCreateManagedDatastream() +const { managedDatastream, history } = await create({ source, processingLevelId, name }) +``` + +Delegates to the tested `createManagedDatastream` orchestration with the +live client (`hs.datastreams` + `hs.qualityControlHistories`). The datastream +is created with `expand_related: true`, so `managedDatastream` comes back in +the same `Datastream & DatastreamExtended` shape as the `datastreams` catalog +and can be appended to it directly. Without the flag the 201 body is the flat +model (FK ids only) and catalog consumers reading `ds.processingLevel.id` +would break. + +### `useManagedDatastreams()` + +```ts +const { loadForSource } = useManagedDatastreams() +const options = await loadForSource(sourceDatastreamId) +// options: [{ historyId, managed, sessions }] +``` + +Resolves a source datastream's managed (QC) datastreams from the loaded QC +histories and fetches each one's sessions — feeds the "Start editing" +chooser, which lists managed datastreams with their in-progress/committed +sessions. + +### `useWorkspacePermissions()` + +Synchronous, reactive role/permission checks for gating UI. The role +travels with the `Workspace` object (`collaboratorRole.permissions`; owners +have a null role; `accountType === 'admin'` overrides), so no separate +endpoint is needed. + +```ts +const { canEdit, canCreateDatastream, roleName, isOwner, can } = + useWorkspacePermissions() +canEdit() // selected workspace: can run the QC edit flow? +canCreateDatastream(ws) // can create the managed datastream here? +roleName(ws) // 'Owner' | | 'Admin' | 'Read-only' +``` + +Used to disable the editor's Start editing / Save / Commit / Create +controls and to mark each workspace's role on the picker. + ### `useResizable()` Generic pointer-drag-resize hook. Used by `SelectDrawer`, `EditDrawer`, @@ -180,12 +274,17 @@ on boot. |-------------------------------------|----------|---------------------------------------------------|-------| | `things` | state | `Thing[]` | Sites in the active workspace; fetched once on workspace mount. | | `datastreams` | state | `(Datastream & DatastreamExtended)[]` | All visible datastreams (with `expand_related` nested objects). | +| `qcHistories` | state | `QualityControlHistory[]` | Workspace QC histories (each links a managed datastream to its source); loaded with the catalog. | +| `managedDatastreamIds` | computed | `Set` | Ids of every managed (QC) datastream; hidden from the catalog (reached via the Start-editing chooser). | +| `historiesBySource` | computed | `Map` | `sourceDatastreamId` -> its QC histories; drives the Start-editing chooser. | +| `addQcHistory` | action | `(history: QualityControlHistory) => void` | Register a newly-created history so its managed datastream hides from the catalog and shows in the chooser without a reload. | +| `removeManagedDatastream` | action | `(historyId: string, managedId: string) => void` | Drop a deleted managed datastream + its history from local state (chooser/catalog) after deleting it server-side. | | `observedProperties` | state | `ObservedProperty[]` | Taxonomy for the filter chips. | | `processingLevels` | state | `ProcessingLevel[]` | Taxonomy for the filter chips. | | `selectedThings` | state | `Thing[]` | Site filter selection (sidebar). | | `selectedObservedPropertyNames` | state | `string[]` | Observed-property filter selection. | | `selectedProcessingLevelNames` | state | `string[]` | Processing-level filter selection. | -| `filteredDatastreams` | computed | `(Datastream & DatastreamExtended)[]` | `datastreams` narrowed by the three filter selections. | +| `filteredDatastreams` | computed | `(Datastream & DatastreamExtended)[]` | `datastreams` narrowed by the three filter selections, with managed (QC) datastreams excluded. | | `plottedDatastreams` | state | `Datastream[]` | Up to 5 streams currently on the chart. | | `qcDatastreamId` | state | `string \| null` | Storage form of the QC target; survives plotted-list mutations. | | `qcDatastream` | computed | `Datastream \| null` | Live lookup of `qcDatastreamId` in `plottedDatastreams`. | @@ -209,8 +308,12 @@ on boot. | `plotDatastream` | action | `(ds: Datastream) => Promise` | Add to plot; promotes to QC when nothing's there yet. | | `unplotDatastream` | action | `(id: string) => Promise` | Remove; promotes the previous plotted entry to QC if removing the QC target. | | `clearPlottedDatastreams` | action | `() => Promise` | Drop the entire plotted set. | +| `addSnapshotSeries` | action | `(id: string, record: ObservationRecord, meta: SnapshotMeta) => Promise` | Add a frozen history snapshot as an extra comparison line under the synthetic id `snap::`. Never promotes to QC target; `refreshGraphSeriesArray` skips its fetch. | +| `removeSnapshotSeries` | action | `(id: string) => Promise` | Drop one snapshot line. Leaves the QC target alone. | | `setPlottedDatastreams` | action | `(items: Datastream[], qcId?: string \| null) => Promise` | Wholesale replace; used by URL hydration. | | `setQcDatastream` | action | `(id: string \| null) => Promise` | Change QC target; preserves the current zoom. | +| `adoptManagedDatastream` | action | `(managed: Datastream, sourceId: string) => Promise` | Enter editing on a freshly-created managed datastream: replace the source in the plot and re-key its already-loaded series as the managed datastream's working copy (no second, empty item; no re-fetch). | +| `releaseManagedDatastream` | action | `() => Promise` | Inverse of `adoptManagedDatastream`, for leaving the editor: swap the managed datastream back to its source (resolved through `qcHistories`), drop the editor's working copy and rebuild, so the plot shows the source as stored rather than the session's uncommitted edits. Managed datastreams are hidden from the catalog table, so without this the Select view shows a plot with nothing selected. No-op when the QC target isn't managed or its source isn't in the catalog. | | `rebuildPlot` | action | `() => Promise` | Serialized rebuild (drop zoom history, refresh series, regenerate options, render). Coalesces concurrent callers. | ### `usePlotlyStore()` — `src/store/plotly.ts` @@ -410,6 +513,39 @@ ephemeral connection state). |------|-------|--------------------|-------| | `hs` | state | `Ref` | Non-null after `main.ts` finishes settings load; type-asserted as non-null for ergonomic consumer code. | +### `useQcSessionStore()` — `src/store/qcSession.ts` + +View-mode state for QC sessions: which session is editable (the single +in-progress one) and which is being viewed. Viewing a committed session +puts the editor in read-only mode. + +| Name | Kind | Type / signature | Notes | +|---------------------|----------|-----------------------------------------|-------| +| `historyId` | state | `string \| null` | The managed datastream's QC history being navigated. | +| `resumeDatastreamId`| state | `string \| null` | Managed datastream the editor was last open on. The only persisted field: a page reload replots it and resumes its session from the last save. Set on entering the editor, cleared on exit. | +| `sessions` | state | `QualityControlSession[]` | Committed + in-progress sessions for the history. | +| `currentSessionId` | state | `string \| null` | The single in-progress (editable) session. | +| `viewedSessionId` | state | `string \| null` | The session currently being viewed. | +| `isLoading` | state | `boolean` | True while `loadSessions` is in flight. | +| `isSwitchingSession`| state | `boolean` | True while another session's data and operations load. The operations panel renders a loading state instead of the outgoing session's entries, which would otherwise linger and read as the incoming session's. | +| `isReadOnly` | computed | `boolean` | True when sessions exist and the viewed one isn't the in-progress session. Guarded on `sessions.length` so plain editing outside the session workflow isn't treated as read-only. | +| `inProgressSession` | computed | `QualityControlSession \| null` | The editable session, if any. | +| `committedSessions` | computed | `QualityControlSession[]` | Sessions with status `committed`. | +| `viewedSession` | computed | `QualityControlSession \| null` | The session for `viewedSessionId`. | +| `loadSessions` | action | `(historyId: string) => Promise` | Load a history's sessions; default the view to the in-progress one. | +| `viewSession` | action | `(sessionId: string) => void` | View a session read-only (no-op for an unknown id). | +| `returnToCurrent` | action | `() => void` | Return to the editable in-progress session. | +| `reset` | action | `() => void` | Clear all state. | + +### `useQcPreferencesStore()` — `src/store/qcPreferences.ts` + +Persisted QC editing preferences. Persistence: key `qc:preferences:v1`, +`pick: ['processingLevelId']`. + +| Name | Kind | Type / signature | Notes | +|---------------------|-------|------------------|-------| +| `processingLevelId` | state | `string \| null` | Last-used processing level for the Create-Datastream-for-Editing form; null on first use (no assumed default). | + ## Internal: utilities ### `src/utils/plotting/plotly.ts` (barrel) diff --git a/apps/qc-app/docs/ARCHITECTURE.md b/apps/qc-app/docs/ARCHITECTURE.md index a61a8d219..f205884c8 100644 --- a/apps/qc-app/docs/ARCHITECTURE.md +++ b/apps/qc-app/docs/ARCHITECTURE.md @@ -220,6 +220,135 @@ itself has zero Vue / Pinia / Plotly dependencies. The contract: Side-stepping `dispatch` breaks undo / redo, breaks QC History export, and silently breaks the worker fast-path. Don't. +## QC history / session service + +Editing is persisted as a session DAG through the HydroServer QC API +(`/api/data/quality-control/histories/{id}/sessions/{id}/operations`). + +The API client itself lives in **`@hydroserver/client`**, split across three +SDK services on the `hs` instance: `qualityControlHistories`, +`qualityControlSessions` (with `commit`), and `qualityControlOperations`. They +are normal SDK services built on the shared `apiMethods` layer, so they inherit +the session auth (CSRF cookie -> `X-CSRFToken`, `credentials: 'include'`) and +the `ApiResponse` return shape — methods never throw on HTTP errors. Bodies are +camelCase (`by_alias`); query parameters are snake_case (`expand_related`, +`range_start`, `managed_datastream_id`, `ancestor_of`, ...). + +`src/services/qualityControl/` holds only the **app-side orchestration** that +composes those services with `@uwrl/qc-utils` and the datastream/observation +APIs: `createManagedDatastream`, the session lifecycle (`session.ts`), +`persistOperations`, `commitSession`, `reconstructSession`, `findHistory`, and +the `observationsBulkBody` serializer. `unwrap` bridges `ApiResponse` to the +thrown errors this glue surfaces. None of it is a transport — swapping the QC +client out is a `@hydroserver/client` change, not an app one. + +Two contract notes worth keeping in mind: + +- **The backend stores the operation DAG as metadata only — it never replays + operations.** The app applies ops locally (qc-utils), pushes the edited + series to the managed datastream via `bulk-create` (replace mode), then calls + `/commit`, which only records checksums and extends the history window. + Checksum verification (source/managed) is the client's responsibility; + `/commit` performs none. +- **Vocabulary differs across the boundary.** qc-utils serializes operations as + `{ method, args }`; the QC API speaks `{ operationType, arguments, order }`. + The enum values are identical, so `persistOperations`/`reconstructSession` + rename the fields when crossing between qc-utils and the API. +- **Operation comments are part of the history, not app-side metadata.** + `HistoryItem.comment` is authored in the operations panel, so it rides + through `serializeHistory` into `persistOperations` and lands on the API's + `comment` field, and exported QC History files carry it. Comments are the one + part of a persisted operation that is patched in place, since they are + written after the operation ran; everything else stays append-only. The API + refuses updates on committed sessions, so the panel renders their comments + read-only. +- **Sessions start/resume from the latest committed state, not the raw source.** + Because each commit replays its session into the managed datastream (in-range + `replace`), the managed datastream's observations already carry every + committed session. `startSession`/`reconstructSession` therefore load the + managed datastream as the working base (via `loadLatestBase`, falling back to + the source only when nothing has been committed yet) and replay just the + current session's own draft operations on top. +- **A reload resumes from the last save, not from memory.** Only + `qcSession.resumeDatastreamId` is persisted; on load the editor replots that + datastream and re-runs `beginEditing`, which rebuilds the working copy from + the server via `reconstructSession`. Edits made since the last save are not + recoverable, so `useUnsavedChangesWarning` raises the browser's native + confirmation while `hasUnsavedChanges` is true. A deliberate exit clears the + pointer, so only an interrupted session reopens. +- **Viewing a past session replays its ancestor chain from the source.** + The managed datastream carries every commit, so it cannot be the base for a + historical view: replaying an older session's operations on top of it would + reproduce the final state. `reconstructCommittedSession` instead fetches the + ancestor closure (`ancestor_of`), loads the raw source over the union of + every window in the chain, and replays the chain in **commit order** + (`committedAt`, falling back to `createdAt`) — committing is what writes + observations into the managed datastream, so it is commit order, not + authoring order, that decides what a later session built on. The union + window matters because operations replay against array indices: loading + only the viewed session's window would misalign a wider ancestor's + selections and corrupt the result silently. The panel then shows just the + viewed session's own operations; the ancestors produced the data, but the + history is about what this session did. +- **Operations are attributed by the server.** Every `QCOperation` carries a + `created_by`, stamped from the authenticated user on create, and the + response resolves a deleted account to a placeholder contact rather than + null. `reconstructSession`/`reconstructCommittedSession` map it onto + `HistoryItem.performedBy` (name, falling back to email) so the operations + panel can attribute each row. It is never sent back: the field is + provenance, not input. Operations applied in the current session show no + attribution until they are saved and reloaded. Comments have no author of + their own — `comment` is a plain nullable column that can be rewritten + later — so "who wrote this note" is a pending backend ask. +- **A commit is terminal.** The API rejects updating, deleting, adding + operations to, or re-committing a committed session, and its PATCH body + carries only `description`. Continuing work after a commit means starting a + new session; the backend links it to every committed session its window + overlaps, which is how the DAG gets built. Reopening the most recent commit + is a pending backend ask (see the TODO in `store/qcSession.ts`) and needs + more than lifting the status guard, since a commit also writes observations + to the managed datastream and rolls the history's checksum and extent + forward. + +Tests stub the three services with `makeQcFake()` (a stateful in-memory double +under `services/qualityControl/__tests__/` that returns +`{ histories, sessions, operations }`) — the production client lives in the +package, not the app. + +**Permission gating.** QC editing writes to the source datastream's workspace +(creates the managed datastream, pushes observations), so the editor's entry +points are gated on the signed-in user's workspace role via +`useWorkspacePermissions()` — a read-only collaborator sees disabled Start +editing / Save / Commit controls and an explanation instead of a mid-flow 403, +and each workspace's role is marked on the picker. The role rides along on the +`Workspace` object (`collaboratorRole.permissions`; owners have a null role; +admins override), so no extra request is needed. + +**History snapshots.** A snapshot is a session's state at one operation, +plotted as an extra comparison line. `useHistorySnapshots()` drives it; +`buildSnapshotRecord()` builds the record by replaying `0..k`, delegating +committed sessions to `reconstructCommittedSession(..., opLimit)` and +replaying the live record for the in-progress one so unsaved drafts count. + +Three constraints shape the implementation: + +- **Window.** The base is always the session chain's own window, never the + plot's time range. Operations replay against array indices, so a different + base window misaligns the replay. A snapshot is therefore frozen at + creation and never refetched. +- **Record isolation.** `useObservationStore.fetchObservationsInRange` hands + back one shared `ObservationRecord` per datastream, and the replay mutates + whatever it is given. Snapshots inject a *detached* fetcher that warms the + raw cache and then constructs its own `ObservationRecord`, so a build never + disturbs the plot's series or a previous snapshot. +- **Identity.** Snapshots ride in `plottedDatastreams` under the synthetic id + `snap::` so legend rendering, colour assignment, + visibility and reorder work unchanged. `isSnapshotId()` guards the paths + that would otherwise treat one as real: `refreshGraphSeriesArray` skips its + fetch, `releaseManagedDatastream` drops them when the editor closes, and + the share encoder keeps them out of `ds` (they use their own `snap` key, so + the QC-target-is-first rule and the `h`/`ya` bitmask indices still hold). + ## Routing and auth vue-router 5, two routes (Home, Workspaces). Two guards run on diff --git a/apps/qc-app/docs/PLOTTING.md b/apps/qc-app/docs/PLOTTING.md index 3552e6a91..b4dd26c98 100644 --- a/apps/qc-app/docs/PLOTTING.md +++ b/apps/qc-app/docs/PLOTTING.md @@ -162,6 +162,19 @@ dispatch. Without it, a programmatic re-render after a HistoryItem replay would look like a user selection and append another item to the history. Cleared once the redraw is back in steady state. +## History snapshot series + +A `GraphSeries` carrying a `snapshot` field is a frozen replay of a QC +session at one operation, not a live datastream. To `createPlotlyOption` it +is an ordinary non-QC series: it gets its own overlaying right-side axis and +its own colour from the shared assigner. That is deliberate. Being able to +shift a snapshot on its own axis is how the user lines it up against the QC +target, which is the point of plotting it. + +What differs is upstream, not here: the data never refetches (see +`refreshGraphSeriesArray`'s `isSnapshotId` guard), and `PlottedDatastreams` +renders the row's provenance instead of a point count. + ## Why `internal.ts` isn't re-exported `plotly.ts` is a barrel for everything the rest of the app needs. diff --git a/apps/qc-app/docs/USER_GUIDE.md b/apps/qc-app/docs/USER_GUIDE.md index 3f2b45b97..9a12f4b91 100644 --- a/apps/qc-app/docs/USER_GUIDE.md +++ b/apps/qc-app/docs/USER_GUIDE.md @@ -414,18 +414,44 @@ The header carries the count chip and four icon buttons (left to right): **undo* The body shows: -- A baseline **Data loaded** row at the top, with a reload-from-server button. +- A baseline **Data loaded** row at the top, carrying a plot-this-step button, a **reload-from-this-step** button that returns the plot to the state the session started from, and a **reload-from-server** button (cloud icon) that refetches and drops the history entirely. - One row per history entry, each with: - The operation icon and Title-Case name. - A failure badge (red `!`) if the op threw at author time. Common after a QC history import that references something missing in this datastream. - A duration badge. - In dev mode, a small chip showing whether the op ran inline or on a worker. + - A **plot-this-step** button that adds that point in history to the plot as a comparison line. - A **reload-from-this-step** button that replays history up to but not including this entry. - An **undo** button on the trailing entry only (older entries are undone via Reload-from-this-step). - A chevron toggles an inline "Arguments" drawer that shows the raw qc-utils call arguments. Clicking the chevron at the very top of the panel collapses the whole panel; the pop-out icon opens the same panel inside a wider modal so you can scan a long history without losing the rest of the sidebar. +## Comparing against a point in history + +The chart-line button on any history row plots that session's state at that +operation as a **separate line**, so you can compare it against what you are +editing now without leaving your session. The button on the **Data loaded** +row plots the state the session started from, before its first operation. + +Click the button again to remove the line. + +Things worth knowing: + +- Snapshots appear in the plotted datastreams list with a history icon, a + `snapshot` chip, and a provenance line such as + `step 3 of 7: Fill Gaps - by Alice - Mar 14, 2026`. +- Each snapshot gets its own Y axis, so you can shift it to line it up + against the QC target, exactly like any other plotted datastream. +- A snapshot is **frozen**. It is computed once, over its own session's + window, and changing the plot's time range never refetches or recomputes + it. Zoom outside that window and the line simply stops. +- Snapshots of the session you are editing include your unsaved edits; + snapshots of any other session replay what was saved to the server. +- Snapshots travel in the share link, so a link reproduces the comparison. + Each one replays on load, so a link carrying several is slower to open. +- Leaving the editor for the Select view drops every snapshot. + ## Save / load a QC history The QC history is the canonical save format. It's a JSON file you can keep, re-apply, share, or version-control. diff --git a/apps/qc-app/src/App.vue b/apps/qc-app/src/App.vue index e54cfe1fb..37df62288 100644 --- a/apps/qc-app/src/App.vue +++ b/apps/qc-app/src/App.vue @@ -25,7 +25,7 @@ import type { Datastream, DatastreamExtended } from '@hydroserver/client' // auth guard sees `hs.session.isAuthenticated` on first navigation. const isLoading = ref(false) -const { things, processingLevels, observedProperties, datastreams } = +const { things, processingLevels, observedProperties, datastreams, qcHistories } = storeToRefs(useDataVisStore()) const { hs } = storeToRefs(useHydroServer()) @@ -37,6 +37,7 @@ async function loadWorkspaceCatalog(workspaceId: string) { datastreamsResponse, processingLevelsResponse, observedPropertiesResponse, + histories, ] = await Promise.all([ hs.value.things.list({ workspace_id: workspaceId } as any), hs.value.datastreams.list({ @@ -45,6 +46,9 @@ async function loadWorkspaceCatalog(workspaceId: string) { } as any), hs.value.processingLevels.list({ workspace_id: workspaceId } as any), hs.value.observedProperties.list({ workspace_id: workspaceId } as any), + // QC histories aren't workspace-filterable server-side; entries for + // other workspaces simply never match this catalog's datastream ids. + hs.value.qualityControlHistories.listAllItems(), ]) things.value = thingsResponse.ok ? thingsResponse.data : [] @@ -57,6 +61,7 @@ async function loadWorkspaceCatalog(workspaceId: string) { observedProperties.value = observedPropertiesResponse.ok ? observedPropertiesResponse.data : [] + qcHistories.value = histories } // Clearing the selection wipes catalogs so stale data from the old diff --git a/apps/qc-app/src/components/EditData/CreateDatastreamForm.vue b/apps/qc-app/src/components/EditData/CreateDatastreamForm.vue new file mode 100644 index 000000000..7cf1dd6d3 --- /dev/null +++ b/apps/qc-app/src/components/EditData/CreateDatastreamForm.vue @@ -0,0 +1,263 @@ + + + diff --git a/apps/qc-app/src/components/EditData/EditHistory.vue b/apps/qc-app/src/components/EditData/EditHistory.vue index 84802f5aa..e3eee9662 100644 --- a/apps/qc-app/src/components/EditData/EditHistory.vue +++ b/apps/qc-app/src/components/EditData/EditHistory.vue @@ -38,7 +38,7 @@ variant="text" density="comfortable" icon="mdi-undo-variant" - :disabled="isUpdating || !canUndo" + :disabled="isUpdating || isReadOnly || !canUndo" @click.stop="onUndo" /> @@ -54,7 +54,7 @@ variant="text" density="comfortable" icon="mdi-redo-variant" - :disabled="isUpdating || !canRedo" + :disabled="isUpdating || isReadOnly || !canRedo" @click.stop="onRedo" /> @@ -86,7 +86,7 @@ variant="text" density="comfortable" icon="mdi-tray-arrow-up" - :disabled="isUpdating" + :disabled="isUpdating || isReadOnly" @click.stop="onLoadHistoryClick" /> @@ -122,7 +122,9 @@ class="flex-grow-1 overflow-y-auto pa-2" style="min-height: 0" > -
+ +