From d669ece0fc3238fb64afbc3412d4ae04de98cb52 Mon Sep 17 00:00:00 2001 From: Sergio Neves Barros Date: Sun, 23 Aug 2026 15:47:23 +0100 Subject: [PATCH 01/37] feat(manual): add manual test case authoring screens First vertical slice of the manual test management UI: the test case list and detail screens, plus the four shared components the remaining screens will reuse. ManualStepEditor renders a case's steps, distinguishing a literal step from an inclusion of a shared step - the latter's own text is ignored, so showing them identically would mislead an author about which content they control here. It also renders read-only, which is how a frozen version and (later) a run's execution view display their steps. Order is managed with explicit move buttons rather than drag-and-drop: keyboard-navigable, no new dependency. CustomFieldForm generates inputs from the team's field definitions, so an admin adding a field makes it appear with no UI change. An archived field is rendered only when the case already holds a value for one: archived fields are on their way out and should not be offered for new input, but an existing value still has to be visible. ChangeHistory shows who changed what, including the two things the version list cannot express - a status change that burns no version, and a case re-versioned because somebody edited a shared step it includes, which names the shared step as the cause. VersionBadge marks which version is on screen and how far behind latest, since "v3" alone does not tell a QA whether what they are reading is current. The detail page fetches history only when its tab is opened - it is paginated and unbounded, and most visits are to read or edit rather than audit. After a save the version list is refetched rather than assumed to have moved, because only a content change burns a version. Creating a test case makes an empty draft and opens it, rather than prompting for a title: authoring is incremental and a draft need not be complete. Adds 84 translation keys to all four language files, and registers the new styles in index.less. No hard-coded colours - all semantic tokens. Co-Authored-By: Claude Opus 5 --- src/app/manual-test-cases/[caseId]/page.js | 10 + src/app/manual-test-cases/page.js | 8 + .../features/change-history/ChangeHistory.js | 123 ++++++ .../features/change-history/styles.less | 85 ++++ .../custom-field-form/CustomFieldForm.js | 132 ++++++ .../features/custom-field-form/styles.less | 33 ++ .../manual-step-editor/ManualStepEditor.js | 203 +++++++++ .../features/manual-step-editor/styles.less | 112 +++++ .../features/version-badge/VersionBadge.js | 43 ++ .../features/version-badge/styles.less | 24 ++ src/components/layout/Shell.js | 8 + .../ManualTestCaseDetailPage.js | 385 ++++++++++++++++++ .../manual-test-cases/ManualTestCasesPage.js | 240 +++++++++++ .../pages/manual-test-cases/styles.less | 71 ++++ src/styles/index.less | 5 + src/translations/cn.json | 86 +++- src/translations/en.json | 86 +++- src/translations/nl.json | 86 +++- src/translations/th.json | 86 +++- 19 files changed, 1822 insertions(+), 4 deletions(-) create mode 100644 src/app/manual-test-cases/[caseId]/page.js create mode 100644 src/app/manual-test-cases/page.js create mode 100644 src/components/features/change-history/ChangeHistory.js create mode 100644 src/components/features/change-history/styles.less create mode 100644 src/components/features/custom-field-form/CustomFieldForm.js create mode 100644 src/components/features/custom-field-form/styles.less create mode 100644 src/components/features/manual-step-editor/ManualStepEditor.js create mode 100644 src/components/features/manual-step-editor/styles.less create mode 100644 src/components/features/version-badge/VersionBadge.js create mode 100644 src/components/features/version-badge/styles.less create mode 100644 src/components/pages/manual-test-cases/ManualTestCaseDetailPage.js create mode 100644 src/components/pages/manual-test-cases/ManualTestCasesPage.js create mode 100644 src/components/pages/manual-test-cases/styles.less diff --git a/src/app/manual-test-cases/[caseId]/page.js b/src/app/manual-test-cases/[caseId]/page.js new file mode 100644 index 0000000..f9bed79 --- /dev/null +++ b/src/app/manual-test-cases/[caseId]/page.js @@ -0,0 +1,10 @@ +'use client'; + +import React from 'react'; +import { useParams } from 'next/navigation'; +import ManualTestCaseDetailPage from '../../../components/pages/manual-test-cases/ManualTestCaseDetailPage'; + +export default function Page() { + const params = useParams(); + return ; +} diff --git a/src/app/manual-test-cases/page.js b/src/app/manual-test-cases/page.js new file mode 100644 index 0000000..a19665e --- /dev/null +++ b/src/app/manual-test-cases/page.js @@ -0,0 +1,8 @@ +'use client'; + +import React from 'react'; +import ManualTestCasesPage from '../../components/pages/manual-test-cases/ManualTestCasesPage'; + +export default function Page() { + return ; +} diff --git a/src/components/features/change-history/ChangeHistory.js b/src/components/features/change-history/ChangeHistory.js new file mode 100644 index 0000000..128d322 --- /dev/null +++ b/src/components/features/change-history/ChangeHistory.js @@ -0,0 +1,123 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Loader, Tag } from 'rsuite'; +import Moment from 'react-moment'; +import { FormattedMessage } from 'react-intl'; + +// The audit actions the API can record. Kept as a lookup so an unrecognised action still +// renders (as its raw value) rather than showing nothing. +const ACTION_LABELS = { + CREATE: 'app.manual.history.action.create', + UPDATE: 'app.manual.history.action.update', + DELETE: 'app.manual.history.action.delete', + CLONE: 'app.manual.history.action.clone', + STATUS_CHANGE: 'app.manual.history.action.status-change', + SHARED_STEP_UPDATE: 'app.manual.history.action.shared-step-update', + ARCHIVE: 'app.manual.history.action.archive', +}; + +// A change value can be a string, number, boolean, date, array or an object (a step +// summary). Rendered as readable text rather than raw JSON wherever possible. +const formatValue = (value) => { + if (value === undefined || value === null || value === '') return '—'; + if (Array.isArray(value)) return value.join(', '); + if (typeof value === 'object') { + return Object.entries(value) + .map(([key, entry]) => `${key}: ${entry}`) + .join(' · '); + } + return String(value); +}; + +/** + * Timeline of who changed what on a test case or shared step. + * + * Distinct from the version list: a version is the content a tester saw, while this says + * who changed it and why - including a status change that burns no version, and a case + * re-versioned because somebody edited a shared step it includes. + */ +const ChangeHistory = ({ entries, loading }) => { + if (loading) { + return ( +
+ } /> +
+ ); + } + + if (!entries || entries.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
    + {entries.map((entry) => ( +
  • +
    + + + + {entry.version !== undefined && entry.version !== null && ( + + + + )} + + {entry.changedBy && entry.changedBy.username + ? entry.changedBy.username + : } + + + {entry.changedAt} + +
    + + {entry.comment && ( +
    {entry.comment}
    + )} + + {/* Names the shared step that caused a case to be re-versioned, so a + version bump nobody made directly is explained rather than mysterious. */} + {entry.causedBy && entry.causedBy.name && ( +
    + +
    + )} + + {entry.changes && entry.changes.length > 0 && ( +
      + {entry.changes.map((change, index) => ( + // eslint-disable-next-line react/no-array-index-key +
    • + {change.field} + {formatValue(change.from)} + → + {formatValue(change.to)} +
    • + ))} +
    + )} +
  • + ))} +
+ ); +}; + +ChangeHistory.propTypes = { + entries: PropTypes.array, + loading: PropTypes.bool, +}; + +export default ChangeHistory; diff --git a/src/components/features/change-history/styles.less b/src/components/features/change-history/styles.less new file mode 100644 index 0000000..d8977b1 --- /dev/null +++ b/src/components/features/change-history/styles.less @@ -0,0 +1,85 @@ +// ── Change history timeline ────────────────────────────────────────────────── +// Audit trail for a manual test case or shared step: who changed what, and why. +.change-history { + list-style: none; + margin: 0; + padding: 0; + + .change-history-entry { + padding: var(--space-3) 0; + border-bottom: 1px solid var(--border-subtle); + + &:last-child { + border-bottom: none; + } + } + + .change-history-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + } + + .change-history-action { + background: var(--sub-panel-background); + color: var(--main-panel-font-color); + font-size: var(--font-size-xs); + } + + .change-history-version, + .change-history-date { + color: var(--modal-secondary-font-color); + font-size: var(--font-size-xs); + } + + .change-history-author { + font-weight: var(--font-weight-medium); + } + + .change-history-comment { + margin-top: var(--space-2); + color: var(--main-panel-font-color); + } + + .change-history-caused-by { + margin-top: var(--space-1); + color: var(--modal-secondary-font-color); + font-size: var(--font-size-sm); + font-style: italic; + } + + .change-history-changes { + list-style: none; + margin: var(--space-2) 0 0; + padding: 0; + + li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-2); + padding: var(--space-1) 0; + font-size: var(--font-size-sm); + } + } + + .change-history-field { + font-weight: var(--font-weight-medium); + color: var(--main-panel-font-color); + } + + // The old value reads as removed, the new as added, without inventing new colours. + .change-history-from { + color: var(--fail-color); + text-decoration: line-through; + } + + .change-history-to { + color: var(--pass-color); + } + + .change-history-arrow { + color: var(--modal-secondary-font-color); + } +} diff --git a/src/components/features/custom-field-form/CustomFieldForm.js b/src/components/features/custom-field-form/CustomFieldForm.js new file mode 100644 index 0000000..4360a70 --- /dev/null +++ b/src/components/features/custom-field-form/CustomFieldForm.js @@ -0,0 +1,132 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Input, InputNumber, Toggle, SelectPicker, TagPicker, DatePicker } from 'rsuite'; +import { FormattedMessage } from 'react-intl'; + +/** + * Renders the inputs for a team's configured custom fields. + * + * The definitions come from the API rather than being hard-coded, so an admin adding a + * field makes it appear here with no UI change. Archived fields are rendered only when the + * case already holds a value for one - they are on their way out, so they should not be + * offered for new input, but an existing value still has to be visible and editable. + */ +const CustomFieldForm = ({ definitions, values, onChange, users, disabled }) => { + const setValue = (key, value) => { + onChange({ ...(values || {}), [key]: value }); + }; + + const visible = (definitions || []).filter((definition) => !definition.archived + || (values && values[definition.key] !== undefined && values[definition.key] !== null)); + + if (visible.length === 0) return null; + + const renderInput = (definition) => { + const value = values ? values[definition.key] : undefined; + const common = { disabled, block: true }; + + switch (definition.type) { + case 'textarea': + return ( + setValue(definition.key, next)} + disabled={disabled} + /> + ); + case 'number': + return ( + setValue(definition.key, next === '' ? null : Number(next))} + disabled={disabled} + /> + ); + case 'boolean': + return ( + setValue(definition.key, next)} + disabled={disabled} + /> + ); + case 'date': + return ( + setValue(definition.key, next ? next.toISOString() : null)} + disabled={disabled} + block + /> + ); + case 'select': + return ( + ({ label: option, value: option }))} + value={value === undefined ? null : value} + onChange={(next) => setValue(definition.key, next)} + {...common} + /> + ); + case 'multiselect': + return ( + ({ label: option, value: option }))} + value={Array.isArray(value) ? value : []} + onChange={(next) => setValue(definition.key, next)} + {...common} + /> + ); + case 'user': + return ( + ({ label: user.username, value: user._id }))} + value={value === undefined ? null : value} + onChange={(next) => setValue(definition.key, next)} + {...common} + /> + ); + default: + return ( + setValue(definition.key, next)} + disabled={disabled} + /> + ); + } + }; + + return ( +
+ {visible.map((definition) => ( +
+ +
+ {renderInput(definition)} +
+
+ ))} +
+ ); +}; + +CustomFieldForm.propTypes = { + definitions: PropTypes.array, + values: PropTypes.object, + onChange: PropTypes.func, + users: PropTypes.array, + disabled: PropTypes.bool, +}; + +export default CustomFieldForm; diff --git a/src/components/features/custom-field-form/styles.less b/src/components/features/custom-field-form/styles.less new file mode 100644 index 0000000..afd2bc5 --- /dev/null +++ b/src/components/features/custom-field-form/styles.less @@ -0,0 +1,33 @@ +// ── Custom field form ──────────────────────────────────────────────────────── +// Inputs generated from the team's configured field definitions. +.custom-field-form { + display: flex; + flex-direction: column; + gap: var(--space-3); + + .custom-field-row { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + .custom-field-label { + color: var(--main-panel-font-color); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + } + + .custom-field-required { + margin-left: var(--space-1); + color: var(--fail-color); + } + + // An archived field only appears when a value already exists for it, so the marker + // explains why an author is seeing a field that is no longer offered. + .custom-field-archived { + margin-left: var(--space-2); + color: var(--modal-secondary-font-color); + font-size: var(--font-size-xs); + font-style: italic; + } +} diff --git a/src/components/features/manual-step-editor/ManualStepEditor.js b/src/components/features/manual-step-editor/ManualStepEditor.js new file mode 100644 index 0000000..e34d28b --- /dev/null +++ b/src/components/features/manual-step-editor/ManualStepEditor.js @@ -0,0 +1,203 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Input, IconButton, Button, SelectPicker, Tag } from 'rsuite'; +import ArrowUpIcon from '@rsuite/icons/ArrowUp'; +import ArrowDownIcon from '@rsuite/icons/ArrowDown'; +import TrashIcon from '@rsuite/icons/Trash'; +import PlusIcon from '@rsuite/icons/Plus'; +import { FormattedMessage, useIntl } from 'react-intl'; + +/** + * Ordered editor for a test case's steps. + * + * A step is either literal (action + expected result) or an inclusion of a shared step, + * in which case its own text is ignored and the shared step's steps take its place when + * the case is read expanded or frozen into a version. The two are rendered differently so + * an author can see at a glance which steps they actually control here. + * + * Order is managed with explicit move buttons rather than drag-and-drop: the list is + * keyboard-navigable, needs no extra dependency, and step order is load-bearing (a step + * result binds to a step by id, but the tester follows them top to bottom). + */ +const ManualStepEditor = ({ steps, onChange, sharedSteps, readOnly }) => { + const intl = useIntl(); + + const sharedStepOptions = (sharedSteps || []).map((sharedStep) => ({ + label: `${sharedStep.name} (v${sharedStep.version})`, + value: sharedStep._id, + })); + + const updateStep = (index, changes) => { + const next = steps.map((step, i) => (i === index ? { ...step, ...changes } : step)); + onChange(next); + }; + + const addStep = () => { + onChange([...(steps || []), { action: '', expected: '' }]); + }; + + const addSharedStep = () => { + onChange([...(steps || []), { action: '', sharedStep: null }]); + }; + + const removeStep = (index) => { + onChange(steps.filter((step, i) => i !== index)); + }; + + const moveStep = (index, direction) => { + const target = index + direction; + if (target < 0 || target >= steps.length) return; + const next = [...steps]; + [next[index], next[target]] = [next[target], next[index]]; + onChange(next); + }; + + if (readOnly) { + return ( +
    + {(steps || []).map((step, index) => ( + // eslint-disable-next-line react/no-array-index-key +
  1. +
    +
    {step.action}
    + {step.expected && ( +
    + + + + {step.expected} +
    + )} + {step.data && ( +
    + + + + {step.data} +
    + )} + {/* An expanded step says where it came from, so a reader can tell + which content is owned by this case and which by a shared step. */} + {step.sharedStepRef && ( + + + + )} + {step.unresolvedSharedStep && ( + + + + )} +
    +
  2. + ))} +
+ ); + } + + return ( +
+
    + {(steps || []).map((step, index) => ( + // eslint-disable-next-line react/no-array-index-key +
  1. +
    + } + disabled={index === 0} + onClick={() => moveStep(index, -1)} + aria-label={intl.formatMessage({ id: 'app.manual.step-editor.move-up' })} + /> + } + disabled={index === steps.length - 1} + onClick={() => moveStep(index, 1)} + aria-label={intl.formatMessage({ id: 'app.manual.step-editor.move-down' })} + /> + } + onClick={() => removeStep(index)} + aria-label={intl.formatMessage({ id: 'app.manual.step-editor.remove' })} + /> +
    + +
    + {/* `sharedStep` being present at all (even null) marks this row as an + inclusion, so an author can add one before choosing which. */} + {'sharedStep' in step ? ( +
    + + + + updateStep(index, { sharedStep: value })} + placeholder={intl.formatMessage({ id: 'app.manual.step-editor.shared-step-placeholder' })} + block + cleanable={false} + /> +

    + +

    +
    + ) : ( + <> + updateStep(index, { action: value })} + placeholder={intl.formatMessage({ id: 'app.manual.step-editor.action-placeholder' })} + /> + updateStep(index, { expected: value })} + placeholder={intl.formatMessage({ id: 'app.manual.step-editor.expected-placeholder' })} + /> + updateStep(index, { data: value })} + placeholder={intl.formatMessage({ id: 'app.manual.step-editor.data-placeholder' })} + /> + + )} +
    +
  2. + ))} +
+ + {(!steps || steps.length === 0) && ( +
+ +
+ )} + +
+ + +
+
+ ); +}; + +ManualStepEditor.propTypes = { + steps: PropTypes.array, + onChange: PropTypes.func, + sharedSteps: PropTypes.array, + readOnly: PropTypes.bool, +}; + +export default ManualStepEditor; diff --git a/src/components/features/manual-step-editor/styles.less b/src/components/features/manual-step-editor/styles.less new file mode 100644 index 0000000..93646dc --- /dev/null +++ b/src/components/features/manual-step-editor/styles.less @@ -0,0 +1,112 @@ +// ── Manual step editor ─────────────────────────────────────────────────────── +// Ordered list of a test case's steps. A step is either literal or an inclusion of a +// shared step; the two look different so an author can see which content they own here. +.manual-step-editor { + .manual-step-list { + list-style: none; + margin: 0; + padding: 0; + counter-reset: manual-step; + } + + .manual-step { + display: flex; + gap: var(--space-3); + padding: var(--space-3); + margin-bottom: var(--space-2); + background: var(--sub-panel-background); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + counter-increment: manual-step; + + &::before { + content: counter(manual-step); + flex: 0 0 auto; + min-width: var(--space-5); + color: var(--modal-secondary-font-color); + font-weight: var(--font-weight-medium); + } + } + + .manual-step-controls { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + .manual-step-body { + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; + } + + .manual-step-label { + display: inline-block; + margin-right: var(--space-2); + color: var(--modal-secondary-font-color); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + text-transform: uppercase; + } + + .manual-step-action { + color: var(--main-panel-font-color); + white-space: pre-wrap; + } + + .manual-step-expected, + .manual-step-data { + color: var(--modal-secondary-font-color); + font-size: var(--font-size-sm); + white-space: pre-wrap; + } + + .manual-step-shared-tag { + align-self: flex-start; + background: var(--sub-panel-background); + color: var(--modal-secondary-font-color); + font-size: var(--font-size-xs); + } + + // An inclusion whose shared step has gone is kept rather than dropped, so it has to be + // visibly flagged - otherwise the case silently reads as one step short. + .manual-step-unresolved-tag { + align-self: flex-start; + background: var(--sub-panel-background); + color: var(--error-color); + font-size: var(--font-size-xs); + } + + .manual-step-editor-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-3); + } +} + +.manual-step-editor-readonly { + list-style: none; + margin: 0; + padding: 0; + counter-reset: manual-step; + + .manual-step { + padding: var(--space-3); + border-bottom: 1px solid var(--border-subtle); + counter-increment: manual-step; + + &:last-child { + border-bottom: none; + } + + &::before { + content: counter(manual-step); + display: inline-block; + min-width: var(--space-5); + color: var(--modal-secondary-font-color); + font-weight: var(--font-weight-medium); + } + } +} diff --git a/src/components/features/version-badge/VersionBadge.js b/src/components/features/version-badge/VersionBadge.js new file mode 100644 index 0000000..a6c78b9 --- /dev/null +++ b/src/components/features/version-badge/VersionBadge.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Tag } from 'rsuite'; +import { FormattedMessage } from 'react-intl'; + +/** + * Shows which version of a test case is being looked at, and how far behind the latest it + * is. Displayed anywhere a bound version is rendered - a run's execution view, or a + * historical version - because "v3" alone does not tell a QA whether what they are reading + * is current. + */ +const VersionBadge = ({ version, latestVersion, onClick }) => { + if (!version) return null; + const behind = latestVersion && latestVersion > version ? latestVersion - version : 0; + const isCurrent = behind === 0; + + return ( + + + + {!isCurrent && ( + + + + )} + + + ); +}; + +VersionBadge.propTypes = { + version: PropTypes.number, + latestVersion: PropTypes.number, + onClick: PropTypes.func, +}; + +export default VersionBadge; diff --git a/src/components/features/version-badge/styles.less b/src/components/features/version-badge/styles.less new file mode 100644 index 0000000..633092f --- /dev/null +++ b/src/components/features/version-badge/styles.less @@ -0,0 +1,24 @@ +// ── Version badge ──────────────────────────────────────────────────────────── +// Marks which frozen version is on screen, and flags when it trails the latest. +.version-badge { + display: inline-flex; + align-items: center; + + .version-badge-tag { + background: var(--sub-panel-background); + color: var(--main-panel-font-color); + font-size: var(--font-size-xs); + border-radius: var(--radius-pill); + } + + // A version behind the latest is worth noticing, so it borrows the skipped hue + // rather than a new colour - it is informational, not an error. + .version-badge-tag-behind { + color: var(--skipped-color); + } + + .version-badge-behind { + margin-left: var(--space-1); + opacity: 0.85; + } +} diff --git a/src/components/layout/Shell.js b/src/components/layout/Shell.js index 274fb1c..61a1eb1 100644 --- a/src/components/layout/Shell.js +++ b/src/components/layout/Shell.js @@ -29,6 +29,7 @@ import AngleRightIcon from '@rsuite/icons/legacy/AngleRight'; import Image from '@rsuite/icons/Image'; import BarChart from '@rsuite/icons/BarChart'; import DocPass from '@rsuite/icons/DocPass'; +import TaskIcon from '@rsuite/icons/Task'; import InfoOutline from '@rsuite/icons/InfoOutline'; import GlobalIcon from '@rsuite/icons/Global'; import AdminIcon from '@rsuite/icons/Admin'; @@ -219,6 +220,13 @@ const Shell = function (props) { /> + } href="/manual-test-cases"> + + + + } href="/screenshot-library"> ({ + label: intl.formatMessage({ id: `app.manual.status.${value.toLowerCase()}` }), + value, + })); + const priorityOptions = PRIORITY_VALUES.map((value) => ({ + label: intl.formatMessage({ id: `app.manual.priority.${value.toLowerCase()}` }), + value, + })); + + const pushError = (error, fallbackId) => { + toaster.push( + + {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))} + , + { placement: 'topEnd' }, + ); + }; + + const loadTestCase = useCallback(async () => { + setLoading(true); + try { + const found = await manualTestCaseRequests.getTestCase(caseId); + setTestCase(found); + setDraft({ + title: found.title, + description: found.description || '', + preconditions: found.preconditions || '', + status: found.status, + priority: found.priority, + tags: found.tags || [], + steps: found.steps || [], + customFields: found.customFields || {}, + }); + const teamId = found.team && found.team._id ? found.team._id : found.team; + const [sharedStepResponse, customFieldResponse, versionResponse] = await Promise.all([ + sharedStepRequests.getSharedSteps(teamId), + customFieldRequests.getCustomFields(teamId), + manualTestCaseRequests.getVersions(caseId), + ]); + setSharedSteps(sharedStepResponse.sharedSteps || []); + setFieldDefinitions(customFieldResponse.customFields || []); + setVersions(versionResponse.versions || []); + } catch (error) { + pushError(error, 'page.manual-test-case-detail.toast.fetch-error'); + } finally { + setLoading(false); + } + }, [caseId]); + + useEffect(() => { + loadTestCase(); + }, [loadTestCase]); + + // History is fetched only when its tab is opened - it is paginated and unbounded, and + // most visits to this page are to read or edit the case, not to audit it. + useEffect(() => { + if (activeTab !== 'history' || history.length > 0) return; + const loadHistory = async () => { + setHistoryLoading(true); + try { + const response = await manualTestCaseRequests.getHistory(caseId, 50); + setHistory(response.history || []); + } catch (error) { + pushError(error, 'page.manual-test-case-detail.toast.history-error'); + } finally { + setHistoryLoading(false); + } + }; + loadHistory(); + }, [activeTab, caseId]); + + const handleSave = async () => { + setSaving(true); + try { + const saved = await manualTestCaseRequests.updateTestCase(caseId, draft); + setTestCase(saved); + // The version may or may not have moved - only a content change burns one - so + // the list is refetched rather than assumed. + const versionResponse = await manualTestCaseRequests.getVersions(caseId); + setVersions(versionResponse.versions || []); + setHistory([]); + toaster.push( + + {intl.formatMessage({ id: 'page.manual-test-case-detail.toast.saved' })} + , + { placement: 'topEnd' }, + ); + } catch (error) { + pushError(error, 'page.manual-test-case-detail.toast.save-error'); + } finally { + setSaving(false); + } + }; + + const handleClone = async () => { + try { + const clone = await manualTestCaseRequests.cloneTestCase(caseId); + router.push(`/manual-test-cases/${clone._id}`); + } catch (error) { + pushError(error, 'page.manual-test-case-detail.toast.clone-error'); + } + }; + + const handleDelete = async () => { + setConfirmDelete(false); + try { + await manualTestCaseRequests.deleteTestCase(caseId); + router.push('/manual-test-cases'); + } catch (error) { + pushError(error, 'page.manual-test-case-detail.toast.delete-error'); + } + }; + + const handleViewVersion = async (version) => { + if (!version) { + setSelectedVersion(null); + return; + } + try { + const frozen = await manualTestCaseRequests.getVersion(caseId, version); + setSelectedVersion(frozen); + } catch (error) { + pushError(error, 'page.manual-test-case-detail.toast.version-error'); + } + }; + + if (loading || !draft) { + return ( +
+
+ } /> +
+
+ ); + } + + const versionOptions = versions.map((version) => ({ + label: intl.formatMessage( + { id: 'page.manual-test-case-detail.version-option' }, + { version: version.version }, + ), + value: version.version, + })); + + return ( +
+
+
+

{testCase.title}

+
+ + + + +
+
+ + + + {activeTab === 'details' && ( +
+
+ + setDraft({ ...draft, title: value })} + /> +
+
+ + setDraft({ ...draft, description: value })} + /> +
+
+ + setDraft({ ...draft, preconditions: value })} + /> +
+
+
+ + setDraft({ ...draft, status: value })} + cleanable={false} + block + /> + {/* Required custom fields only bite once a case leaves DRAFT, so + say so before an author hits a validation error on save. */} +

+ +

+
+
+ + setDraft({ ...draft, priority: value })} + cleanable={false} + block + /> +
+
+
+ + setDraft({ ...draft, tags: value })} + block + /> +
+ + {fieldDefinitions.length > 0 && ( + } + className="manual-test-case-custom-fields" + > + setDraft({ ...draft, customFields: values })} + /> + + )} +
+ )} + + {activeTab === 'steps' && ( +
+ setDraft({ ...draft, steps })} + sharedSteps={sharedSteps} + /> +
+ )} + + {activeTab === 'versions' && ( +
+
+ +
+ {selectedVersion ? ( + <> + {/* Reading a frozen version, not the live case: this is exactly + what an execution bound to it renders. */} +
+ + {selectedVersion.title} +
+ + + ) : ( +
+ +
+ )} +
+ )} + + {activeTab === 'history' && ( +
+ +
+ )} +
+ + setConfirmDelete(false)} + /> +
+ ); +} + +const mapStateToProps = (state) => ({ + currentTeam: state.teamsReducer.currentTeam, +}); + +export default connect(mapStateToProps)(ManualTestCaseDetailPage); diff --git a/src/components/pages/manual-test-cases/ManualTestCasesPage.js b/src/components/pages/manual-test-cases/ManualTestCasesPage.js new file mode 100644 index 0000000..092bd69 --- /dev/null +++ b/src/components/pages/manual-test-cases/ManualTestCasesPage.js @@ -0,0 +1,240 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; +import { connect } from 'react-redux'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { Table, Input, InputGroup, SelectPicker, TagPicker, Button, Loader, Pagination, Tag, Message, useToaster } from 'rsuite'; +import SearchIcon from '@rsuite/icons/Search'; +import PlusIcon from '@rsuite/icons/Plus'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { ManualTestCaseRequests } from 'angles-javascript-client'; +import { getApiErrorMessage } from '../../../utility/ApiUtilities'; + +const { Column, HeaderCell, Cell } = Table; + +const STATUS_VALUES = ['DRAFT', 'ACTIVE', 'DEPRECATED']; +const PRIORITY_VALUES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; +const PAGE_LIMIT = 25; + +function ManualTestCasesPage(props) { + const { currentTeam } = props; + const router = useRouter(); + const toaster = useToaster(); + const intl = useIntl(); + + const [testCases, setTestCases] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState([]); + const [priorityFilter, setPriorityFilter] = useState([]); + const [creating, setCreating] = useState(false); + + const manualTestCaseRequests = new ManualTestCaseRequests(axios); + + const statusOptions = STATUS_VALUES.map((value) => ({ + label: intl.formatMessage({ id: `app.manual.status.${value.toLowerCase()}` }), + value, + })); + const priorityOptions = PRIORITY_VALUES.map((value) => ({ + label: intl.formatMessage({ id: `app.manual.priority.${value.toLowerCase()}` }), + value, + })); + + const loadTestCases = useCallback(async () => { + if (!currentTeam || !currentTeam._id) return; + setLoading(true); + try { + const response = await manualTestCaseRequests.getTestCases(currentTeam._id, { + search: search || undefined, + status: statusFilter, + priority: priorityFilter, + limit: PAGE_LIMIT, + skip: (page - 1) * PAGE_LIMIT, + }); + setTestCases(response.testCases || []); + setTotal(response.metrics ? response.metrics.totalTestCases : 0); + } catch (error) { + toaster.push( + + {getApiErrorMessage(error, intl.formatMessage({ id: 'page.manual-test-cases.toast.fetch-error' }))} + , + { placement: 'topEnd' }, + ); + } finally { + setLoading(false); + } + }, [currentTeam, search, statusFilter, priorityFilter, page]); + + useEffect(() => { + loadTestCases(); + }, [loadTestCases]); + + // Any filter change invalidates the current page - staying on page 4 of a narrower + // result set would show an empty table rather than the matches. + useEffect(() => { + setPage(1); + }, [search, statusFilter, priorityFilter, currentTeam]); + + const handleCreate = async () => { + if (!currentTeam || !currentTeam._id) return; + setCreating(true); + try { + // Created as an empty draft and opened for editing, rather than asking for a + // title up front - authoring is incremental and a draft need not be complete. + const created = await manualTestCaseRequests.createTestCase({ + team: currentTeam._id, + title: intl.formatMessage({ id: 'page.manual-test-cases.new-case-title' }), + }); + router.push(`/manual-test-cases/${created._id}`); + } catch (error) { + toaster.push( + + {getApiErrorMessage(error, intl.formatMessage({ id: 'page.manual-test-cases.toast.create-error' }))} + , + { placement: 'topEnd' }, + ); + setCreating(false); + } + }; + + if (!currentTeam || !currentTeam._id) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+
+
+

+ +

+ +
+ +
+ + + + + + +
+ + {loading ? ( +
+ } /> +
+ ) : ( + <> + router.push(`/manual-test-cases/${row._id}`)} + > + + + + {(row) => ( + + {row.title} + + )} + + + + + + {(row) => ( + + + + )} + + + + + + {(row) => ( + + )} + + + + + {(row) => `v${row.version}`} + + + + + {(row) => (row.updatedBy ? row.updatedBy.username : '—')} + + + + + {(row) => (row.steps ? row.steps.length : 0)} + +
+ + {testCases.length === 0 && ( +
+ +
+ )} + + {total > PAGE_LIMIT && ( +
+ +
+ )} + + )} +
+
+ ); +} + +const mapStateToProps = (state) => ({ + currentTeam: state.teamsReducer.currentTeam, +}); + +export default connect(mapStateToProps)(ManualTestCasesPage); diff --git a/src/components/pages/manual-test-cases/styles.less b/src/components/pages/manual-test-cases/styles.less new file mode 100644 index 0000000..4211dae --- /dev/null +++ b/src/components/pages/manual-test-cases/styles.less @@ -0,0 +1,71 @@ +// ── Manual test cases ──────────────────────────────────────────────────────── +.manual-test-cases-page { + .manual-test-cases-filters { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: center; + } + + .manual-test-cases-search { + max-width: 320px; + } + + .manual-test-cases-pagination { + display: flex; + justify-content: flex-end; + padding-top: var(--space-3); + } +} + +// Status pills reuse the shared result hues rather than inventing new ones: DRAFT reads as +// not-yet-run, ACTIVE as good, DEPRECATED as retired. +.manual-status-tag { + background: var(--sub-panel-background); + font-size: var(--font-size-xs); + + &.manual-status-draft { + color: var(--skipped-color); + } + + &.manual-status-active { + color: var(--pass-color); + } + + &.manual-status-deprecated { + color: var(--modal-secondary-font-color); + } +} + +.manual-test-case-detail-page { + .manual-test-case-header-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + } + + .manual-test-case-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: var(--space-3); + } + + .manual-test-case-custom-fields { + margin-top: var(--space-4); + background: var(--sub-panel-background); + border-radius: var(--radius-md); + } + + .manual-version-header { + display: flex; + align-items: center; + gap: var(--space-2); + padding-bottom: var(--space-3); + } + + .manual-version-title { + font-weight: var(--font-weight-medium); + color: var(--main-panel-font-color); + } +} diff --git a/src/styles/index.less b/src/styles/index.less index 60fc349..8e17e89 100644 --- a/src/styles/index.less +++ b/src/styles/index.less @@ -32,6 +32,7 @@ @import '../components/pages/admin-settings/styles.less'; @import '../components/pages/dashboard/styles.less'; @import '../components/pages/login/styles.less'; +@import '../components/pages/manual-test-cases/styles.less'; @import '../components/pages/metrics/styles.less'; @import '../components/pages/screenshot-library/styles.less'; @import '../components/pages/team-settings/styles.less'; @@ -40,7 +41,11 @@ @import '../components/pages/user-settings/styles.less'; // ── Co-located feature styles (components/features/*) ──────────────────────── +@import '../components/features/change-history/styles.less'; +@import '../components/features/custom-field-form/styles.less'; @import '../components/features/execution-timeline/styles.less'; +@import '../components/features/manual-step-editor/styles.less'; @import '../components/features/results-bar/styles.less'; @import '../components/features/screenshot-view/styles.less'; +@import '../components/features/version-badge/styles.less'; @import '../components/features/test-suite/styles.less'; diff --git a/src/translations/cn.json b/src/translations/cn.json index 7b022aa..5079e41 100644 --- a/src/translations/cn.json +++ b/src/translations/cn.json @@ -511,5 +511,89 @@ "page.team-settings.toast.name-update-success": "团队名称更新成功", "page.team-settings.toast.name-update-error": "更新团队名称失败", "page.team-settings.toast.components-update-success": "组件添加成功", - "page.team-settings.toast.components-update-error": "添加组件失败" + "page.team-settings.toast.components-update-error": "添加组件失败", + "nav.manual-test-cases": "手动测试用例", + "app.manual.status.draft": "草稿", + "app.manual.status.active": "启用", + "app.manual.status.deprecated": "已弃用", + "app.manual.priority.low": "低", + "app.manual.priority.medium": "中", + "app.manual.priority.high": "高", + "app.manual.priority.critical": "紧急", + "app.manual.cancel": "取消", + "app.manual.version-badge.version": "v{version}", + "app.manual.version-badge.behind": "· 落后最新 {count} 个版本", + "app.manual.history.loading": "正在加载历史...", + "app.manual.history.empty": "尚无变更记录。", + "app.manual.history.unknown-author": "未知用户", + "app.manual.history.caused-by": "由共享步骤“{name}”的更新引起", + "app.manual.history.action.create": "已创建", + "app.manual.history.action.update": "已更新", + "app.manual.history.action.delete": "已删除", + "app.manual.history.action.clone": "已克隆", + "app.manual.history.action.status-change": "状态已更改", + "app.manual.history.action.shared-step-update": "共享步骤已更新", + "app.manual.history.action.archive": "已归档", + "app.manual.step-editor.expected": "预期结果", + "app.manual.step-editor.data": "测试数据", + "app.manual.step-editor.shared-step": "共享步骤", + "app.manual.step-editor.shared-step-placeholder": "选择共享步骤", + "app.manual.step-editor.shared-step-help": "运行用例或保存版本时,其步骤将替换此步骤。", + "app.manual.step-editor.from-shared-step": "来自共享步骤 (v{version})", + "app.manual.step-editor.unresolved": "共享步骤已不存在", + "app.manual.step-editor.action-placeholder": "测试人员应执行什么操作?", + "app.manual.step-editor.expected-placeholder": "预期会发生什么?", + "app.manual.step-editor.data-placeholder": "测试数据(可选)", + "app.manual.step-editor.add-step": "添加步骤", + "app.manual.step-editor.add-shared-step": "引用共享步骤", + "app.manual.step-editor.move-up": "上移步骤", + "app.manual.step-editor.move-down": "下移步骤", + "app.manual.step-editor.remove": "删除步骤", + "app.manual.step-editor.empty": "尚无步骤,请在下方添加。", + "app.manual.custom-field.archived": "(已归档)", + "page.manual-test-cases": "手动测试用例", + "page.manual-test-cases.create": "新建测试用例", + "page.manual-test-cases.new-case-title": "未命名测试用例", + "page.manual-test-cases.loading": "正在加载测试用例...", + "page.manual-test-cases.empty": "没有符合这些筛选条件的测试用例。", + "page.manual-test-cases.no-team": "请选择团队以查看其手动测试用例。", + "page.manual-test-cases.filters.search": "搜索标题或描述", + "page.manual-test-cases.filters.status": "状态", + "page.manual-test-cases.filters.priority": "优先级", + "page.manual-test-cases.table.title": "标题", + "page.manual-test-cases.table.status": "状态", + "page.manual-test-cases.table.priority": "优先级", + "page.manual-test-cases.table.version": "版本", + "page.manual-test-cases.table.updated-by": "更新者", + "page.manual-test-cases.table.steps": "步骤数", + "page.manual-test-cases.toast.fetch-error": "无法加载测试用例。", + "page.manual-test-cases.toast.create-error": "无法创建测试用例。", + "page.manual-test-case-detail.loading": "正在加载测试用例...", + "page.manual-test-case-detail.save": "保存", + "page.manual-test-case-detail.clone": "克隆", + "page.manual-test-case-detail.delete": "删除", + "page.manual-test-case-detail.tab.details": "详情", + "page.manual-test-case-detail.tab.steps": "步骤", + "page.manual-test-case-detail.tab.versions": "版本", + "page.manual-test-case-detail.tab.history": "历史记录", + "page.manual-test-case-detail.field.title": "标题", + "page.manual-test-case-detail.field.description": "描述", + "page.manual-test-case-detail.field.preconditions": "前置条件", + "page.manual-test-case-detail.field.status": "状态", + "page.manual-test-case-detail.field.priority": "优先级", + "page.manual-test-case-detail.field.tags": "标签", + "page.manual-test-case-detail.status-help": "必填自定义字段仅在用例离开“草稿”状态后强制校验。", + "page.manual-test-case-detail.custom-fields": "自定义字段", + "page.manual-test-case-detail.select-version": "选择版本", + "page.manual-test-case-detail.version-option": "版本 {version}", + "page.manual-test-case-detail.versions-help": "选择一个版本,查看执行时测试人员所看到的内容。", + "page.manual-test-case-detail.delete-confirm-title": "删除测试用例", + "page.manual-test-case-detail.delete-confirm-message": "这将删除该测试用例及其全部版本历史,且无法撤销。", + "page.manual-test-case-detail.toast.saved": "测试用例已保存。", + "page.manual-test-case-detail.toast.fetch-error": "无法加载测试用例。", + "page.manual-test-case-detail.toast.save-error": "无法保存测试用例。", + "page.manual-test-case-detail.toast.clone-error": "无法克隆测试用例。", + "page.manual-test-case-detail.toast.delete-error": "无法删除测试用例。", + "page.manual-test-case-detail.toast.version-error": "无法加载该版本。", + "page.manual-test-case-detail.toast.history-error": "无法加载变更历史。" } diff --git a/src/translations/en.json b/src/translations/en.json index ea0b60b..c68f41f 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -511,5 +511,89 @@ "page.team-settings.toast.name-update-success": "Team name updated successfully", "page.team-settings.toast.name-update-error": "Failed to update team name", "page.team-settings.toast.components-update-success": "Components added successfully", - "page.team-settings.toast.components-update-error": "Failed to add components" + "page.team-settings.toast.components-update-error": "Failed to add components", + "nav.manual-test-cases": "Manual Test Cases", + "app.manual.status.draft": "Draft", + "app.manual.status.active": "Active", + "app.manual.status.deprecated": "Deprecated", + "app.manual.priority.low": "Low", + "app.manual.priority.medium": "Medium", + "app.manual.priority.high": "High", + "app.manual.priority.critical": "Critical", + "app.manual.cancel": "Cancel", + "app.manual.version-badge.version": "v{version}", + "app.manual.version-badge.behind": "· {count} behind latest", + "app.manual.history.loading": "Loading history...", + "app.manual.history.empty": "No changes recorded yet.", + "app.manual.history.unknown-author": "Unknown user", + "app.manual.history.caused-by": "Caused by an update to shared step \"{name}\"", + "app.manual.history.action.create": "Created", + "app.manual.history.action.update": "Updated", + "app.manual.history.action.delete": "Deleted", + "app.manual.history.action.clone": "Cloned", + "app.manual.history.action.status-change": "Status changed", + "app.manual.history.action.shared-step-update": "Shared step updated", + "app.manual.history.action.archive": "Archived", + "app.manual.step-editor.expected": "Expected", + "app.manual.step-editor.data": "Data", + "app.manual.step-editor.shared-step": "Shared step", + "app.manual.step-editor.shared-step-placeholder": "Select a shared step", + "app.manual.step-editor.shared-step-help": "Its steps replace this one when the test case is run or a version is saved.", + "app.manual.step-editor.from-shared-step": "From shared step (v{version})", + "app.manual.step-editor.unresolved": "Shared step no longer exists", + "app.manual.step-editor.action-placeholder": "What should the tester do?", + "app.manual.step-editor.expected-placeholder": "What should happen?", + "app.manual.step-editor.data-placeholder": "Test data (optional)", + "app.manual.step-editor.add-step": "Add step", + "app.manual.step-editor.add-shared-step": "Include shared step", + "app.manual.step-editor.move-up": "Move step up", + "app.manual.step-editor.move-down": "Move step down", + "app.manual.step-editor.remove": "Remove step", + "app.manual.step-editor.empty": "No steps yet. Add the first one below.", + "app.manual.custom-field.archived": "(archived)", + "page.manual-test-cases": "Manual Test Cases", + "page.manual-test-cases.create": "New test case", + "page.manual-test-cases.new-case-title": "Untitled test case", + "page.manual-test-cases.loading": "Loading test cases...", + "page.manual-test-cases.empty": "No test cases match these filters.", + "page.manual-test-cases.no-team": "Select a team to see its manual test cases.", + "page.manual-test-cases.filters.search": "Search title or description", + "page.manual-test-cases.filters.status": "Status", + "page.manual-test-cases.filters.priority": "Priority", + "page.manual-test-cases.table.title": "Title", + "page.manual-test-cases.table.status": "Status", + "page.manual-test-cases.table.priority": "Priority", + "page.manual-test-cases.table.version": "Version", + "page.manual-test-cases.table.updated-by": "Updated by", + "page.manual-test-cases.table.steps": "Steps", + "page.manual-test-cases.toast.fetch-error": "Could not load the test cases.", + "page.manual-test-cases.toast.create-error": "Could not create the test case.", + "page.manual-test-case-detail.loading": "Loading test case...", + "page.manual-test-case-detail.save": "Save", + "page.manual-test-case-detail.clone": "Clone", + "page.manual-test-case-detail.delete": "Delete", + "page.manual-test-case-detail.tab.details": "Details", + "page.manual-test-case-detail.tab.steps": "Steps", + "page.manual-test-case-detail.tab.versions": "Versions", + "page.manual-test-case-detail.tab.history": "History", + "page.manual-test-case-detail.field.title": "Title", + "page.manual-test-case-detail.field.description": "Description", + "page.manual-test-case-detail.field.preconditions": "Preconditions", + "page.manual-test-case-detail.field.status": "Status", + "page.manual-test-case-detail.field.priority": "Priority", + "page.manual-test-case-detail.field.tags": "Tags", + "page.manual-test-case-detail.status-help": "Required custom fields are only enforced once a case leaves Draft.", + "page.manual-test-case-detail.custom-fields": "Custom fields", + "page.manual-test-case-detail.select-version": "Select a version", + "page.manual-test-case-detail.version-option": "Version {version}", + "page.manual-test-case-detail.versions-help": "Select a version to see exactly what a tester saw when it was executed.", + "page.manual-test-case-detail.delete-confirm-title": "Delete test case", + "page.manual-test-case-detail.delete-confirm-message": "This removes the test case and its whole version history. This cannot be undone.", + "page.manual-test-case-detail.toast.saved": "Test case saved.", + "page.manual-test-case-detail.toast.fetch-error": "Could not load the test case.", + "page.manual-test-case-detail.toast.save-error": "Could not save the test case.", + "page.manual-test-case-detail.toast.clone-error": "Could not clone the test case.", + "page.manual-test-case-detail.toast.delete-error": "Could not delete the test case.", + "page.manual-test-case-detail.toast.version-error": "Could not load that version.", + "page.manual-test-case-detail.toast.history-error": "Could not load the change history." } diff --git a/src/translations/nl.json b/src/translations/nl.json index 614515e..2ef8743 100644 --- a/src/translations/nl.json +++ b/src/translations/nl.json @@ -511,5 +511,89 @@ "page.team-settings.toast.name-update-success": "Teamnaam succesvol bijgewerkt", "page.team-settings.toast.name-update-error": "Bijwerken van teamnaam mislukt", "page.team-settings.toast.components-update-success": "Onderdelen succesvol toegevoegd", - "page.team-settings.toast.components-update-error": "Toevoegen van onderdelen mislukt" + "page.team-settings.toast.components-update-error": "Toevoegen van onderdelen mislukt", + "nav.manual-test-cases": "Handmatige Testgevallen", + "app.manual.status.draft": "Concept", + "app.manual.status.active": "Actief", + "app.manual.status.deprecated": "Vervallen", + "app.manual.priority.low": "Laag", + "app.manual.priority.medium": "Gemiddeld", + "app.manual.priority.high": "Hoog", + "app.manual.priority.critical": "Kritiek", + "app.manual.cancel": "Annuleren", + "app.manual.version-badge.version": "v{version}", + "app.manual.version-badge.behind": "· {count} achter op laatste", + "app.manual.history.loading": "Geschiedenis laden...", + "app.manual.history.empty": "Nog geen wijzigingen vastgelegd.", + "app.manual.history.unknown-author": "Onbekende gebruiker", + "app.manual.history.caused-by": "Veroorzaakt door een wijziging in gedeelde stap \"{name}\"", + "app.manual.history.action.create": "Aangemaakt", + "app.manual.history.action.update": "Bijgewerkt", + "app.manual.history.action.delete": "Verwijderd", + "app.manual.history.action.clone": "Gekloond", + "app.manual.history.action.status-change": "Status gewijzigd", + "app.manual.history.action.shared-step-update": "Gedeelde stap bijgewerkt", + "app.manual.history.action.archive": "Gearchiveerd", + "app.manual.step-editor.expected": "Verwacht", + "app.manual.step-editor.data": "Gegevens", + "app.manual.step-editor.shared-step": "Gedeelde stap", + "app.manual.step-editor.shared-step-placeholder": "Kies een gedeelde stap", + "app.manual.step-editor.shared-step-help": "De stappen ervan vervangen deze bij het uitvoeren of opslaan van een versie.", + "app.manual.step-editor.from-shared-step": "Uit gedeelde stap (v{version})", + "app.manual.step-editor.unresolved": "Gedeelde stap bestaat niet meer", + "app.manual.step-editor.action-placeholder": "Wat moet de tester doen?", + "app.manual.step-editor.expected-placeholder": "Wat moet er gebeuren?", + "app.manual.step-editor.data-placeholder": "Testgegevens (optioneel)", + "app.manual.step-editor.add-step": "Stap toevoegen", + "app.manual.step-editor.add-shared-step": "Gedeelde stap opnemen", + "app.manual.step-editor.move-up": "Stap omhoog", + "app.manual.step-editor.move-down": "Stap omlaag", + "app.manual.step-editor.remove": "Stap verwijderen", + "app.manual.step-editor.empty": "Nog geen stappen. Voeg hieronder de eerste toe.", + "app.manual.custom-field.archived": "(gearchiveerd)", + "page.manual-test-cases": "Handmatige Testgevallen", + "page.manual-test-cases.create": "Nieuw testgeval", + "page.manual-test-cases.new-case-title": "Naamloos testgeval", + "page.manual-test-cases.loading": "Testgevallen laden...", + "page.manual-test-cases.empty": "Geen testgevallen voldoen aan deze filters.", + "page.manual-test-cases.no-team": "Kies een team om de handmatige testgevallen te zien.", + "page.manual-test-cases.filters.search": "Zoek in titel of omschrijving", + "page.manual-test-cases.filters.status": "Status", + "page.manual-test-cases.filters.priority": "Prioriteit", + "page.manual-test-cases.table.title": "Titel", + "page.manual-test-cases.table.status": "Status", + "page.manual-test-cases.table.priority": "Prioriteit", + "page.manual-test-cases.table.version": "Versie", + "page.manual-test-cases.table.updated-by": "Bijgewerkt door", + "page.manual-test-cases.table.steps": "Stappen", + "page.manual-test-cases.toast.fetch-error": "Kon de testgevallen niet laden.", + "page.manual-test-cases.toast.create-error": "Kon het testgeval niet aanmaken.", + "page.manual-test-case-detail.loading": "Testgeval laden...", + "page.manual-test-case-detail.save": "Opslaan", + "page.manual-test-case-detail.clone": "Klonen", + "page.manual-test-case-detail.delete": "Verwijderen", + "page.manual-test-case-detail.tab.details": "Details", + "page.manual-test-case-detail.tab.steps": "Stappen", + "page.manual-test-case-detail.tab.versions": "Versies", + "page.manual-test-case-detail.tab.history": "Geschiedenis", + "page.manual-test-case-detail.field.title": "Titel", + "page.manual-test-case-detail.field.description": "Omschrijving", + "page.manual-test-case-detail.field.preconditions": "Voorwaarden", + "page.manual-test-case-detail.field.status": "Status", + "page.manual-test-case-detail.field.priority": "Prioriteit", + "page.manual-test-case-detail.field.tags": "Labels", + "page.manual-test-case-detail.status-help": "Verplichte aangepaste velden gelden pas zodra een geval niet langer Concept is.", + "page.manual-test-case-detail.custom-fields": "Aangepaste velden", + "page.manual-test-case-detail.select-version": "Kies een versie", + "page.manual-test-case-detail.version-option": "Versie {version}", + "page.manual-test-case-detail.versions-help": "Kies een versie om te zien wat een tester zag bij de uitvoering.", + "page.manual-test-case-detail.delete-confirm-title": "Testgeval verwijderen", + "page.manual-test-case-detail.delete-confirm-message": "Dit verwijdert het testgeval en de volledige versiegeschiedenis. Dit kan niet ongedaan worden gemaakt.", + "page.manual-test-case-detail.toast.saved": "Testgeval opgeslagen.", + "page.manual-test-case-detail.toast.fetch-error": "Kon het testgeval niet laden.", + "page.manual-test-case-detail.toast.save-error": "Kon het testgeval niet opslaan.", + "page.manual-test-case-detail.toast.clone-error": "Kon het testgeval niet klonen.", + "page.manual-test-case-detail.toast.delete-error": "Kon het testgeval niet verwijderen.", + "page.manual-test-case-detail.toast.version-error": "Kon die versie niet laden.", + "page.manual-test-case-detail.toast.history-error": "Kon de geschiedenis niet laden." } diff --git a/src/translations/th.json b/src/translations/th.json index d932566..c2a0b53 100644 --- a/src/translations/th.json +++ b/src/translations/th.json @@ -511,5 +511,89 @@ "page.team-settings.toast.name-update-success": "อัปเดตชื่อทีมสำเร็จแล้ว", "page.team-settings.toast.name-update-error": "ไม่สามารถอัปเดตชื่อทีมได้", "page.team-settings.toast.components-update-success": "เพิ่มส่วนประกอบสำเร็จแล้ว", - "page.team-settings.toast.components-update-error": "ไม่สามารถเพิ่มส่วนประกอบได้" + "page.team-settings.toast.components-update-error": "ไม่สามารถเพิ่มส่วนประกอบได้", + "nav.manual-test-cases": "เทสต์เคสแบบแมนนวล", + "app.manual.status.draft": "ฉบับร่าง", + "app.manual.status.active": "ใช้งาน", + "app.manual.status.deprecated": "เลิกใช้", + "app.manual.priority.low": "ต่ำ", + "app.manual.priority.medium": "ปานกลาง", + "app.manual.priority.high": "สูง", + "app.manual.priority.critical": "วิกฤต", + "app.manual.cancel": "ยกเลิก", + "app.manual.version-badge.version": "v{version}", + "app.manual.version-badge.behind": "· ตามหลังล่าสุด {count}", + "app.manual.history.loading": "กำลังโหลดประวัติ...", + "app.manual.history.empty": "ยังไม่มีการบันทึกการเปลี่ยนแปลง", + "app.manual.history.unknown-author": "ผู้ใช้ที่ไม่รู้จัก", + "app.manual.history.caused-by": "เกิดจากการอัปเดตขั้นตอนที่ใช้ร่วมกัน \"{name}\"", + "app.manual.history.action.create": "สร้างแล้ว", + "app.manual.history.action.update": "อัปเดตแล้ว", + "app.manual.history.action.delete": "ลบแล้ว", + "app.manual.history.action.clone": "โคลนแล้ว", + "app.manual.history.action.status-change": "เปลี่ยนสถานะ", + "app.manual.history.action.shared-step-update": "อัปเดตขั้นตอนที่ใช้ร่วมกัน", + "app.manual.history.action.archive": "เก็บถาวรแล้ว", + "app.manual.step-editor.expected": "ผลที่คาดหวัง", + "app.manual.step-editor.data": "ข้อมูล", + "app.manual.step-editor.shared-step": "ขั้นตอนที่ใช้ร่วมกัน", + "app.manual.step-editor.shared-step-placeholder": "เลือกขั้นตอนที่ใช้ร่วมกัน", + "app.manual.step-editor.shared-step-help": "ขั้นตอนของรายการนั้นจะแทนที่ขั้นตอนนี้เมื่อรันหรือบันทึกเวอร์ชัน", + "app.manual.step-editor.from-shared-step": "จากขั้นตอนที่ใช้ร่วมกัน (v{version})", + "app.manual.step-editor.unresolved": "ไม่มีขั้นตอนที่ใช้ร่วมกันแล้ว", + "app.manual.step-editor.action-placeholder": "ผู้ทดสอบควรทำอะไร?", + "app.manual.step-editor.expected-placeholder": "ควรเกิดอะไรขึ้น?", + "app.manual.step-editor.data-placeholder": "ข้อมูลทดสอบ (ไม่บังคับ)", + "app.manual.step-editor.add-step": "เพิ่มขั้นตอน", + "app.manual.step-editor.add-shared-step": "เพิ่มขั้นตอนที่ใช้ร่วมกัน", + "app.manual.step-editor.move-up": "เลื่อนขั้นตอนขึ้น", + "app.manual.step-editor.move-down": "เลื่อนขั้นตอนลง", + "app.manual.step-editor.remove": "ลบขั้นตอน", + "app.manual.step-editor.empty": "ยังไม่มีขั้นตอน เพิ่มขั้นตอนแรกด้านล่าง", + "app.manual.custom-field.archived": "(เก็บถาวร)", + "page.manual-test-cases": "เทสต์เคสแบบแมนนวล", + "page.manual-test-cases.create": "เทสต์เคสใหม่", + "page.manual-test-cases.new-case-title": "เทสต์เคสไม่มีชื่อ", + "page.manual-test-cases.loading": "กำลังโหลดเทสต์เคส...", + "page.manual-test-cases.empty": "ไม่มีเทสต์เคสที่ตรงกับตัวกรองนี้", + "page.manual-test-cases.no-team": "เลือกทีมเพื่อดูเทสต์เคสแบบแมนนวล", + "page.manual-test-cases.filters.search": "ค้นหาชื่อหรือคำอธิบาย", + "page.manual-test-cases.filters.status": "สถานะ", + "page.manual-test-cases.filters.priority": "ความสำคัญ", + "page.manual-test-cases.table.title": "ชื่อ", + "page.manual-test-cases.table.status": "สถานะ", + "page.manual-test-cases.table.priority": "ความสำคัญ", + "page.manual-test-cases.table.version": "เวอร์ชัน", + "page.manual-test-cases.table.updated-by": "อัปเดตโดย", + "page.manual-test-cases.table.steps": "ขั้นตอน", + "page.manual-test-cases.toast.fetch-error": "ไม่สามารถโหลดเทสต์เคสได้", + "page.manual-test-cases.toast.create-error": "ไม่สามารถสร้างเทสต์เคสได้", + "page.manual-test-case-detail.loading": "กำลังโหลดเทสต์เคส...", + "page.manual-test-case-detail.save": "บันทึก", + "page.manual-test-case-detail.clone": "โคลน", + "page.manual-test-case-detail.delete": "ลบ", + "page.manual-test-case-detail.tab.details": "รายละเอียด", + "page.manual-test-case-detail.tab.steps": "ขั้นตอน", + "page.manual-test-case-detail.tab.versions": "เวอร์ชัน", + "page.manual-test-case-detail.tab.history": "ประวัติ", + "page.manual-test-case-detail.field.title": "ชื่อ", + "page.manual-test-case-detail.field.description": "คำอธิบาย", + "page.manual-test-case-detail.field.preconditions": "เงื่อนไขก่อนทดสอบ", + "page.manual-test-case-detail.field.status": "สถานะ", + "page.manual-test-case-detail.field.priority": "ความสำคัญ", + "page.manual-test-case-detail.field.tags": "แท็ก", + "page.manual-test-case-detail.status-help": "ฟิลด์ที่กำหนดเองซึ่งจำเป็นจะบังคับใช้เมื่อเคสออกจากสถานะฉบับร่างแล้วเท่านั้น", + "page.manual-test-case-detail.custom-fields": "ฟิลด์ที่กำหนดเอง", + "page.manual-test-case-detail.select-version": "เลือกเวอร์ชัน", + "page.manual-test-case-detail.version-option": "เวอร์ชัน {version}", + "page.manual-test-case-detail.versions-help": "เลือกเวอร์ชันเพื่อดูสิ่งที่ผู้ทดสอบเห็นตอนรัน", + "page.manual-test-case-detail.delete-confirm-title": "ลบเทสต์เคส", + "page.manual-test-case-detail.delete-confirm-message": "การดำเนินการนี้จะลบเทสต์เคสและประวัติเวอร์ชันทั้งหมด และไม่สามารถย้อนกลับได้", + "page.manual-test-case-detail.toast.saved": "บันทึกเทสต์เคสแล้ว", + "page.manual-test-case-detail.toast.fetch-error": "ไม่สามารถโหลดเทสต์เคสได้", + "page.manual-test-case-detail.toast.save-error": "ไม่สามารถบันทึกเทสต์เคสได้", + "page.manual-test-case-detail.toast.clone-error": "ไม่สามารถโคลนเทสต์เคสได้", + "page.manual-test-case-detail.toast.delete-error": "ไม่สามารถลบเทสต์เคสได้", + "page.manual-test-case-detail.toast.version-error": "ไม่สามารถโหลดเวอร์ชันนั้นได้", + "page.manual-test-case-detail.toast.history-error": "ไม่สามารถโหลดประวัติการเปลี่ยนแปลงได้" } From ee2c2d2305e4503eadcc1b003e7a0ae7c598fd5d Mon Sep 17 00:00:00 2001 From: Sergio Neves Barros Date: Sun, 23 Aug 2026 17:10:22 +0100 Subject: [PATCH 02/37] feat(manual): add the automated/manual toggle to the dashboard and metrics Completes the filtering half of the manual test case feature: manual runs already appeared in the dashboards, but nothing could narrow to one type. - Shared ALL_EXECUTION_TYPES sentinel, toExecutionTypeParam and getExecutionTypeOptions in GeneralUtilities, so both pages share one definition rather than duplicating the mapping - Execution-type SelectPicker on DashboardPage and MetricsPage - 5 translation keys across en/cn/nl/th The picker needs a concrete value for "no filter" but the API takes the absence of the parameter to mean both, so the sentinel is mapped back to undefined before the request. Defaulting to All keeps exactly the pre-3.0 behaviour. Also moves off the npm-linked client onto the published 3.0.1. Co-Authored-By: Claude Opus 5 --- package-lock.json | 14 +++++----- package.json | 2 +- .../pages/dashboard/DashboardPage.js | 28 +++++++++++++++++-- src/components/pages/metrics/MetricsPage.js | 25 ++++++++++++++--- src/translations/cn.json | 7 ++++- src/translations/en.json | 7 ++++- src/translations/nl.json | 7 ++++- src/translations/th.json | 7 ++++- src/utility/GeneralUtilities.js | 19 ++++++++++++- 9 files changed, 97 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5dbd856..f886a60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@rsuite/icons": "^1.0.2", "@types/react": "^18.0.28", - "angles-javascript-client": "^1.0.43", + "angles-javascript-client": "^3.0.1", "apexcharts": "^6.8.0", "axios": "^1.13.3", "base-64": "^1.0.0", @@ -2137,9 +2137,9 @@ "license": "MIT" }, "node_modules/angles-javascript-client": { - "version": "1.0.43", - "resolved": "https://registry.npmjs.org/angles-javascript-client/-/angles-javascript-client-1.0.43.tgz", - "integrity": "sha512-0y+xrDzaM29f/1ifzvjawIcnw3gW+5y0g7dH+GLFomN/uuVCDRjUVPkWXd+1pYNdlH6bqSmcE2QrrDGxHCvU1A==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/angles-javascript-client/-/angles-javascript-client-3.0.1.tgz", + "integrity": "sha512-NnRTwg0hxTEXOSamyD16L0qIkXVCgg0R19Y8QIvx4wAyRlAlPTGoS5t+Ib9nMR5+HLeQP4r1ApYSCMIxDwZNTA==", "license": "Apache-2.0", "dependencies": { "axios": "^1.13.2", @@ -9523,9 +9523,9 @@ } }, "angles-javascript-client": { - "version": "1.0.43", - "resolved": "https://registry.npmjs.org/angles-javascript-client/-/angles-javascript-client-1.0.43.tgz", - "integrity": "sha512-0y+xrDzaM29f/1ifzvjawIcnw3gW+5y0g7dH+GLFomN/uuVCDRjUVPkWXd+1pYNdlH6bqSmcE2QrrDGxHCvU1A==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/angles-javascript-client/-/angles-javascript-client-3.0.1.tgz", + "integrity": "sha512-NnRTwg0hxTEXOSamyD16L0qIkXVCgg0R19Y8QIvx4wAyRlAlPTGoS5t+Ib9nMR5+HLeQP4r1ApYSCMIxDwZNTA==", "requires": { "axios": "^1.13.2", "form-data": "^4.0.0", diff --git a/package.json b/package.json index 9253cf0..bf1aa5f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dependencies": { "@rsuite/icons": "^1.0.2", "@types/react": "^18.0.28", - "angles-javascript-client": "^1.0.43", + "angles-javascript-client": "^3.0.1", "apexcharts": "^6.8.0", "axios": "^1.13.3", "base-64": "^1.0.0", diff --git a/src/components/pages/dashboard/DashboardPage.js b/src/components/pages/dashboard/DashboardPage.js index c8cdf6c..fe415b3 100644 --- a/src/components/pages/dashboard/DashboardPage.js +++ b/src/components/pages/dashboard/DashboardPage.js @@ -43,6 +43,7 @@ import { getDateRangesPicker, getDurationAsString } from '../../../utility/TimeU import ExecutionBarChart from './charts/ExecutionBarChart'; import BuildExecutionPieChart from './charts/BuildExecutionPieChart'; import ConfirmModal from '../../common/ConfirmModal'; +import { getExecutionTypeOptions, toExecutionTypeParam } from '../../../utility/GeneralUtilities'; const generateFilterMenuData = function (environments, components) { const data = []; @@ -139,6 +140,8 @@ const DashboardPage = function (props) { const [endDate, setEndDate] = useState(queryEndDate ? moment(queryEndDate) : moment()); const { afterToday } = DateRangePicker; const [selectedTeamId, setSelectedTeamId] = useState(undefined); + // undefined = both types, which is what every pre-3.0 dashboard showed. + const [executionType, setExecutionType] = useState(undefined); // filtering values const [selectedBuilds, setSelectedBuilds] = useState({}); @@ -154,6 +157,7 @@ const DashboardPage = function (props) { const limitValues = [10, 15, 25, 50].map( (item) => ({ label: item, value: item }), ); + const executionTypeValues = getExecutionTypeOptions(intl); const addIndexToBuilds = (buildsToIndex, skip) => { buildsToIndex.forEach((build, index) => { build.index = index + skip + 1; @@ -174,6 +178,7 @@ const DashboardPage = function (props) { limit, startDate, endDate, + executionType, ) .then(({ builds: retrievedBuilds, @@ -198,7 +203,7 @@ const DashboardPage = function (props) { ); } }, [currentTeam, limit, filteredEnvironments, - filteredComponents, startDate, endDate, activePage, searchParams]); + filteredComponents, startDate, endDate, activePage, searchParams, executionType]); useEffect(() => { if (currentTeam) { @@ -217,7 +222,7 @@ const DashboardPage = function (props) { useEffect(() => { setActivePage(1); }, [filteredEnvironments, filteredComponents, currentTeam, - startDate, endDate, limit]); + startDate, endDate, limit, executionType]); const toggleSelectedBuild = (build) => { const updatedBuilds = update( @@ -307,6 +312,11 @@ const DashboardPage = function (props) { } }; + const handleExecutionTypeChange = (value) => { + setExecutionType(toExecutionTypeParam(value)); + setActivePage(1); + }; + // eslint-disable-next-line no-shadow const renderIconButton = (props, ref) => ( // eslint-disable-next-line react/jsx-props-no-spreading @@ -463,6 +473,20 @@ const DashboardPage = function (props) { searchable={false} onChange={handleLimitChange} /> + + )} + data={executionTypeValues} + appearance="default" + cleanable={false} + style={{ width: 200 }} + value={executionType === undefined ? ALL_EXECUTION_TYPES : executionType} + searchable={false} + onChange={handleExecutionTypeChange} + /> { + const getMetrics = (teamId, componentId, fromDate, toDate, groupingId, executionTypeId) => { if (metrics && Object.keys(metrics).length > 0) { setMetrics(undefined); } - metricRequests.getPhaseMetrics(teamId, componentId, fromDate, toDate, groupingId) + metricRequests + .getPhaseMetrics(teamId, componentId, fromDate, toDate, groupingId, executionTypeId) .then((returnedMetrics) => { setMetrics(returnedMetrics); setPlatformColors(getPlatformArrayColors(returnedMetrics)); @@ -103,9 +107,11 @@ const MetricsPage = function (props) { const retrieveMetrics = () => { if (endDate && startDate && selectedTeam) { if (selectedComponent === 'any') { - getMetrics(selectedTeam, undefined, startDate, endDate, groupingPeriod); + getMetrics(selectedTeam, undefined, startDate, endDate, groupingPeriod, executionType); } else { - getMetrics(selectedTeam, selectedComponent, startDate, endDate, groupingPeriod); + getMetrics( + selectedTeam, selectedComponent, startDate, endDate, groupingPeriod, executionType, + ); } } }; @@ -251,6 +257,17 @@ const MetricsPage = function (props) { } }} /> + } + cleanable={false} + searchable={false} + appearance="subtle" + data={getExecutionTypeOptions(intl)} + value={executionType === undefined ? ALL_EXECUTION_TYPES : executionType} + onChange={(value) => { + setExecutionType(toExecutionTypeParam(value)); + }} + /> diff --git a/src/translations/cn.json b/src/translations/cn.json index 5079e41..816a3ce 100644 --- a/src/translations/cn.json +++ b/src/translations/cn.json @@ -595,5 +595,10 @@ "page.manual-test-case-detail.toast.clone-error": "无法克隆测试用例。", "page.manual-test-case-detail.toast.delete-error": "无法删除测试用例。", "page.manual-test-case-detail.toast.version-error": "无法加载该版本。", - "page.manual-test-case-detail.toast.history-error": "无法加载变更历史。" + "page.manual-test-case-detail.toast.history-error": "无法加载变更历史。", + "app.execution-type.all": "全部", + "app.execution-type.automated": "自动化", + "app.execution-type.manual": "手动", + "page.dashboard.filters.label.execution-type": "类型", + "page.metrics.filters.labels.execution-type": "类型" } diff --git a/src/translations/en.json b/src/translations/en.json index c68f41f..b9d1e7a 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -595,5 +595,10 @@ "page.manual-test-case-detail.toast.clone-error": "Could not clone the test case.", "page.manual-test-case-detail.toast.delete-error": "Could not delete the test case.", "page.manual-test-case-detail.toast.version-error": "Could not load that version.", - "page.manual-test-case-detail.toast.history-error": "Could not load the change history." + "page.manual-test-case-detail.toast.history-error": "Could not load the change history.", + "app.execution-type.all": "All", + "app.execution-type.automated": "Automated", + "app.execution-type.manual": "Manual", + "page.dashboard.filters.label.execution-type": "Type", + "page.metrics.filters.labels.execution-type": "Type" } diff --git a/src/translations/nl.json b/src/translations/nl.json index 2ef8743..340f452 100644 --- a/src/translations/nl.json +++ b/src/translations/nl.json @@ -595,5 +595,10 @@ "page.manual-test-case-detail.toast.clone-error": "Kon het testgeval niet klonen.", "page.manual-test-case-detail.toast.delete-error": "Kon het testgeval niet verwijderen.", "page.manual-test-case-detail.toast.version-error": "Kon die versie niet laden.", - "page.manual-test-case-detail.toast.history-error": "Kon de geschiedenis niet laden." + "page.manual-test-case-detail.toast.history-error": "Kon de geschiedenis niet laden.", + "app.execution-type.all": "Alle", + "app.execution-type.automated": "Geautomatiseerd", + "app.execution-type.manual": "Handmatig", + "page.dashboard.filters.label.execution-type": "Type", + "page.metrics.filters.labels.execution-type": "Type" } diff --git a/src/translations/th.json b/src/translations/th.json index c2a0b53..572abdd 100644 --- a/src/translations/th.json +++ b/src/translations/th.json @@ -595,5 +595,10 @@ "page.manual-test-case-detail.toast.clone-error": "ไม่สามารถโคลนเทสต์เคสได้", "page.manual-test-case-detail.toast.delete-error": "ไม่สามารถลบเทสต์เคสได้", "page.manual-test-case-detail.toast.version-error": "ไม่สามารถโหลดเวอร์ชันนั้นได้", - "page.manual-test-case-detail.toast.history-error": "ไม่สามารถโหลดประวัติการเปลี่ยนแปลงได้" + "page.manual-test-case-detail.toast.history-error": "ไม่สามารถโหลดประวัติการเปลี่ยนแปลงได้", + "app.execution-type.all": "ทั้งหมด", + "app.execution-type.automated": "อัตโนมัติ", + "app.execution-type.manual": "ด้วยตนเอง", + "page.dashboard.filters.label.execution-type": "ประเภท", + "page.metrics.filters.labels.execution-type": "ประเภท" } diff --git a/src/utility/GeneralUtilities.js b/src/utility/GeneralUtilities.js index a839c1e..687f0d0 100644 --- a/src/utility/GeneralUtilities.js +++ b/src/utility/GeneralUtilities.js @@ -1,6 +1,23 @@ import { useState } from 'react'; +import { ExecutionTypes } from 'angles-javascript-client'; + +// The execution-type pickers need a concrete value for "no filter", but the API takes the +// absence of the parameter to mean both. This sentinel is mapped back to undefined before +// the request is made, so omitting the filter keeps the pre-3.0 behaviour. +export const ALL_EXECUTION_TYPES = 'all'; + +export const toExecutionTypeParam = (value) => ( + value === ALL_EXECUTION_TYPES ? undefined : value +); + +// Options for an execution-type SelectPicker. Takes intl rather than importing it so the +// caller's provider is used. +export const getExecutionTypeOptions = (intl) => ([ + { label: intl.formatMessage({ id: 'app.execution-type.all' }), value: ALL_EXECUTION_TYPES }, + { label: intl.formatMessage({ id: 'app.execution-type.automated' }), value: ExecutionTypes.AUTOMATED }, + { label: intl.formatMessage({ id: 'app.execution-type.manual' }), value: ExecutionTypes.MANUAL }, +]); -// eslint-disable-next-line import/prefer-default-export export const useConstructor = (callBack = () => {}) => { const [hasBeenCalled, setHasBeenCalled] = useState(false); if (hasBeenCalled) return; From 3ba12ed22114baebd33e473cc1b1827e64c8d102 Mon Sep 17 00:00:00 2001 From: Sergio Neves Barros Date: Sun, 23 Aug 2026 17:20:46 +0100 Subject: [PATCH 03/37] feat(manual): add the remaining manual test management screens Completes phase 8: the five outstanding routes and the two shared components the plan called for. Routes - shared-steps: library, editor, usage drawer and history drawer - manual-test-runs: run list with status filter and a creation wizard - manual-test-runs/[runId]: the execution view - manual-test-cases/[caseId]/version/[version]: frozen version plus diff - admin/custom-fields: field configuration, beside admin/settings Components - attachment-upload: drop zone, thumbnails, markdown reference copy - version-diff: field, custom field and positional step comparison The execution view renders the bound version throughout, never the live case. The version badge shows when that version has fallen behind, linking to the frozen view; rebind is offered only for an entry with no result yet, since the API rejects re-pointing a recorded execution. Editing a shared step re-versions every case that includes it, so the editor looks up usage first and warns with the count before the author commits. A case result defaults to the worst step outcome rather than making the tester state it twice, and stays overridable. Nav groups the three manual screens under a "Manual testing" submenu now that there is more than one, and custom fields joins the admin menu. 154 translation keys across en/cn/nl/th (602 -> 756 each). Co-Authored-By: Claude Opus 5 --- src/app/admin/custom-fields/page.js | 8 + .../[caseId]/version/[version]/page.js | 10 + src/app/manual-test-runs/[runId]/page.js | 10 + src/app/manual-test-runs/page.js | 8 + src/app/shared-steps/page.js | 8 + .../attachment-upload/AttachmentUpload.js | 138 +++++++ .../features/attachment-upload/styles.less | 42 ++ .../features/version-diff/VersionDiff.js | 137 +++++++ .../features/version-diff/styles.less | 54 +++ src/components/layout/Shell.js | 19 +- .../AdminCustomFieldsPage.js | 331 ++++++++++++++++ .../pages/admin-custom-fields/styles.less | 8 + .../ManualTestCaseVersionPage.js | 181 +++++++++ .../manual-test-case-version/styles.less | 6 + .../ManualTestRunDetailPage.js | 369 ++++++++++++++++++ .../manual-test-runs/ManualTestRunsPage.js | 315 +++++++++++++++ .../pages/manual-test-runs/styles.less | 71 ++++ .../pages/shared-steps/SharedStepsPage.js | 353 +++++++++++++++++ src/components/pages/shared-steps/styles.less | 11 + src/styles/index.less | 6 + src/translations/cn.json | 156 +++++++- src/translations/en.json | 156 +++++++- src/translations/nl.json | 156 +++++++- src/translations/th.json | 156 +++++++- 24 files changed, 2698 insertions(+), 11 deletions(-) create mode 100644 src/app/admin/custom-fields/page.js create mode 100644 src/app/manual-test-cases/[caseId]/version/[version]/page.js create mode 100644 src/app/manual-test-runs/[runId]/page.js create mode 100644 src/app/manual-test-runs/page.js create mode 100644 src/app/shared-steps/page.js create mode 100644 src/components/features/attachment-upload/AttachmentUpload.js create mode 100644 src/components/features/attachment-upload/styles.less create mode 100644 src/components/features/version-diff/VersionDiff.js create mode 100644 src/components/features/version-diff/styles.less create mode 100644 src/components/pages/admin-custom-fields/AdminCustomFieldsPage.js create mode 100644 src/components/pages/admin-custom-fields/styles.less create mode 100644 src/components/pages/manual-test-case-version/ManualTestCaseVersionPage.js create mode 100644 src/components/pages/manual-test-case-version/styles.less create mode 100644 src/components/pages/manual-test-runs/ManualTestRunDetailPage.js create mode 100644 src/components/pages/manual-test-runs/ManualTestRunsPage.js create mode 100644 src/components/pages/manual-test-runs/styles.less create mode 100644 src/components/pages/shared-steps/SharedStepsPage.js create mode 100644 src/components/pages/shared-steps/styles.less diff --git a/src/app/admin/custom-fields/page.js b/src/app/admin/custom-fields/page.js new file mode 100644 index 0000000..9c63b9e --- /dev/null +++ b/src/app/admin/custom-fields/page.js @@ -0,0 +1,8 @@ +'use client'; + +import React from 'react'; +import AdminCustomFieldsPage from '../../../components/pages/admin-custom-fields/AdminCustomFieldsPage'; + +export default function Page() { + return ; +} diff --git a/src/app/manual-test-cases/[caseId]/version/[version]/page.js b/src/app/manual-test-cases/[caseId]/version/[version]/page.js new file mode 100644 index 0000000..974a3b9 --- /dev/null +++ b/src/app/manual-test-cases/[caseId]/version/[version]/page.js @@ -0,0 +1,10 @@ +'use client'; + +import React from 'react'; +import { useParams } from 'next/navigation'; +import ManualTestCaseVersionPage from '../../../../../components/pages/manual-test-case-version/ManualTestCaseVersionPage'; + +export default function Page() { + const params = useParams(); + return ; +} diff --git a/src/app/manual-test-runs/[runId]/page.js b/src/app/manual-test-runs/[runId]/page.js new file mode 100644 index 0000000..b4d8216 --- /dev/null +++ b/src/app/manual-test-runs/[runId]/page.js @@ -0,0 +1,10 @@ +'use client'; + +import React from 'react'; +import { useParams } from 'next/navigation'; +import ManualTestRunDetailPage from '../../../components/pages/manual-test-runs/ManualTestRunDetailPage'; + +export default function Page() { + const params = useParams(); + return ; +} diff --git a/src/app/manual-test-runs/page.js b/src/app/manual-test-runs/page.js new file mode 100644 index 0000000..5f1991f --- /dev/null +++ b/src/app/manual-test-runs/page.js @@ -0,0 +1,8 @@ +'use client'; + +import React from 'react'; +import ManualTestRunsPage from '../../components/pages/manual-test-runs/ManualTestRunsPage'; + +export default function Page() { + return ; +} diff --git a/src/app/shared-steps/page.js b/src/app/shared-steps/page.js new file mode 100644 index 0000000..6ef033b --- /dev/null +++ b/src/app/shared-steps/page.js @@ -0,0 +1,8 @@ +'use client'; + +import React from 'react'; +import SharedStepsPage from '../../components/pages/shared-steps/SharedStepsPage'; + +export default function Page() { + return ; +} diff --git a/src/components/features/attachment-upload/AttachmentUpload.js b/src/components/features/attachment-upload/AttachmentUpload.js new file mode 100644 index 0000000..c25112a --- /dev/null +++ b/src/components/features/attachment-upload/AttachmentUpload.js @@ -0,0 +1,138 @@ +import React, { useState } from 'react'; +import axios from 'axios'; +import { Uploader, Loader, Message, useToaster, IconButton, Whisper, Tooltip } from 'rsuite'; +import TrashIcon from '@rsuite/icons/Trash'; +import CopyIcon from '@rsuite/icons/Copy'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { AttachmentRequests } from 'angles-javascript-client'; +import { getApiErrorMessage } from '../../../utility/ApiUtilities'; + +/* + * Drop zone plus thumbnail strip for a test case step, shared step or execution. + * + * `owner` is the { testCaseId | sharedStepId | executionId } pair the API keys storage on. + * Uploads go straight to the server rather than being held until save, because the markdown + * reference a QA pastes into a step has to resolve to a real attachment id immediately. + */ +const AttachmentUpload = ({ owner, attachments, onChange, readOnly }) => { + const intl = useIntl(); + const toaster = useToaster(); + const [uploading, setUploading] = useState(false); + const attachmentRequests = new AttachmentRequests(axios); + + const list = attachments || []; + + const pushError = (error, fallbackId) => { + toaster.push( + + {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))} + , + { placement: 'topEnd' }, + ); + }; + + const handleUpload = async (file) => { + setUploading(true); + try { + // blobFile is the underlying File; RSuite wraps it in its own descriptor. + const uploaded = await attachmentRequests + .uploadAttachment(file.blobFile, owner, file.name); + onChange([...list, uploaded]); + } catch (error) { + pushError(error, 'app.manual.attachments.upload-error'); + } finally { + setUploading(false); + } + }; + + const handleDelete = async (attachment) => { + try { + await attachmentRequests.deleteAttachment(attachment._id); + onChange(list.filter((item) => item._id !== attachment._id)); + } catch (error) { + // A 409 here is expected and meaningful: a frozen version still references the + // image, so it cannot be removed without rewriting history. + pushError(error, 'app.manual.attachments.delete-error'); + } + }; + + const copyReference = (attachment) => { + const reference = `![${attachment.originalName || 'image'}](attachment:${attachment._id})`; + if (navigator.clipboard) { + navigator.clipboard.writeText(reference); + toaster.push( + + + , + { placement: 'topEnd' }, + ); + } + }; + + return ( +
+ {!readOnly && ( + { + const pending = fileList[fileList.length - 1]; + if (pending) handleUpload(pending); + }} + draggable + > +
+ {uploading ? + : } +
+
+ )} + {list.length > 0 && ( +
+ {list.map((attachment) => ( +
+ + {attachment.originalName + +
+ + + + )} + > + } + onClick={() => copyReference(attachment)} + /> + + {!readOnly && ( + } + onClick={() => handleDelete(attachment)} + /> + )} +
+
+ ))} +
+ )} +
+ ); +}; + +export default AttachmentUpload; diff --git a/src/components/features/attachment-upload/styles.less b/src/components/features/attachment-upload/styles.less new file mode 100644 index 0000000..89d1654 --- /dev/null +++ b/src/components/features/attachment-upload/styles.less @@ -0,0 +1,42 @@ +// ── Attachment upload ──────────────────────────────────────────────────────── +// Drop zone plus thumbnail strip, used by the step editor and the execution view. +.attachment-upload { + .attachment-upload-zone { + padding: var(--space-3); + border: 1px dashed var(--border-color); + border-radius: var(--radius-sm); + text-align: center; + color: var(--main-panel-secondary-font-color); + font-size: var(--font-size-sm); + background: var(--sub-panel-background); + } + + .attachment-upload-list { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: var(--space-2); + } + + .attachment-upload-item { + position: relative; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: var(--space-1); + background: var(--sub-panel-background); + + img { + display: block; + max-width: 96px; + max-height: 96px; + border-radius: var(--radius-xs); + } + + .attachment-upload-item-actions { + display: flex; + justify-content: center; + gap: var(--space-1); + margin-top: var(--space-1); + } + } +} diff --git a/src/components/features/version-diff/VersionDiff.js b/src/components/features/version-diff/VersionDiff.js new file mode 100644 index 0000000..db8a6cf --- /dev/null +++ b/src/components/features/version-diff/VersionDiff.js @@ -0,0 +1,137 @@ +import React from 'react'; +import { Tag } from 'rsuite'; +import { FormattedMessage } from 'react-intl'; + +/* + * Side-by-side comparison of two frozen test case versions. + * + * The diff is computed here rather than server-side because both versions are already + * loaded for display - asking the API to diff them would be a third round trip for data + * the page is holding. Steps are compared positionally and reported as one row per + * position, so an inserted step shows every later position as changed; that is honest + * about what the tester would have seen rather than pretending to track step identity + * across an edit. + */ +const FIELDS = ['title', 'description', 'preconditions', 'priority', 'tags']; + +const formatValue = (value) => { + if (value === undefined || value === null || value === '') return null; + if (Array.isArray(value)) return value.join(', '); + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +}; + +const sameValue = (left, right) => formatValue(left) === formatValue(right); + +const VersionDiff = ({ left, right }) => { + if (!left || !right) return null; + + const changedFields = FIELDS.filter((field) => !sameValue(left[field], right[field])); + + const leftSteps = left.steps || []; + const rightSteps = right.steps || []; + const stepCount = Math.max(leftSteps.length, rightSteps.length); + const stepRows = []; + for (let index = 0; index < stepCount; index += 1) { + const leftStep = leftSteps[index]; + const rightStep = rightSteps[index]; + const changed = !leftStep || !rightStep + || !sameValue(leftStep.action, rightStep.action) + || !sameValue(leftStep.expected, rightStep.expected) + || !sameValue(leftStep.data, rightStep.data); + if (changed) stepRows.push({ index, leftStep, rightStep }); + } + + const leftFields = left.customFields || {}; + const rightFields = right.customFields || {}; + const customKeys = Array.from(new Set([ + ...Object.keys(leftFields), ...Object.keys(rightFields), + ])).filter((key) => !sameValue(leftFields[key], rightFields[key])); + + const unchanged = changedFields.length === 0 + && stepRows.length === 0 && customKeys.length === 0; + + if (unchanged) { + return ( +
+ +
+ ); + } + + const renderCell = (value) => { + const formatted = formatValue(value); + if (formatted === null) { + return ( + + + + ); + } + return formatted; + }; + + return ( +
+
+ + → + +
+ + + + + + + + + + + {changedFields.map((field) => ( + + + + + + ))} + {customKeys.map((key) => ( + + + + + + ))} + {stepRows.map(({ index, leftStep, rightStep }) => ( + + + + + + ))} + +
{renderCell(left[field])}{renderCell(right[field])}
{key}{renderCell(leftFields[key])}{renderCell(rightFields[key])}
+ + + {leftStep ? ( + <> +
{renderCell(leftStep.action)}
+
{renderCell(leftStep.expected)}
+ + ) : renderCell(null)} +
+ {rightStep ? ( + <> +
{renderCell(rightStep.action)}
+
{renderCell(rightStep.expected)}
+ + ) : renderCell(null)} +
+
+ ); +}; + +export default VersionDiff; diff --git a/src/components/features/version-diff/styles.less b/src/components/features/version-diff/styles.less new file mode 100644 index 0000000..e5110ad --- /dev/null +++ b/src/components/features/version-diff/styles.less @@ -0,0 +1,54 @@ +// ── Version diff ───────────────────────────────────────────────────────────── +// Side-by-side comparison of two frozen test case versions. +.version-diff { + .version-diff-header { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-3); + } + + .version-diff-arrow { + color: var(--main-panel-secondary-font-color); + } + + .version-diff-table { + width: 100%; + border-collapse: collapse; + + th, + td { + text-align: left; + padding: var(--space-2); + border-bottom: 1px solid var(--border-subtle); + vertical-align: top; + font-size: var(--font-size-sm); + } + + th { + color: var(--main-panel-secondary-font-color); + font-weight: var(--font-weight-medium); + } + } + + // The two sides borrow the fail/pass hues to read as removed/added without + // introducing a diff-specific palette. + .version-diff-before { + color: var(--fail-color); + } + + .version-diff-after { + color: var(--pass-color); + } + + .version-diff-expected { + color: var(--main-panel-secondary-font-color); + font-size: var(--font-size-xs); + margin-top: var(--space-1); + } + + .version-diff-empty { + color: var(--main-panel-secondary-font-color); + font-style: italic; + } +} diff --git a/src/components/layout/Shell.js b/src/components/layout/Shell.js index 61a1eb1..58d9736 100644 --- a/src/components/layout/Shell.js +++ b/src/components/layout/Shell.js @@ -220,13 +220,17 @@ const Shell = function (props) { />
- } href="/manual-test-cases"> - - - - + } title={}> + + + + + + + + + + } href="/screenshot-library"> } title={}> + )} } href="/about"> diff --git a/src/components/pages/admin-custom-fields/AdminCustomFieldsPage.js b/src/components/pages/admin-custom-fields/AdminCustomFieldsPage.js new file mode 100644 index 0000000..901c23e --- /dev/null +++ b/src/components/pages/admin-custom-fields/AdminCustomFieldsPage.js @@ -0,0 +1,331 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; +import { connect } from 'react-redux'; +import { + Table, Button, Loader, Message, useToaster, Modal, Form, Input, SelectPicker, + TagInput, Toggle, InputNumber, Tag, +} from 'rsuite'; +import PlusIcon from '@rsuite/icons/Plus'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { CustomFieldRequests, CustomFieldTypes, CustomFieldScopes } from 'angles-javascript-client'; +import { getApiErrorMessage } from '../../../utility/ApiUtilities'; +import ConfirmModal from '../../common/ConfirmModal'; + +const { Column, HeaderCell, Cell } = Table; + +// Only these two need an options list; the rest take free input. +const OPTION_BACKED_TYPES = [CustomFieldTypes.SELECT, CustomFieldTypes.MULTISELECT]; + +const emptyDefinition = { + key: '', label: '', type: CustomFieldTypes.TEXT, options: [], + required: false, appliesTo: CustomFieldScopes.TESTCASE, order: 0, +}; + +const AdminCustomFieldsPage = function (props) { + const { currentTeam } = props; + const intl = useIntl(); + const toaster = useToaster(); + + const [definitions, setDefinitions] = useState([]); + const [loading, setLoading] = useState(false); + const [editing, setEditing] = useState(undefined); + const [saving, setSaving] = useState(false); + const [deleteState, setDeleteState] = useState({ open: false, definition: undefined }); + + const customFieldRequests = new CustomFieldRequests(axios); + + const pushError = (error, fallbackId) => { + toaster.push( + + {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))} + , + { placement: 'topEnd' }, + ); + }; + + const loadDefinitions = useCallback(async () => { + if (!currentTeam || !currentTeam._id) return; + setLoading(true); + try { + // Archived fields are included so an admin can see what is still rendering on + // existing cases; they are visually separated in the table. + const response = await customFieldRequests.getCustomFields(currentTeam._id, true); + setDefinitions(response.customFields || []); + } catch (error) { + pushError(error, 'page.admin-custom-fields.toast.fetch-error'); + } finally { + setLoading(false); + } + }, [currentTeam]); + + useEffect(() => { loadDefinitions(); }, [loadDefinitions]); + + const typeOptions = Object.values(CustomFieldTypes).map((value) => ({ + label: intl.formatMessage({ id: `app.manual.custom-field.type.${value}` }), + value, + })); + const scopeOptions = Object.values(CustomFieldScopes).map((value) => ({ + label: intl.formatMessage({ id: `app.manual.custom-field.scope.${value}` }), + value, + })); + + const handleSave = async () => { + if (!editing) return; + setSaving(true); + try { + const payload = { + key: editing.key, + label: editing.label, + type: editing.type, + options: OPTION_BACKED_TYPES.includes(editing.type) ? editing.options : [], + required: !!editing.required, + appliesTo: editing.appliesTo, + order: Number(editing.order) || 0, + }; + if (editing._id) { + // key is the stable storage key values are held against, so it is not + // editable after creation - changing it would orphan every stored value. + const { key, ...updatable } = payload; + await customFieldRequests.updateCustomField(editing._id, updatable); + } else { + await customFieldRequests.createCustomField({ + ...payload, team: currentTeam._id, + }); + } + setEditing(undefined); + loadDefinitions(); + } catch (error) { + pushError(error, 'page.admin-custom-fields.toast.save-error'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + const { definition } = deleteState; + setDeleteState({ open: false, definition: undefined }); + if (!definition) return; + try { + const response = await customFieldRequests.deleteCustomField(definition._id); + toaster.push( + + + , + { placement: 'topEnd' }, + ); + loadDefinitions(); + } catch (error) { + pushError(error, 'page.admin-custom-fields.toast.delete-error'); + } + }; + + if (!currentTeam || !currentTeam._id) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ + + + +
+
+ +
+ + {loading ? : ( + + + + + {(row) => ( + + {row.label} + {row.archived && ( + + + + )} + + )} + + + + + + + + + + {(row) => } + + + + + + {(row) => ( + + )} + + + + + + {(row) => } + + + + + + {(row) => ( + + + {!row.archived && ( + <> + | + + + )} + + )} + + +
+ )} +
+ + setEditing(undefined)}> + + + + + + + {editing && ( +
+ + + setEditing({ ...editing, label: value })} + /> + + + + setEditing({ ...editing, key: value })} + /> + + + + + + + setEditing({ ...editing, type: value })} + /> + + {OPTION_BACKED_TYPES.includes(editing.type) && ( + + + setEditing({ ...editing, options: value })} + /> + + )} + + + setEditing({ ...editing, appliesTo: value })} + /> + + + + setEditing({ ...editing, order: value })} + /> + + + + setEditing({ ...editing, required: value })} + /> + + + + +
+ )} +
+ + + + +
+ + setDeleteState({ open: false, definition: undefined })} + /> +
+ ); +}; + +const mapStateToProps = (state) => ({ currentTeam: state.teamsReducer.currentTeam }); + +export default connect(mapStateToProps)(AdminCustomFieldsPage); diff --git a/src/components/pages/admin-custom-fields/styles.less b/src/components/pages/admin-custom-fields/styles.less new file mode 100644 index 0000000..7e1a341 --- /dev/null +++ b/src/components/pages/admin-custom-fields/styles.less @@ -0,0 +1,8 @@ +// ── Admin: custom fields ───────────────────────────────────────────────────── +.admin-custom-fields-page { + .admin-custom-fields-archived { + margin-left: var(--space-2); + font-size: var(--font-size-xs); + color: var(--skipped-color); + } +} diff --git a/src/components/pages/manual-test-case-version/ManualTestCaseVersionPage.js b/src/components/pages/manual-test-case-version/ManualTestCaseVersionPage.js new file mode 100644 index 0000000..81231af --- /dev/null +++ b/src/components/pages/manual-test-case-version/ManualTestCaseVersionPage.js @@ -0,0 +1,181 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; +import Link from 'next/link'; +import { Loader, Message, useToaster, SelectPicker, Panel, Tag } from 'rsuite'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { ManualTestCaseRequests } from 'angles-javascript-client'; +import { getApiErrorMessage } from '../../../utility/ApiUtilities'; +import ManualStepEditor from '../../features/manual-step-editor/ManualStepEditor'; +import CustomFieldForm from '../../features/custom-field-form/CustomFieldForm'; +import VersionBadge from '../../features/version-badge/VersionBadge'; +import VersionDiff from '../../features/version-diff/VersionDiff'; + +/* + * Read-only view of one frozen version, with an optional diff against another. + * + * Everything here renders from the version document rather than the live case: that is the + * whole point of the frozen collection, and re-reading the head would defeat it. The step + * editor and custom field form are reused in readOnly mode so a version renders exactly the + * way the case does, including the field labels that were in force at the time. + */ +const ManualTestCaseVersionPage = function ({ caseId, version }) { + const intl = useIntl(); + const toaster = useToaster(); + + const [frozen, setFrozen] = useState(undefined); + const [versions, setVersions] = useState([]); + const [compareTo, setCompareTo] = useState(undefined); + const [compareVersion, setCompareVersion] = useState(undefined); + const [loading, setLoading] = useState(true); + + const manualTestCaseRequests = new ManualTestCaseRequests(axios); + const versionNumber = Number(version); + + const pushError = (error, fallbackId) => { + toaster.push( + + {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))} + , + { placement: 'topEnd' }, + ); + }; + + const load = useCallback(async () => { + setLoading(true); + try { + const [frozenResponse, versionsResponse] = await Promise.all([ + manualTestCaseRequests.getVersion(caseId, versionNumber), + manualTestCaseRequests.getVersions(caseId), + ]); + setFrozen(frozenResponse); + setVersions(versionsResponse.versions || []); + } catch (error) { + pushError(error, 'page.manual-test-case-version.toast.fetch-error'); + } finally { + setLoading(false); + } + }, [caseId, versionNumber]); + + useEffect(() => { load(); }, [load]); + + const handleCompare = async (value) => { + setCompareTo(value); + if (!value) { + setCompareVersion(undefined); + return; + } + try { + const other = await manualTestCaseRequests.getVersion(caseId, value); + setCompareVersion(other); + } catch (error) { + pushError(error, 'page.manual-test-case-version.toast.compare-error'); + setCompareVersion(undefined); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!frozen) { + return ( +
+ +
+ ); + } + + const latestVersion = versions.length > 0 + ? Math.max(...versions.map((entry) => entry.version)) : versionNumber; + + const compareOptions = versions + .filter((entry) => entry.version !== versionNumber) + .map((entry) => ({ + label: intl.formatMessage( + { id: 'page.manual-test-case-detail.version-option' }, + { version: entry.version }, + ), + value: entry.version, + })); + + // The diff always reads oldest-to-newest regardless of which side was picked, so + // "before" and "after" mean what they say. + const [diffLeft, diffRight] = compareVersion && compareVersion.version < frozen.version + ? [compareVersion, frozen] : [frozen, compareVersion]; + + return ( +
+
+ {frozen.title} + + + + +
+ +
+ +
+ +
+
+ } + data={compareOptions} + value={compareTo} + searchable={false} + style={{ width: 260 }} + onChange={handleCompare} + /> +
+ + {compareVersion && ( +
+ +
+ )} +
+ + }> +
+ + {frozen.description || '-'} +
+
+ + {frozen.preconditions || '-'} +
+
+ + + {frozen.priority + ? + : '-'} + +
+
+ + + {(frozen.tags || []).map((tag) => {tag})} + +
+ {}} + disabled + /> +
+ + }> + {}} readOnly /> + +
+ ); +}; + +export default ManualTestCaseVersionPage; diff --git a/src/components/pages/manual-test-case-version/styles.less b/src/components/pages/manual-test-case-version/styles.less new file mode 100644 index 0000000..ac57551 --- /dev/null +++ b/src/components/pages/manual-test-case-version/styles.less @@ -0,0 +1,6 @@ +// ── Manual test case: frozen version view ──────────────────────────────────── +.manual-test-case-version-page { + .detail-row { + margin-bottom: var(--space-2); + } +} diff --git a/src/components/pages/manual-test-runs/ManualTestRunDetailPage.js b/src/components/pages/manual-test-runs/ManualTestRunDetailPage.js new file mode 100644 index 0000000..d9f527f --- /dev/null +++ b/src/components/pages/manual-test-runs/ManualTestRunDetailPage.js @@ -0,0 +1,369 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; +import Link from 'next/link'; +import { + Loader, Message, useToaster, Panel, Button, Input, SelectPicker, Tag, Nav, ButtonGroup, +} from 'rsuite'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { + ManualTestRunRequests, ManualTestCaseRequests, ManualRunStates, + ManualCaseResultStates, ManualStepResultStates, +} from 'angles-javascript-client'; +import { getApiErrorMessage } from '../../../utility/ApiUtilities'; +import VersionBadge from '../../features/version-badge/VersionBadge'; +import AttachmentUpload from '../../features/attachment-upload/AttachmentUpload'; + +/* + * Execution view: works through one test case at a time, recording a result per step. + * + * Everything a tester sees comes from the *bound version*, never the live case. A case + * edited mid-run does not shift under them - that binding is fixed at run creation, and the + * version badge shows when the bound version has fallen behind the latest, linking to the + * diff so the tester can see what changed without the run changing. + */ +const STEP_STATES = Object.values(ManualStepResultStates); +const CASE_STATES = Object.values(ManualCaseResultStates) + .filter((state) => state !== 'NOT_RUN' && state !== 'IN_PROGRESS'); + +const ManualTestRunDetailPage = function ({ runId }) { + const intl = useIntl(); + const toaster = useToaster(); + + const [run, setRun] = useState(undefined); + const [loading, setLoading] = useState(true); + const [activeCaseId, setActiveCaseId] = useState(undefined); + const [frozen, setFrozen] = useState(undefined); + const [frozenLoading, setFrozenLoading] = useState(false); + const [latestVersion, setLatestVersion] = useState(undefined); + const [stepResults, setStepResults] = useState([]); + const [caseStatus, setCaseStatus] = useState(undefined); + const [notes, setNotes] = useState(''); + const [saving, setSaving] = useState(false); + + const manualTestRunRequests = new ManualTestRunRequests(axios); + const manualTestCaseRequests = new ManualTestCaseRequests(axios); + + const pushError = (error, fallbackId) => { + toaster.push( + + {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))} + , + { placement: 'topEnd' }, + ); + }; + + const loadRun = useCallback(async () => { + setLoading(true); + try { + const response = await manualTestRunRequests.getTestRun(runId, true); + setRun(response); + if (!activeCaseId && response.testCases && response.testCases.length > 0) { + setActiveCaseId(response.testCases[0].testCase._id + || response.testCases[0].testCase); + } + } catch (error) { + pushError(error, 'page.manual-test-run-detail.toast.fetch-error'); + } finally { + setLoading(false); + } + }, [runId]); + + useEffect(() => { loadRun(); }, [loadRun]); + + const activeEntry = run && run.testCases + ? run.testCases.find((entry) => (entry.testCase._id || entry.testCase) === activeCaseId) + : undefined; + + // Loads the frozen version this entry is bound to, plus the case's version list purely + // to tell the tester whether they are behind. + useEffect(() => { + const loadFrozen = async () => { + if (!activeEntry) return; + setFrozenLoading(true); + try { + const caseId = activeEntry.testCase._id || activeEntry.testCase; + const [version, versions] = await Promise.all([ + manualTestCaseRequests.getVersion(caseId, activeEntry.versionNumber), + manualTestCaseRequests.getVersions(caseId), + ]); + setFrozen(version); + setLatestVersion((versions.versions || []) + .reduce((max, entry) => Math.max(max, entry.version), 0)); + const existing = activeEntry.stepResults || []; + setStepResults((version.steps || []).map((step, index) => { + const previous = existing.find((result) => result.stepId === step._id) + || existing[index]; + return { + stepId: step._id, + status: previous ? previous.status : ManualStepResultStates.NOT_RUN, + actual: previous ? previous.actual : '', + attachments: previous ? previous.attachments || [] : [], + }; + })); + setCaseStatus(activeEntry.status && activeEntry.status !== 'NOT_RUN' + ? activeEntry.status : undefined); + setNotes(activeEntry.notes || ''); + } catch (error) { + pushError(error, 'page.manual-test-run-detail.toast.version-error'); + setFrozen(undefined); + } finally { + setFrozenLoading(false); + } + }; + loadFrozen(); + }, [activeCaseId, run]); + + const updateStepResult = (index, changes) => { + setStepResults((current) => current.map( + (result, position) => (position === index ? { ...result, ...changes } : result), + )); + }; + + // A case defaults to the worst step outcome, which is what a tester means by "this + // failed" without having to set it twice. They can still override it explicitly. + const derivedCaseStatus = () => { + if (caseStatus) return caseStatus; + if (stepResults.some((result) => result.status === 'FAIL')) return 'FAIL'; + if (stepResults.some((result) => result.status === 'BLOCKED')) return 'BLOCKED'; + if (stepResults.some((result) => result.status === 'SKIPPED')) return 'SKIPPED'; + if (stepResults.length > 0 + && stepResults.every((result) => result.status === 'PASS')) return 'PASS'; + return undefined; + }; + + const handleRecord = async () => { + const status = derivedCaseStatus(); + if (!activeEntry || !status) return; + setSaving(true); + try { + const caseId = activeEntry.testCase._id || activeEntry.testCase; + await manualTestRunRequests.recordResult(runId, caseId, { + status, + notes, + stepResults: stepResults.map((result) => ({ + stepId: result.stepId, + status: result.status, + actual: result.actual, + attachments: (result.attachments || []).map( + (attachment) => attachment._id || attachment, + ), + })), + }); + toaster.push( + + + , + { placement: 'topEnd' }, + ); + loadRun(); + } catch (error) { + pushError(error, 'page.manual-test-run-detail.toast.record-error'); + } finally { + setSaving(false); + } + }; + + const handleRebind = async () => { + if (!activeEntry) return; + try { + const caseId = activeEntry.testCase._id || activeEntry.testCase; + await manualTestRunRequests.rebindTestCase(runId, caseId); + loadRun(); + } catch (error) { + // 409 when the entry already has a result - re-pointing it would attach a + // recorded execution to content it was never run against. + pushError(error, 'page.manual-test-run-detail.toast.rebind-error'); + } + }; + + const handleRunStatus = async (status) => { + try { + await manualTestRunRequests.updateStatus(runId, status); + loadRun(); + } catch (error) { + pushError(error, 'page.manual-test-run-detail.toast.status-error'); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!run) { + return ( +
+ +
+ ); + } + + const stepStateOptions = STEP_STATES.map((value) => ({ + label: intl.formatMessage({ id: `app.manual.step-status.${value.toLowerCase()}` }), + value, + })); + const caseStateOptions = CASE_STATES.map((value) => ({ + label: intl.formatMessage({ id: `app.manual.case-status.${value.toLowerCase()}` }), + value, + })); + const activeCaseIdValue = activeEntry + ? (activeEntry.testCase._id || activeEntry.testCase) : undefined; + const isBehind = latestVersion && activeEntry && latestVersion > activeEntry.versionNumber; + const canRebind = isBehind && activeEntry + && (!activeEntry.status || activeEntry.status === 'NOT_RUN'); + + return ( +
+
+ {run.name} + + + + + {run.status !== ManualRunStates.IN_PROGRESS + && run.status !== ManualRunStates.COMPLETED && ( + + )} + {run.status !== ManualRunStates.COMPLETED && ( + + )} + + {run.build && ( + + + + )} +
+ +
+ + + + +
+ {frozenLoading && } + {!frozenLoading && frozen && activeEntry && ( + +
+ {frozen.title} + + {isBehind && ( + + + + )} + {canRebind && ( + + )} +
+ + {frozen.preconditions && ( +
+
+ +
+
{frozen.preconditions}
+
+ )} + + {(frozen.steps || []).map((step, index) => ( +
+
+ {index + 1} + updateStepResult(index, { status: value })} + /> +
+
{step.action}
+ {step.expected && ( +
+ + {': '} + {step.expected} +
+ )} + updateStepResult(index, { actual: value })} + /> + updateStepResult(index, { attachments })} + readOnly={!activeEntry.execution} + /> +
+ ))} + +
+ } + data={caseStateOptions} + value={derivedCaseStatus()} + searchable={false} + style={{ width: 220 }} + onChange={setCaseStatus} + /> + + +
+
+ )} +
+
+
+ ); +}; + +export default ManualTestRunDetailPage; diff --git a/src/components/pages/manual-test-runs/ManualTestRunsPage.js b/src/components/pages/manual-test-runs/ManualTestRunsPage.js new file mode 100644 index 0000000..efd875d --- /dev/null +++ b/src/components/pages/manual-test-runs/ManualTestRunsPage.js @@ -0,0 +1,315 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; +import { connect } from 'react-redux'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { + Table, Button, Loader, Message, useToaster, Modal, Form, Input, SelectPicker, + CheckPicker, Tag, Pagination, +} from 'rsuite'; +import PlusIcon from '@rsuite/icons/Plus'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { + ManualTestRunRequests, ManualTestCaseRequests, ManualRunStates, +} from 'angles-javascript-client'; +import { getApiErrorMessage } from '../../../utility/ApiUtilities'; +import ConfirmModal from '../../common/ConfirmModal'; + +const { Column, HeaderCell, Cell } = Table; + +const PAGE_LIMIT = 25; +const RUN_STATES = Object.values(ManualRunStates); + +const ManualTestRunsPage = function (props) { + const { currentTeam, environments } = props; + const intl = useIntl(); + const toaster = useToaster(); + const router = useRouter(); + + const [runs, setRuns] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [statusFilter, setStatusFilter] = useState([]); + const [creating, setCreating] = useState(undefined); + const [saving, setSaving] = useState(false); + const [availableCases, setAvailableCases] = useState([]); + const [deleteState, setDeleteState] = useState({ open: false, run: undefined }); + + const manualTestRunRequests = new ManualTestRunRequests(axios); + const manualTestCaseRequests = new ManualTestCaseRequests(axios); + + const pushError = (error, fallbackId) => { + toaster.push( + + {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))} + , + { placement: 'topEnd' }, + ); + }; + + const loadRuns = useCallback(async () => { + if (!currentTeam || !currentTeam._id) return; + setLoading(true); + try { + const response = await manualTestRunRequests.getTestRuns( + currentTeam._id, + statusFilter.length > 0 ? statusFilter : undefined, + undefined, + PAGE_LIMIT, + (page - 1) * PAGE_LIMIT, + ); + setRuns(response.testRuns || []); + setTotal(response.metrics ? response.metrics.totalTestRuns : 0); + } catch (error) { + pushError(error, 'page.manual-test-runs.toast.fetch-error'); + } finally { + setLoading(false); + } + }, [currentTeam, statusFilter, page]); + + useEffect(() => { loadRuns(); }, [loadRuns]); + useEffect(() => { setPage(1); }, [statusFilter, currentTeam]); + + // Only ACTIVE cases can go into a run - the API rejects DEPRECATED ones, so offering + // them would produce an error the QA could not act on. + const openWizard = async () => { + setCreating({ name: '', environment: undefined, component: undefined, testCaseIds: [] }); + try { + const response = await manualTestCaseRequests.getTestCases(currentTeam._id, { + status: ['ACTIVE'], limit: 200, + }); + setAvailableCases(response.testCases || []); + } catch (error) { + pushError(error, 'page.manual-test-runs.toast.cases-error'); + setAvailableCases([]); + } + }; + + const handleCreate = async () => { + if (!creating) return; + setSaving(true); + try { + const created = await manualTestRunRequests.createTestRun({ + name: creating.name, + team: currentTeam._id, + environment: creating.environment, + component: creating.component, + testCaseIds: creating.testCaseIds, + }); + setCreating(undefined); + router.push(`/manual-test-runs/${created._id}`); + } catch (error) { + pushError(error, 'page.manual-test-runs.toast.create-error'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + const { run } = deleteState; + setDeleteState({ open: false, run: undefined }); + if (!run) return; + try { + await manualTestRunRequests.deleteTestRun(run._id); + loadRuns(); + } catch (error) { + pushError(error, 'page.manual-test-runs.toast.delete-error'); + } + }; + + if (!currentTeam || !currentTeam._id) { + return ( +
+ +
+ ); + } + + const statusOptions = RUN_STATES.map((value) => ({ + label: intl.formatMessage({ id: `app.manual.run-status.${value.toLowerCase()}` }), + value, + })); + const environmentOptions = (environments || []) + .map((environment) => ({ label: environment.name, value: environment.name })); + const componentOptions = (currentTeam.components || []) + .map((component) => ({ label: component.name, value: component._id })); + const caseOptions = availableCases + .map((testCase) => ({ label: testCase.title, value: testCase._id })); + + const progressOf = (run) => { + const cases = run.testCases || []; + const done = cases.filter((entry) => entry.status && entry.status !== 'NOT_RUN' + && entry.status !== 'IN_PROGRESS').length; + return `${done}/${cases.length}`; + }; + + return ( +
+
+
+ + + + +
+ +
+ } + data={statusOptions} + value={statusFilter} + searchable={false} + style={{ width: 260 }} + onChange={setStatusFilter} + /> +
+ + {loading ? : ( + <> + + + + + {(row) => ( + {row.name} + )} + + + + + + {(row) => ( + + + + )} + + + + + {(row) => progressOf(row)} + + + + + {(row) => (row.environment ? row.environment.name : '-')} + + + + + + {(row) => ( + + )} + + +
+ {total > PAGE_LIMIT && ( + + )} + + )} +
+ + setCreating(undefined)}> + + + + + {creating && ( +
+ + + setCreating({ ...creating, name: value })} + /> + + + + setCreating({ ...creating, environment: value })} + /> + + + + setCreating({ ...creating, component: value })} + /> + + + + + + + setCreating({ ...creating, testCaseIds: value })} + /> + + + + +
+ )} +
+ + + + +
+ + setDeleteState({ open: false, run: undefined })} + /> +
+ ); +}; + +const mapStateToProps = (state) => ({ + currentTeam: state.teamsReducer.currentTeam, + environments: state.environmentsReducer.environments, +}); + +export default connect(mapStateToProps)(ManualTestRunsPage); diff --git a/src/components/pages/manual-test-runs/styles.less b/src/components/pages/manual-test-runs/styles.less new file mode 100644 index 0000000..9b0bfcb --- /dev/null +++ b/src/components/pages/manual-test-runs/styles.less @@ -0,0 +1,71 @@ +// ── Manual test runs ───────────────────────────────────────────────────────── +.manual-test-runs-page { + .rs-tag { + font-size: var(--font-size-xs); + } +} + +// Execution view: case list on the left, the bound version's steps on the right. +.manual-test-run-detail-page { + .manual-test-run-detail-body { + display: flex; + align-items: flex-start; + gap: var(--space-4); + } + + .manual-test-run-case-list { + flex: 0 0 260px; + + .manual-test-run-case-title { + margin-left: var(--space-2); + } + } + + .manual-test-run-execution { + flex: 1 1 auto; + min-width: 0; + } + + .manual-test-run-step { + padding: var(--space-3); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + margin-bottom: var(--space-3); + background: var(--sub-panel-background); + } + + .manual-test-run-step-header { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-2); + } + + .manual-test-run-step-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--radius-pill); + background: var(--main-panel-background); + font-size: var(--font-size-xs); + } + + .manual-test-run-step-action { + margin-bottom: var(--space-1); + } + + .manual-test-run-step-expected { + color: var(--main-panel-secondary-font-color); + font-size: var(--font-size-sm); + margin-bottom: var(--space-2); + } + + .manual-test-run-outcome { + display: flex; + flex-direction: column; + gap: var(--space-2); + align-items: flex-start; + } +} diff --git a/src/components/pages/shared-steps/SharedStepsPage.js b/src/components/pages/shared-steps/SharedStepsPage.js new file mode 100644 index 0000000..4d9cc10 --- /dev/null +++ b/src/components/pages/shared-steps/SharedStepsPage.js @@ -0,0 +1,353 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; +import { connect } from 'react-redux'; +import { + Table, Input, InputGroup, Button, Loader, Message, useToaster, Modal, Form, Drawer, Tag, +} from 'rsuite'; +import SearchIcon from '@rsuite/icons/Search'; +import PlusIcon from '@rsuite/icons/Plus'; +import { FormattedMessage, useIntl } from 'react-intl'; +import Link from 'next/link'; +import { SharedStepRequests } from 'angles-javascript-client'; +import { getApiErrorMessage } from '../../../utility/ApiUtilities'; +import ManualStepEditor from '../../features/manual-step-editor/ManualStepEditor'; +import ChangeHistory from '../../features/change-history/ChangeHistory'; +import ConfirmModal from '../../common/ConfirmModal'; + +const { Column, HeaderCell, Cell } = Table; + +const SharedStepsPage = function (props) { + const { currentTeam } = props; + const intl = useIntl(); + const toaster = useToaster(); + + const [sharedSteps, setSharedSteps] = useState([]); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(''); + const [editing, setEditing] = useState(undefined); + const [saving, setSaving] = useState(false); + const [usage, setUsage] = useState(undefined); + const [usageLoading, setUsageLoading] = useState(false); + const [history, setHistory] = useState(undefined); + const [historyLoading, setHistoryLoading] = useState(false); + const [deleteState, setDeleteState] = useState({ open: false, sharedStep: undefined }); + const [cascadeWarning, setCascadeWarning] = useState(undefined); + + const sharedStepRequests = new SharedStepRequests(axios); + + const pushError = (error, fallbackId) => { + toaster.push( + + {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))} + , + { placement: 'topEnd' }, + ); + }; + + const loadSharedSteps = useCallback(async () => { + if (!currentTeam || !currentTeam._id) return; + setLoading(true); + try { + const response = await sharedStepRequests + .getSharedSteps(currentTeam._id, search || undefined); + setSharedSteps(response.sharedSteps || []); + } catch (error) { + pushError(error, 'page.shared-steps.toast.fetch-error'); + } finally { + setLoading(false); + } + }, [currentTeam, search]); + + useEffect(() => { loadSharedSteps(); }, [loadSharedSteps]); + + // Editing a shared step re-versions every case that includes it, so the author is told + // how many that is before they commit rather than after. + const openEditor = async (sharedStep) => { + setEditing(sharedStep ? { ...sharedStep } : { name: '', description: '', steps: [] }); + setCascadeWarning(undefined); + if (sharedStep && sharedStep._id) { + try { + const response = await sharedStepRequests.getUsage(sharedStep._id); + const count = response.metrics ? response.metrics.totalTestCases : 0; + if (count > 0) setCascadeWarning(count); + } catch (error) { + // A failed usage lookup must not block editing - the warning is advisory. + setCascadeWarning(undefined); + } + } + }; + + const handleSave = async () => { + if (!editing) return; + setSaving(true); + try { + const payload = { + name: editing.name, + description: editing.description, + steps: (editing.steps || []).map((step, index) => ({ + order: index + 1, + action: step.action, + expected: step.expected, + data: step.data, + })), + }; + if (editing._id) { + const response = await sharedStepRequests.updateSharedStep(editing._id, payload); + const cascaded = response && response.cascade + ? response.cascade.testCasesVersioned : 0; + if (cascaded > 0) { + toaster.push( + + + , + { placement: 'topEnd' }, + ); + } + } else { + await sharedStepRequests.createSharedStep({ ...payload, team: currentTeam._id }); + } + setEditing(undefined); + loadSharedSteps(); + } catch (error) { + pushError(error, 'page.shared-steps.toast.save-error'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + const { sharedStep } = deleteState; + setDeleteState({ open: false, sharedStep: undefined }); + if (!sharedStep) return; + try { + await sharedStepRequests.deleteSharedStep(sharedStep._id); + loadSharedSteps(); + } catch (error) { + // 409 when the step is still referenced - the message from the API names the count. + pushError(error, 'page.shared-steps.toast.delete-error'); + } + }; + + const showUsage = async (sharedStep) => { + setUsage({ sharedStep, testCases: [] }); + setUsageLoading(true); + try { + const response = await sharedStepRequests.getUsage(sharedStep._id); + setUsage({ sharedStep, testCases: response.testCases || [] }); + } catch (error) { + pushError(error, 'page.shared-steps.toast.usage-error'); + setUsage(undefined); + } finally { + setUsageLoading(false); + } + }; + + const showHistory = async (sharedStep) => { + setHistory({ sharedStep, entries: [] }); + setHistoryLoading(true); + try { + const response = await sharedStepRequests.getHistory(sharedStep._id); + setHistory({ sharedStep, entries: response.history || [] }); + } catch (error) { + pushError(error, 'page.shared-steps.toast.history-error'); + setHistory(undefined); + } finally { + setHistoryLoading(false); + } + }; + + if (!currentTeam || !currentTeam._id) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ + + + +
+
+ +
+ +
+ + + + +
+ + {loading ? : ( + + + + + + + + + + + + {(row) => ((row.steps || []).length)} + + + + {(row) => {`v${row.version}`}} + + + + + {(row) => ( + + + | + + | + + | + + + )} + + +
+ )} +
+ + setEditing(undefined)}> + + + + + + + {editing && ( + <> + {cascadeWarning > 0 && ( +
+ +
+ )} +
+ + + setEditing({ ...editing, name: value })} + /> + + + + setEditing({ ...editing, description: value })} + /> + +
+ setEditing({ ...editing, steps })} + /> + + )} +
+ + + + +
+ + setUsage(undefined)}> + + + + + + + {usageLoading && } + {usage && !usageLoading && usage.testCases.length === 0 && ( +
+ +
+ )} + {usage && !usageLoading && usage.testCases.map((testCase) => ( +
+ {testCase.title} + {`v${testCase.version}`} +
+ ))} +
+
+ + setHistory(undefined)}> + + + + + + + + + + + setDeleteState({ open: false, sharedStep: undefined })} + /> +
+ ); +}; + +const mapStateToProps = (state) => ({ currentTeam: state.teamsReducer.currentTeam }); + +export default connect(mapStateToProps)(SharedStepsPage); diff --git a/src/components/pages/shared-steps/styles.less b/src/components/pages/shared-steps/styles.less new file mode 100644 index 0000000..a969254 --- /dev/null +++ b/src/components/pages/shared-steps/styles.less @@ -0,0 +1,11 @@ +// ── Shared steps ───────────────────────────────────────────────────────────── +.shared-steps-page { + .shared-steps-usage-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2) 0; + border-bottom: 1px solid var(--border-subtle); + } +} diff --git a/src/styles/index.less b/src/styles/index.less index 8e17e89..72818cc 100644 --- a/src/styles/index.less +++ b/src/styles/index.less @@ -33,6 +33,10 @@ @import '../components/pages/dashboard/styles.less'; @import '../components/pages/login/styles.less'; @import '../components/pages/manual-test-cases/styles.less'; +@import '../components/pages/manual-test-case-version/styles.less'; +@import '../components/pages/manual-test-runs/styles.less'; +@import '../components/pages/shared-steps/styles.less'; +@import '../components/pages/admin-custom-fields/styles.less'; @import '../components/pages/metrics/styles.less'; @import '../components/pages/screenshot-library/styles.less'; @import '../components/pages/team-settings/styles.less'; @@ -41,6 +45,7 @@ @import '../components/pages/user-settings/styles.less'; // ── Co-located feature styles (components/features/*) ──────────────────────── +@import '../components/features/attachment-upload/styles.less'; @import '../components/features/change-history/styles.less'; @import '../components/features/custom-field-form/styles.less'; @import '../components/features/execution-timeline/styles.less'; @@ -48,4 +53,5 @@ @import '../components/features/results-bar/styles.less'; @import '../components/features/screenshot-view/styles.less'; @import '../components/features/version-badge/styles.less'; +@import '../components/features/version-diff/styles.less'; @import '../components/features/test-suite/styles.less'; diff --git a/src/translations/cn.json b/src/translations/cn.json index 816a3ce..af69734 100644 --- a/src/translations/cn.json +++ b/src/translations/cn.json @@ -600,5 +600,159 @@ "app.execution-type.automated": "自动化", "app.execution-type.manual": "手动", "page.dashboard.filters.label.execution-type": "类型", - "page.metrics.filters.labels.execution-type": "类型" + "page.metrics.filters.labels.execution-type": "类型", + "app.manual.save": "保存", + "app.manual.delete": "删除", + "app.manual.edit": "编辑", + "app.manual.actions": "操作", + "app.manual.loading": "加载中...", + "app.manual.yes": "是", + "app.manual.no": "否", + "app.manual.no-team": "请选择一个团队以继续。", + "app.manual.field.description": "描述", + "app.manual.field.preconditions": "前置条件", + "app.manual.field.priority": "优先级", + "app.manual.field.tags": "标签", + "app.manual.field.title": "标题", + "app.manual.field.expected": "预期结果", + "app.manual.field.steps": "步骤", + "app.manual.attachments.drop-hint": "将图片拖放到此处,或点击选择", + "app.manual.attachments.uploading": "上传中...", + "app.manual.attachments.upload-error": "无法上传附件。", + "app.manual.attachments.delete-error": "无法删除附件。它可能被某个已冻结版本引用。", + "app.manual.attachments.copy-reference": "复制 Markdown 引用", + "app.manual.attachments.reference-copied": "引用已复制到剪贴板。", + "app.manual.version-diff.identical": "这两个版本的内容完全相同。", + "app.manual.version-diff.field": "字段", + "app.manual.version-diff.before": "变更前", + "app.manual.version-diff.after": "变更后", + "app.manual.version-diff.empty": "(空)", + "app.manual.version-diff.step": "步骤 {number}", + "app.manual.custom-field.type.text": "文本", + "app.manual.custom-field.type.textarea": "长文本", + "app.manual.custom-field.type.number": "数字", + "app.manual.custom-field.type.date": "日期", + "app.manual.custom-field.type.boolean": "是 / 否", + "app.manual.custom-field.type.select": "单选", + "app.manual.custom-field.type.multiselect": "多选", + "app.manual.custom-field.type.user": "用户", + "app.manual.custom-field.scope.testcase": "测试用例", + "app.manual.custom-field.scope.execution": "执行", + "app.manual.custom-field.scope.both": "两者", + "app.manual.run-status.planned": "已计划", + "app.manual.run-status.in_progress": "进行中", + "app.manual.run-status.completed": "已完成", + "app.manual.run-status.cancelled": "已取消", + "app.manual.case-status.pass": "通过", + "app.manual.case-status.fail": "失败", + "app.manual.case-status.error": "错误", + "app.manual.case-status.skipped": "已跳过", + "app.manual.case-status.blocked": "被阻塞", + "app.manual.case-status.not_run": "未执行", + "app.manual.case-status.in_progress": "进行中", + "app.manual.step-status.not_run": "未执行", + "app.manual.step-status.pass": "通过", + "app.manual.step-status.fail": "失败", + "app.manual.step-status.blocked": "被阻塞", + "app.manual.step-status.skipped": "已跳过", + "nav.manual-testing": "手动测试", + "nav.manual-test-runs": "测试执行", + "nav.shared-steps": "共享步骤", + "nav.admin.custom-fields": "自定义字段", + "page.admin-custom-fields.title": "自定义字段", + "page.admin-custom-fields.help": "为该团队的每个手动测试用例显示的额外字段。仅当用例离开草稿状态时才强制填写必填字段。", + "page.admin-custom-fields.button.add": "添加字段", + "page.admin-custom-fields.archived": "已归档", + "page.admin-custom-fields.header.label": "标签", + "page.admin-custom-fields.header.key": "键", + "page.admin-custom-fields.header.type": "类型", + "page.admin-custom-fields.header.required": "必填", + "page.admin-custom-fields.header.applies-to": "适用于", + "page.admin-custom-fields.modal.add-title": "添加自定义字段", + "page.admin-custom-fields.modal.edit-title": "编辑自定义字段", + "page.admin-custom-fields.form.label": "标签", + "page.admin-custom-fields.form.key": "键", + "page.admin-custom-fields.form.key-help": "小写字母、数字和下划线。字段创建后无法更改。", + "page.admin-custom-fields.form.type": "类型", + "page.admin-custom-fields.form.options": "选项", + "page.admin-custom-fields.form.applies-to": "适用于", + "page.admin-custom-fields.form.order": "显示顺序", + "page.admin-custom-fields.form.required": "必填", + "page.admin-custom-fields.form.required-help": "仅当测试用例离开草稿状态时才强制填写。", + "page.admin-custom-fields.delete-confirm-title": "删除自定义字段", + "page.admin-custom-fields.delete-confirm-message": "如果仍有测试用例保存该字段的值,它将被归档而不是删除,以保证现有内容仍可读。", + "page.admin-custom-fields.toast.fetch-error": "无法加载自定义字段。", + "page.admin-custom-fields.toast.save-error": "无法保存自定义字段。", + "page.admin-custom-fields.toast.delete-error": "无法删除自定义字段。", + "page.admin-custom-fields.toast.archived": "该字段正在使用中,因此已被归档而非删除。", + "page.admin-custom-fields.toast.deleted": "自定义字段已删除。", + "page.shared-steps.title": "共享步骤", + "page.shared-steps.help": "可被多个测试用例引用的可复用步骤序列。编辑其中一个会为引用它的每个测试用例创建新版本。", + "page.shared-steps.button.add": "添加共享步骤", + "page.shared-steps.search-placeholder": "搜索共享步骤", + "page.shared-steps.header.name": "名称", + "page.shared-steps.header.description": "描述", + "page.shared-steps.header.steps": "步骤", + "page.shared-steps.header.version": "版本", + "page.shared-steps.action.usage": "使用情况", + "page.shared-steps.action.history": "历史记录", + "page.shared-steps.modal.add-title": "添加共享步骤", + "page.shared-steps.modal.edit-title": "编辑共享步骤", + "page.shared-steps.form.name": "名称", + "page.shared-steps.form.description": "描述", + "page.shared-steps.cascade-warning": "有 {count} 个测试用例引用了该共享步骤。保存将为每个用例创建新版本。", + "page.shared-steps.usage.title": "使用该共享步骤的测试用例", + "page.shared-steps.usage.empty": "暂无测试用例引用该共享步骤。", + "page.shared-steps.history.title": "共享步骤历史记录", + "page.shared-steps.delete-confirm-title": "删除共享步骤", + "page.shared-steps.delete-confirm-message": "仍被测试用例引用的共享步骤无法删除。", + "page.shared-steps.toast.fetch-error": "无法加载共享步骤。", + "page.shared-steps.toast.save-error": "无法保存共享步骤。", + "page.shared-steps.toast.delete-error": "无法删除共享步骤。", + "page.shared-steps.toast.usage-error": "无法加载使用情况列表。", + "page.shared-steps.toast.history-error": "无法加载历史记录。", + "page.shared-steps.toast.cascaded": "已为 {count} 个测试用例创建新版本。", + "page.manual-test-runs.title": "手动测试执行", + "page.manual-test-runs.button.add": "新建测试执行", + "page.manual-test-runs.button.create": "创建执行", + "page.manual-test-runs.filter.status": "状态", + "page.manual-test-runs.header.name": "名称", + "page.manual-test-runs.header.status": "状态", + "page.manual-test-runs.header.progress": "进度", + "page.manual-test-runs.header.environment": "环境", + "page.manual-test-runs.modal.title": "新建测试执行", + "page.manual-test-runs.form.name": "名称", + "page.manual-test-runs.form.environment": "环境", + "page.manual-test-runs.form.component": "组件", + "page.manual-test-runs.form.component-help": "一次执行只覆盖单个组件。无法添加其他组件的测试用例。", + "page.manual-test-runs.form.test-cases": "测试用例", + "page.manual-test-runs.form.test-cases-help": "每个用例都会绑定到其当前版本,之后的修改不会影响本次执行。", + "page.manual-test-runs.delete-confirm-title": "删除测试执行", + "page.manual-test-runs.delete-confirm-message": "这将删除该执行及其记录的结果。", + "page.manual-test-runs.toast.fetch-error": "无法加载测试执行。", + "page.manual-test-runs.toast.cases-error": "无法加载可用的测试用例。", + "page.manual-test-runs.toast.create-error": "无法创建测试执行。", + "page.manual-test-runs.toast.delete-error": "无法删除测试执行。", + "page.manual-test-run-detail.not-found": "未找到该测试执行。", + "page.manual-test-run-detail.button.start": "开始执行", + "page.manual-test-run-detail.button.complete": "完成执行", + "page.manual-test-run-detail.button.record": "记录结果", + "page.manual-test-run-detail.button.rebind": "更新到最新版本", + "page.manual-test-run-detail.case-result": "用例结果", + "page.manual-test-run-detail.actual-placeholder": "实际发生了什么?", + "page.manual-test-run-detail.notes-placeholder": "备注(可选)", + "page.manual-test-run-detail.view-build": "查看构建", + "page.manual-test-run-detail.view-version": "查看正在测试的版本", + "page.manual-test-run-detail.toast.fetch-error": "无法加载测试执行。", + "page.manual-test-run-detail.toast.version-error": "无法加载正在测试的版本。", + "page.manual-test-run-detail.toast.record-error": "无法记录结果。", + "page.manual-test-run-detail.toast.recorded": "结果已记录。", + "page.manual-test-run-detail.toast.rebind-error": "无法将该用例更新到最新版本。已有结果的用例无法更新。", + "page.manual-test-run-detail.toast.status-error": "无法更改执行状态。", + "page.manual-test-case-version.not-found": "未找到该版本。", + "page.manual-test-case-version.back-to-case": "返回测试用例", + "page.manual-test-case-version.frozen-notice": "这是一个已冻结的版本。它准确显示当时编写的内容,且永不改变。", + "page.manual-test-case-version.compare-with": "比较版本", + "page.manual-test-case-version.toast.fetch-error": "无法加载该版本。", + "page.manual-test-case-version.toast.compare-error": "无法加载用于比较的版本。" } diff --git a/src/translations/en.json b/src/translations/en.json index b9d1e7a..a38f589 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -600,5 +600,159 @@ "app.execution-type.automated": "Automated", "app.execution-type.manual": "Manual", "page.dashboard.filters.label.execution-type": "Type", - "page.metrics.filters.labels.execution-type": "Type" + "page.metrics.filters.labels.execution-type": "Type", + "app.manual.save": "Save", + "app.manual.delete": "Delete", + "app.manual.edit": "Edit", + "app.manual.actions": "Actions", + "app.manual.loading": "Loading...", + "app.manual.yes": "Yes", + "app.manual.no": "No", + "app.manual.no-team": "Select a team to continue.", + "app.manual.field.description": "Description", + "app.manual.field.preconditions": "Preconditions", + "app.manual.field.priority": "Priority", + "app.manual.field.tags": "Tags", + "app.manual.field.title": "Title", + "app.manual.field.expected": "Expected", + "app.manual.field.steps": "Steps", + "app.manual.attachments.drop-hint": "Drop an image here, or click to choose one", + "app.manual.attachments.uploading": "Uploading...", + "app.manual.attachments.upload-error": "Could not upload the attachment.", + "app.manual.attachments.delete-error": "Could not delete the attachment. It may be referenced by a frozen version.", + "app.manual.attachments.copy-reference": "Copy markdown reference", + "app.manual.attachments.reference-copied": "Reference copied to the clipboard.", + "app.manual.version-diff.identical": "These two versions have identical content.", + "app.manual.version-diff.field": "Field", + "app.manual.version-diff.before": "Before", + "app.manual.version-diff.after": "After", + "app.manual.version-diff.empty": "(empty)", + "app.manual.version-diff.step": "Step {number}", + "app.manual.custom-field.type.text": "Text", + "app.manual.custom-field.type.textarea": "Long text", + "app.manual.custom-field.type.number": "Number", + "app.manual.custom-field.type.date": "Date", + "app.manual.custom-field.type.boolean": "Yes / No", + "app.manual.custom-field.type.select": "Single select", + "app.manual.custom-field.type.multiselect": "Multi select", + "app.manual.custom-field.type.user": "User", + "app.manual.custom-field.scope.testcase": "Test cases", + "app.manual.custom-field.scope.execution": "Executions", + "app.manual.custom-field.scope.both": "Both", + "app.manual.run-status.planned": "Planned", + "app.manual.run-status.in_progress": "In progress", + "app.manual.run-status.completed": "Completed", + "app.manual.run-status.cancelled": "Cancelled", + "app.manual.case-status.pass": "Pass", + "app.manual.case-status.fail": "Fail", + "app.manual.case-status.error": "Error", + "app.manual.case-status.skipped": "Skipped", + "app.manual.case-status.blocked": "Blocked", + "app.manual.case-status.not_run": "Not run", + "app.manual.case-status.in_progress": "In progress", + "app.manual.step-status.not_run": "Not run", + "app.manual.step-status.pass": "Pass", + "app.manual.step-status.fail": "Fail", + "app.manual.step-status.blocked": "Blocked", + "app.manual.step-status.skipped": "Skipped", + "nav.manual-testing": "Manual testing", + "nav.manual-test-runs": "Test runs", + "nav.shared-steps": "Shared steps", + "nav.admin.custom-fields": "Custom fields", + "page.admin-custom-fields.title": "Custom fields", + "page.admin-custom-fields.help": "Extra fields shown on every manual test case for this team. Required fields are only enforced once a case leaves draft.", + "page.admin-custom-fields.button.add": "Add field", + "page.admin-custom-fields.archived": "Archived", + "page.admin-custom-fields.header.label": "Label", + "page.admin-custom-fields.header.key": "Key", + "page.admin-custom-fields.header.type": "Type", + "page.admin-custom-fields.header.required": "Required", + "page.admin-custom-fields.header.applies-to": "Applies to", + "page.admin-custom-fields.modal.add-title": "Add custom field", + "page.admin-custom-fields.modal.edit-title": "Edit custom field", + "page.admin-custom-fields.form.label": "Label", + "page.admin-custom-fields.form.key": "Key", + "page.admin-custom-fields.form.key-help": "Lowercase letters, numbers and underscores. Cannot be changed once the field is created.", + "page.admin-custom-fields.form.type": "Type", + "page.admin-custom-fields.form.options": "Options", + "page.admin-custom-fields.form.applies-to": "Applies to", + "page.admin-custom-fields.form.order": "Display order", + "page.admin-custom-fields.form.required": "Required", + "page.admin-custom-fields.form.required-help": "Only enforced when a test case leaves draft.", + "page.admin-custom-fields.delete-confirm-title": "Delete custom field", + "page.admin-custom-fields.delete-confirm-message": "If any test case still holds a value for this field it will be archived rather than deleted, so existing content stays readable.", + "page.admin-custom-fields.toast.fetch-error": "Could not load the custom fields.", + "page.admin-custom-fields.toast.save-error": "Could not save the custom field.", + "page.admin-custom-fields.toast.delete-error": "Could not delete the custom field.", + "page.admin-custom-fields.toast.archived": "The field is in use, so it was archived rather than deleted.", + "page.admin-custom-fields.toast.deleted": "Custom field deleted.", + "page.shared-steps.title": "Shared steps", + "page.shared-steps.help": "Re-usable step sequences that many test cases can include. Editing one creates a new version of every test case that includes it.", + "page.shared-steps.button.add": "Add shared step", + "page.shared-steps.search-placeholder": "Search shared steps", + "page.shared-steps.header.name": "Name", + "page.shared-steps.header.description": "Description", + "page.shared-steps.header.steps": "Steps", + "page.shared-steps.header.version": "Version", + "page.shared-steps.action.usage": "Usage", + "page.shared-steps.action.history": "History", + "page.shared-steps.modal.add-title": "Add shared step", + "page.shared-steps.modal.edit-title": "Edit shared step", + "page.shared-steps.form.name": "Name", + "page.shared-steps.form.description": "Description", + "page.shared-steps.cascade-warning": "{count} test cases include this shared step. Saving will create a new version of each of them.", + "page.shared-steps.usage.title": "Test cases using this shared step", + "page.shared-steps.usage.empty": "No test cases include this shared step yet.", + "page.shared-steps.history.title": "Shared step history", + "page.shared-steps.delete-confirm-title": "Delete shared step", + "page.shared-steps.delete-confirm-message": "A shared step that is still referenced by a test case cannot be deleted.", + "page.shared-steps.toast.fetch-error": "Could not load the shared steps.", + "page.shared-steps.toast.save-error": "Could not save the shared step.", + "page.shared-steps.toast.delete-error": "Could not delete the shared step.", + "page.shared-steps.toast.usage-error": "Could not load the usage list.", + "page.shared-steps.toast.history-error": "Could not load the history.", + "page.shared-steps.toast.cascaded": "{count} test cases were given a new version.", + "page.manual-test-runs.title": "Manual test runs", + "page.manual-test-runs.button.add": "New test run", + "page.manual-test-runs.button.create": "Create run", + "page.manual-test-runs.filter.status": "Status", + "page.manual-test-runs.header.name": "Name", + "page.manual-test-runs.header.status": "Status", + "page.manual-test-runs.header.progress": "Progress", + "page.manual-test-runs.header.environment": "Environment", + "page.manual-test-runs.modal.title": "New test run", + "page.manual-test-runs.form.name": "Name", + "page.manual-test-runs.form.environment": "Environment", + "page.manual-test-runs.form.component": "Component", + "page.manual-test-runs.form.component-help": "A run covers a single component. Test cases from another component cannot be added.", + "page.manual-test-runs.form.test-cases": "Test cases", + "page.manual-test-runs.form.test-cases-help": "Each case is bound to its current version, so later edits will not change this run.", + "page.manual-test-runs.delete-confirm-title": "Delete test run", + "page.manual-test-runs.delete-confirm-message": "This removes the run and the results recorded against it.", + "page.manual-test-runs.toast.fetch-error": "Could not load the test runs.", + "page.manual-test-runs.toast.cases-error": "Could not load the available test cases.", + "page.manual-test-runs.toast.create-error": "Could not create the test run.", + "page.manual-test-runs.toast.delete-error": "Could not delete the test run.", + "page.manual-test-run-detail.not-found": "That test run could not be found.", + "page.manual-test-run-detail.button.start": "Start run", + "page.manual-test-run-detail.button.complete": "Complete run", + "page.manual-test-run-detail.button.record": "Record result", + "page.manual-test-run-detail.button.rebind": "Move to latest version", + "page.manual-test-run-detail.case-result": "Case result", + "page.manual-test-run-detail.actual-placeholder": "What actually happened?", + "page.manual-test-run-detail.notes-placeholder": "Notes (optional)", + "page.manual-test-run-detail.view-build": "View build", + "page.manual-test-run-detail.view-version": "View the version being tested", + "page.manual-test-run-detail.toast.fetch-error": "Could not load the test run.", + "page.manual-test-run-detail.toast.version-error": "Could not load the version being tested.", + "page.manual-test-run-detail.toast.record-error": "Could not record the result.", + "page.manual-test-run-detail.toast.recorded": "Result recorded.", + "page.manual-test-run-detail.toast.rebind-error": "Could not move this case to the latest version. A case that already has a result cannot be moved.", + "page.manual-test-run-detail.toast.status-error": "Could not change the run status.", + "page.manual-test-case-version.not-found": "That version could not be found.", + "page.manual-test-case-version.back-to-case": "Back to the test case", + "page.manual-test-case-version.frozen-notice": "This is a frozen version. It shows exactly what was authored at the time and never changes.", + "page.manual-test-case-version.compare-with": "Compare with", + "page.manual-test-case-version.toast.fetch-error": "Could not load that version.", + "page.manual-test-case-version.toast.compare-error": "Could not load the version to compare against." } diff --git a/src/translations/nl.json b/src/translations/nl.json index 340f452..344cddf 100644 --- a/src/translations/nl.json +++ b/src/translations/nl.json @@ -600,5 +600,159 @@ "app.execution-type.automated": "Geautomatiseerd", "app.execution-type.manual": "Handmatig", "page.dashboard.filters.label.execution-type": "Type", - "page.metrics.filters.labels.execution-type": "Type" + "page.metrics.filters.labels.execution-type": "Type", + "app.manual.save": "Opslaan", + "app.manual.delete": "Verwijderen", + "app.manual.edit": "Bewerken", + "app.manual.actions": "Acties", + "app.manual.loading": "Laden...", + "app.manual.yes": "Ja", + "app.manual.no": "Nee", + "app.manual.no-team": "Selecteer een team om door te gaan.", + "app.manual.field.description": "Omschrijving", + "app.manual.field.preconditions": "Voorwaarden", + "app.manual.field.priority": "Prioriteit", + "app.manual.field.tags": "Tags", + "app.manual.field.title": "Titel", + "app.manual.field.expected": "Verwacht", + "app.manual.field.steps": "Stappen", + "app.manual.attachments.drop-hint": "Sleep een afbeelding hierheen of klik om er een te kiezen", + "app.manual.attachments.uploading": "Uploaden...", + "app.manual.attachments.upload-error": "Kon de bijlage niet uploaden.", + "app.manual.attachments.delete-error": "Kon de bijlage niet verwijderen. Mogelijk wordt deze gebruikt door een bevroren versie.", + "app.manual.attachments.copy-reference": "Markdown-verwijzing kopiëren", + "app.manual.attachments.reference-copied": "Verwijzing gekopieerd naar het klembord.", + "app.manual.version-diff.identical": "Deze twee versies hebben identieke inhoud.", + "app.manual.version-diff.field": "Veld", + "app.manual.version-diff.before": "Voor", + "app.manual.version-diff.after": "Na", + "app.manual.version-diff.empty": "(leeg)", + "app.manual.version-diff.step": "Stap {number}", + "app.manual.custom-field.type.text": "Tekst", + "app.manual.custom-field.type.textarea": "Lange tekst", + "app.manual.custom-field.type.number": "Getal", + "app.manual.custom-field.type.date": "Datum", + "app.manual.custom-field.type.boolean": "Ja / Nee", + "app.manual.custom-field.type.select": "Enkele keuze", + "app.manual.custom-field.type.multiselect": "Meerdere keuzes", + "app.manual.custom-field.type.user": "Gebruiker", + "app.manual.custom-field.scope.testcase": "Testgevallen", + "app.manual.custom-field.scope.execution": "Uitvoeringen", + "app.manual.custom-field.scope.both": "Beide", + "app.manual.run-status.planned": "Gepland", + "app.manual.run-status.in_progress": "Bezig", + "app.manual.run-status.completed": "Voltooid", + "app.manual.run-status.cancelled": "Geannuleerd", + "app.manual.case-status.pass": "Geslaagd", + "app.manual.case-status.fail": "Mislukt", + "app.manual.case-status.error": "Fout", + "app.manual.case-status.skipped": "Overgeslagen", + "app.manual.case-status.blocked": "Geblokkeerd", + "app.manual.case-status.not_run": "Niet uitgevoerd", + "app.manual.case-status.in_progress": "Bezig", + "app.manual.step-status.not_run": "Niet uitgevoerd", + "app.manual.step-status.pass": "Geslaagd", + "app.manual.step-status.fail": "Mislukt", + "app.manual.step-status.blocked": "Geblokkeerd", + "app.manual.step-status.skipped": "Overgeslagen", + "nav.manual-testing": "Handmatig testen", + "nav.manual-test-runs": "Testruns", + "nav.shared-steps": "Gedeelde stappen", + "nav.admin.custom-fields": "Aangepaste velden", + "page.admin-custom-fields.title": "Aangepaste velden", + "page.admin-custom-fields.help": "Extra velden die op elk handmatig testgeval van dit team worden getoond. Verplichte velden gelden pas zodra een testgeval de conceptstatus verlaat.", + "page.admin-custom-fields.button.add": "Veld toevoegen", + "page.admin-custom-fields.archived": "Gearchiveerd", + "page.admin-custom-fields.header.label": "Label", + "page.admin-custom-fields.header.key": "Sleutel", + "page.admin-custom-fields.header.type": "Type", + "page.admin-custom-fields.header.required": "Verplicht", + "page.admin-custom-fields.header.applies-to": "Van toepassing op", + "page.admin-custom-fields.modal.add-title": "Aangepast veld toevoegen", + "page.admin-custom-fields.modal.edit-title": "Aangepast veld bewerken", + "page.admin-custom-fields.form.label": "Label", + "page.admin-custom-fields.form.key": "Sleutel", + "page.admin-custom-fields.form.key-help": "Kleine letters, cijfers en underscores. Kan niet worden gewijzigd nadat het veld is aangemaakt.", + "page.admin-custom-fields.form.type": "Type", + "page.admin-custom-fields.form.options": "Opties", + "page.admin-custom-fields.form.applies-to": "Van toepassing op", + "page.admin-custom-fields.form.order": "Weergavevolgorde", + "page.admin-custom-fields.form.required": "Verplicht", + "page.admin-custom-fields.form.required-help": "Geldt alleen zodra een testgeval de conceptstatus verlaat.", + "page.admin-custom-fields.delete-confirm-title": "Aangepast veld verwijderen", + "page.admin-custom-fields.delete-confirm-message": "Als een testgeval nog een waarde voor dit veld heeft, wordt het gearchiveerd in plaats van verwijderd zodat bestaande inhoud leesbaar blijft.", + "page.admin-custom-fields.toast.fetch-error": "Kon de aangepaste velden niet laden.", + "page.admin-custom-fields.toast.save-error": "Kon het aangepaste veld niet opslaan.", + "page.admin-custom-fields.toast.delete-error": "Kon het aangepaste veld niet verwijderen.", + "page.admin-custom-fields.toast.archived": "Het veld is in gebruik en is daarom gearchiveerd in plaats van verwijderd.", + "page.admin-custom-fields.toast.deleted": "Aangepast veld verwijderd.", + "page.shared-steps.title": "Gedeelde stappen", + "page.shared-steps.help": "Herbruikbare stappenreeksen die door meerdere testgevallen kunnen worden opgenomen. Bij bewerking krijgt elk testgeval dat ze bevat een nieuwe versie.", + "page.shared-steps.button.add": "Gedeelde stap toevoegen", + "page.shared-steps.search-placeholder": "Zoek gedeelde stappen", + "page.shared-steps.header.name": "Naam", + "page.shared-steps.header.description": "Omschrijving", + "page.shared-steps.header.steps": "Stappen", + "page.shared-steps.header.version": "Versie", + "page.shared-steps.action.usage": "Gebruik", + "page.shared-steps.action.history": "Geschiedenis", + "page.shared-steps.modal.add-title": "Gedeelde stap toevoegen", + "page.shared-steps.modal.edit-title": "Gedeelde stap bewerken", + "page.shared-steps.form.name": "Naam", + "page.shared-steps.form.description": "Omschrijving", + "page.shared-steps.cascade-warning": "{count} testgevallen bevatten deze gedeelde stap. Opslaan maakt van elk daarvan een nieuwe versie.", + "page.shared-steps.usage.title": "Testgevallen die deze gedeelde stap gebruiken", + "page.shared-steps.usage.empty": "Nog geen testgevallen bevatten deze gedeelde stap.", + "page.shared-steps.history.title": "Geschiedenis van gedeelde stap", + "page.shared-steps.delete-confirm-title": "Gedeelde stap verwijderen", + "page.shared-steps.delete-confirm-message": "Een gedeelde stap waarnaar nog wordt verwezen door een testgeval kan niet worden verwijderd.", + "page.shared-steps.toast.fetch-error": "Kon de gedeelde stappen niet laden.", + "page.shared-steps.toast.save-error": "Kon de gedeelde stap niet opslaan.", + "page.shared-steps.toast.delete-error": "Kon de gedeelde stap niet verwijderen.", + "page.shared-steps.toast.usage-error": "Kon de gebruikslijst niet laden.", + "page.shared-steps.toast.history-error": "Kon de geschiedenis niet laden.", + "page.shared-steps.toast.cascaded": "{count} testgevallen hebben een nieuwe versie gekregen.", + "page.manual-test-runs.title": "Handmatige testruns", + "page.manual-test-runs.button.add": "Nieuwe testrun", + "page.manual-test-runs.button.create": "Run aanmaken", + "page.manual-test-runs.filter.status": "Status", + "page.manual-test-runs.header.name": "Naam", + "page.manual-test-runs.header.status": "Status", + "page.manual-test-runs.header.progress": "Voortgang", + "page.manual-test-runs.header.environment": "Omgeving", + "page.manual-test-runs.modal.title": "Nieuwe testrun", + "page.manual-test-runs.form.name": "Naam", + "page.manual-test-runs.form.environment": "Omgeving", + "page.manual-test-runs.form.component": "Component", + "page.manual-test-runs.form.component-help": "Een run dekt één component. Testgevallen van een ander component kunnen niet worden toegevoegd.", + "page.manual-test-runs.form.test-cases": "Testgevallen", + "page.manual-test-runs.form.test-cases-help": "Elk testgeval wordt aan de huidige versie gekoppeld, zodat latere wijzigingen deze run niet veranderen.", + "page.manual-test-runs.delete-confirm-title": "Testrun verwijderen", + "page.manual-test-runs.delete-confirm-message": "Hiermee worden de run en de daarin vastgelegde resultaten verwijderd.", + "page.manual-test-runs.toast.fetch-error": "Kon de testruns niet laden.", + "page.manual-test-runs.toast.cases-error": "Kon de beschikbare testgevallen niet laden.", + "page.manual-test-runs.toast.create-error": "Kon de testrun niet aanmaken.", + "page.manual-test-runs.toast.delete-error": "Kon de testrun niet verwijderen.", + "page.manual-test-run-detail.not-found": "Die testrun kon niet worden gevonden.", + "page.manual-test-run-detail.button.start": "Run starten", + "page.manual-test-run-detail.button.complete": "Run afronden", + "page.manual-test-run-detail.button.record": "Resultaat vastleggen", + "page.manual-test-run-detail.button.rebind": "Naar nieuwste versie", + "page.manual-test-run-detail.case-result": "Resultaat testgeval", + "page.manual-test-run-detail.actual-placeholder": "Wat gebeurde er werkelijk?", + "page.manual-test-run-detail.notes-placeholder": "Notities (optioneel)", + "page.manual-test-run-detail.view-build": "Build bekijken", + "page.manual-test-run-detail.view-version": "Bekijk de geteste versie", + "page.manual-test-run-detail.toast.fetch-error": "Kon de testrun niet laden.", + "page.manual-test-run-detail.toast.version-error": "Kon de geteste versie niet laden.", + "page.manual-test-run-detail.toast.record-error": "Kon het resultaat niet vastleggen.", + "page.manual-test-run-detail.toast.recorded": "Resultaat vastgelegd.", + "page.manual-test-run-detail.toast.rebind-error": "Kon dit testgeval niet naar de nieuwste versie verplaatsen. Een testgeval met een resultaat kan niet worden verplaatst.", + "page.manual-test-run-detail.toast.status-error": "Kon de status van de run niet wijzigen.", + "page.manual-test-case-version.not-found": "Die versie kon niet worden gevonden.", + "page.manual-test-case-version.back-to-case": "Terug naar het testgeval", + "page.manual-test-case-version.frozen-notice": "Dit is een bevroren versie. Deze toont exact wat destijds is opgesteld en verandert nooit.", + "page.manual-test-case-version.compare-with": "Vergelijk met", + "page.manual-test-case-version.toast.fetch-error": "Kon die versie niet laden.", + "page.manual-test-case-version.toast.compare-error": "Kon de vergelijkingsversie niet laden." } diff --git a/src/translations/th.json b/src/translations/th.json index 572abdd..f1fe57f 100644 --- a/src/translations/th.json +++ b/src/translations/th.json @@ -600,5 +600,159 @@ "app.execution-type.automated": "อัตโนมัติ", "app.execution-type.manual": "ด้วยตนเอง", "page.dashboard.filters.label.execution-type": "ประเภท", - "page.metrics.filters.labels.execution-type": "ประเภท" + "page.metrics.filters.labels.execution-type": "ประเภท", + "app.manual.save": "บันทึก", + "app.manual.delete": "ลบ", + "app.manual.edit": "แก้ไข", + "app.manual.actions": "การดำเนินการ", + "app.manual.loading": "กำลังโหลด...", + "app.manual.yes": "ใช่", + "app.manual.no": "ไม่", + "app.manual.no-team": "เลือกทีมเพื่อดำเนินการต่อ", + "app.manual.field.description": "คำอธิบาย", + "app.manual.field.preconditions": "เงื่อนไขเบื้องต้น", + "app.manual.field.priority": "ความสำคัญ", + "app.manual.field.tags": "แท็ก", + "app.manual.field.title": "ชื่อเรื่อง", + "app.manual.field.expected": "ผลที่คาดหวัง", + "app.manual.field.steps": "ขั้นตอน", + "app.manual.attachments.drop-hint": "วางรูปภาพที่นี่ หรือคลิกเพื่อเลือก", + "app.manual.attachments.uploading": "กำลังอัปโหลด...", + "app.manual.attachments.upload-error": "ไม่สามารถอัปโหลดไฟล์แนบได้", + "app.manual.attachments.delete-error": "ไม่สามารถลบไฟล์แนบได้ อาจถูกอ้างอิงโดยเวอร์ชันที่ถูกตรึงไว้", + "app.manual.attachments.copy-reference": "คัดลอกการอ้างอิง Markdown", + "app.manual.attachments.reference-copied": "คัดลอกการอ้างอิงไปยังคลิปบอร์ดแล้ว", + "app.manual.version-diff.identical": "สองเวอร์ชันนี้มีเนื้อหาเหมือนกัน", + "app.manual.version-diff.field": "ฟิลด์", + "app.manual.version-diff.before": "ก่อน", + "app.manual.version-diff.after": "หลัง", + "app.manual.version-diff.empty": "(ว่าง)", + "app.manual.version-diff.step": "ขั้นตอนที่ {number}", + "app.manual.custom-field.type.text": "ข้อความ", + "app.manual.custom-field.type.textarea": "ข้อความยาว", + "app.manual.custom-field.type.number": "ตัวเลข", + "app.manual.custom-field.type.date": "วันที่", + "app.manual.custom-field.type.boolean": "ใช่ / ไม่", + "app.manual.custom-field.type.select": "เลือกได้หนึ่งรายการ", + "app.manual.custom-field.type.multiselect": "เลือกได้หลายรายการ", + "app.manual.custom-field.type.user": "ผู้ใช้", + "app.manual.custom-field.scope.testcase": "เคสทดสอบ", + "app.manual.custom-field.scope.execution": "การดำเนินการทดสอบ", + "app.manual.custom-field.scope.both": "ทั้งสอง", + "app.manual.run-status.planned": "วางแผนไว้", + "app.manual.run-status.in_progress": "กำลังดำเนินการ", + "app.manual.run-status.completed": "เสร็จสิ้น", + "app.manual.run-status.cancelled": "ยกเลิก", + "app.manual.case-status.pass": "ผ่าน", + "app.manual.case-status.fail": "ไม่ผ่าน", + "app.manual.case-status.error": "ข้อผิดพลาด", + "app.manual.case-status.skipped": "ข้าม", + "app.manual.case-status.blocked": "ถูกบล็อก", + "app.manual.case-status.not_run": "ยังไม่ได้ทดสอบ", + "app.manual.case-status.in_progress": "กำลังดำเนินการ", + "app.manual.step-status.not_run": "ยังไม่ได้ทดสอบ", + "app.manual.step-status.pass": "ผ่าน", + "app.manual.step-status.fail": "ไม่ผ่าน", + "app.manual.step-status.blocked": "ถูกบล็อก", + "app.manual.step-status.skipped": "ข้าม", + "nav.manual-testing": "การทดสอบด้วยตนเอง", + "nav.manual-test-runs": "รอบการทดสอบ", + "nav.shared-steps": "ขั้นตอนที่ใช้ร่วมกัน", + "nav.admin.custom-fields": "ฟิลด์ที่กำหนดเอง", + "page.admin-custom-fields.title": "ฟิลด์ที่กำหนดเอง", + "page.admin-custom-fields.help": "ฟิลด์เพิ่มเติมที่แสดงในทุกเคสทดสอบด้วยตนเองของทีมนี้ ฟิลด์ที่จำเป็นจะบังคับใช้เมื่อเคสออกจากสถานะแบบร่างเท่านั้น", + "page.admin-custom-fields.button.add": "เพิ่มฟิลด์", + "page.admin-custom-fields.archived": "เก็บถาวรแล้ว", + "page.admin-custom-fields.header.label": "ป้ายกำกับ", + "page.admin-custom-fields.header.key": "คีย์", + "page.admin-custom-fields.header.type": "ประเภท", + "page.admin-custom-fields.header.required": "จำเป็น", + "page.admin-custom-fields.header.applies-to": "ใช้กับ", + "page.admin-custom-fields.modal.add-title": "เพิ่มฟิลด์ที่กำหนดเอง", + "page.admin-custom-fields.modal.edit-title": "แก้ไขฟิลด์ที่กำหนดเอง", + "page.admin-custom-fields.form.label": "ป้ายกำกับ", + "page.admin-custom-fields.form.key": "คีย์", + "page.admin-custom-fields.form.key-help": "ตัวพิมพ์เล็ก ตัวเลข และขีดล่าง ไม่สามารถเปลี่ยนได้หลังจากสร้างฟิลด์แล้ว", + "page.admin-custom-fields.form.type": "ประเภท", + "page.admin-custom-fields.form.options": "ตัวเลือก", + "page.admin-custom-fields.form.applies-to": "ใช้กับ", + "page.admin-custom-fields.form.order": "ลำดับการแสดง", + "page.admin-custom-fields.form.required": "จำเป็น", + "page.admin-custom-fields.form.required-help": "บังคับใช้เมื่อเคสทดสอบออกจากสถานะแบบร่างเท่านั้น", + "page.admin-custom-fields.delete-confirm-title": "ลบฟิลด์ที่กำหนดเอง", + "page.admin-custom-fields.delete-confirm-message": "หากยังมีเคสทดสอบที่เก็บค่าของฟิลด์นี้ ระบบจะเก็บถาวรแทนการลบ เพื่อให้เนื้อหาเดิมยังอ่านได้", + "page.admin-custom-fields.toast.fetch-error": "ไม่สามารถโหลดฟิลด์ที่กำหนดเองได้", + "page.admin-custom-fields.toast.save-error": "ไม่สามารถบันทึกฟิลด์ที่กำหนดเองได้", + "page.admin-custom-fields.toast.delete-error": "ไม่สามารถลบฟิลด์ที่กำหนดเองได้", + "page.admin-custom-fields.toast.archived": "ฟิลด์นี้กำลังใช้งานอยู่ จึงถูกเก็บถาวรแทนการลบ", + "page.admin-custom-fields.toast.deleted": "ลบฟิลด์ที่กำหนดเองแล้ว", + "page.shared-steps.title": "ขั้นตอนที่ใช้ร่วมกัน", + "page.shared-steps.help": "ลำดับขั้นตอนที่นำกลับมาใช้ได้ ซึ่งเคสทดสอบหลายรายการสามารถรวมไว้ได้ การแก้ไขจะสร้างเวอร์ชันใหม่ของทุกเคสที่รวมขั้นตอนนี้", + "page.shared-steps.button.add": "เพิ่มขั้นตอนที่ใช้ร่วมกัน", + "page.shared-steps.search-placeholder": "ค้นหาขั้นตอนที่ใช้ร่วมกัน", + "page.shared-steps.header.name": "ชื่อ", + "page.shared-steps.header.description": "คำอธิบาย", + "page.shared-steps.header.steps": "ขั้นตอน", + "page.shared-steps.header.version": "เวอร์ชัน", + "page.shared-steps.action.usage": "การใช้งาน", + "page.shared-steps.action.history": "ประวัติ", + "page.shared-steps.modal.add-title": "เพิ่มขั้นตอนที่ใช้ร่วมกัน", + "page.shared-steps.modal.edit-title": "แก้ไขขั้นตอนที่ใช้ร่วมกัน", + "page.shared-steps.form.name": "ชื่อ", + "page.shared-steps.form.description": "คำอธิบาย", + "page.shared-steps.cascade-warning": "มี {count} เคสทดสอบที่รวมขั้นตอนนี้ การบันทึกจะสร้างเวอร์ชันใหม่ของแต่ละเคส", + "page.shared-steps.usage.title": "เคสทดสอบที่ใช้ขั้นตอนนี้", + "page.shared-steps.usage.empty": "ยังไม่มีเคสทดสอบที่รวมขั้นตอนนี้", + "page.shared-steps.history.title": "ประวัติขั้นตอนที่ใช้ร่วมกัน", + "page.shared-steps.delete-confirm-title": "ลบขั้นตอนที่ใช้ร่วมกัน", + "page.shared-steps.delete-confirm-message": "ไม่สามารถลบขั้นตอนที่ยังถูกอ้างอิงโดยเคสทดสอบได้", + "page.shared-steps.toast.fetch-error": "ไม่สามารถโหลดขั้นตอนที่ใช้ร่วมกันได้", + "page.shared-steps.toast.save-error": "ไม่สามารถบันทึกขั้นตอนที่ใช้ร่วมกันได้", + "page.shared-steps.toast.delete-error": "ไม่สามารถลบขั้นตอนที่ใช้ร่วมกันได้", + "page.shared-steps.toast.usage-error": "ไม่สามารถโหลดรายการการใช้งานได้", + "page.shared-steps.toast.history-error": "ไม่สามารถโหลดประวัติได้", + "page.shared-steps.toast.cascaded": "สร้างเวอร์ชันใหม่ให้ {count} เคสทดสอบแล้ว", + "page.manual-test-runs.title": "รอบการทดสอบด้วยตนเอง", + "page.manual-test-runs.button.add": "สร้างรอบทดสอบใหม่", + "page.manual-test-runs.button.create": "สร้างรอบทดสอบ", + "page.manual-test-runs.filter.status": "สถานะ", + "page.manual-test-runs.header.name": "ชื่อ", + "page.manual-test-runs.header.status": "สถานะ", + "page.manual-test-runs.header.progress": "ความคืบหน้า", + "page.manual-test-runs.header.environment": "สภาพแวดล้อม", + "page.manual-test-runs.modal.title": "สร้างรอบทดสอบใหม่", + "page.manual-test-runs.form.name": "ชื่อ", + "page.manual-test-runs.form.environment": "สภาพแวดล้อม", + "page.manual-test-runs.form.component": "คอมโพเนนต์", + "page.manual-test-runs.form.component-help": "หนึ่งรอบทดสอบครอบคลุมหนึ่งคอมโพเนนต์ ไม่สามารถเพิ่มเคสจากคอมโพเนนต์อื่นได้", + "page.manual-test-runs.form.test-cases": "เคสทดสอบ", + "page.manual-test-runs.form.test-cases-help": "แต่ละเคสจะผูกกับเวอร์ชันปัจจุบัน การแก้ไขภายหลังจะไม่เปลี่ยนรอบทดสอบนี้", + "page.manual-test-runs.delete-confirm-title": "ลบรอบการทดสอบ", + "page.manual-test-runs.delete-confirm-message": "การดำเนินการนี้จะลบรอบทดสอบและผลลัพธ์ที่บันทึกไว้", + "page.manual-test-runs.toast.fetch-error": "ไม่สามารถโหลดรอบการทดสอบได้", + "page.manual-test-runs.toast.cases-error": "ไม่สามารถโหลดเคสทดสอบที่ใช้ได้", + "page.manual-test-runs.toast.create-error": "ไม่สามารถสร้างรอบการทดสอบได้", + "page.manual-test-runs.toast.delete-error": "ไม่สามารถลบรอบการทดสอบได้", + "page.manual-test-run-detail.not-found": "ไม่พบรอบการทดสอบนี้", + "page.manual-test-run-detail.button.start": "เริ่มรอบทดสอบ", + "page.manual-test-run-detail.button.complete": "จบรอบทดสอบ", + "page.manual-test-run-detail.button.record": "บันทึกผล", + "page.manual-test-run-detail.button.rebind": "ย้ายไปเวอร์ชันล่าสุด", + "page.manual-test-run-detail.case-result": "ผลของเคส", + "page.manual-test-run-detail.actual-placeholder": "เกิดอะไรขึ้นจริง?", + "page.manual-test-run-detail.notes-placeholder": "หมายเหตุ (ไม่บังคับ)", + "page.manual-test-run-detail.view-build": "ดูบิลด์", + "page.manual-test-run-detail.view-version": "ดูเวอร์ชันที่กำลังทดสอบ", + "page.manual-test-run-detail.toast.fetch-error": "ไม่สามารถโหลดรอบการทดสอบได้", + "page.manual-test-run-detail.toast.version-error": "ไม่สามารถโหลดเวอร์ชันที่กำลังทดสอบได้", + "page.manual-test-run-detail.toast.record-error": "ไม่สามารถบันทึกผลได้", + "page.manual-test-run-detail.toast.recorded": "บันทึกผลแล้ว", + "page.manual-test-run-detail.toast.rebind-error": "ไม่สามารถย้ายเคสนี้ไปเวอร์ชันล่าสุด เคสที่มีผลแล้วไม่สามารถย้ายได้", + "page.manual-test-run-detail.toast.status-error": "ไม่สามารถเปลี่ยนสถานะรอบทดสอบได้", + "page.manual-test-case-version.not-found": "ไม่พบเวอร์ชันนี้", + "page.manual-test-case-version.back-to-case": "กลับไปที่เคสทดสอบ", + "page.manual-test-case-version.frozen-notice": "นี่คือเวอร์ชันที่ถูกตรึงไว้ แสดงเนื้อหาตามที่เขียนไว้ ณ ขณะนั้น และจะไม่เปลี่ยนแปลง", + "page.manual-test-case-version.compare-with": "เปรียบเทียบกับ", + "page.manual-test-case-version.toast.fetch-error": "ไม่สามารถโหลดเวอร์ชันนี้ได้", + "page.manual-test-case-version.toast.compare-error": "ไม่สามารถโหลดเวอร์ชันสำหรับเปรียบเทียบได้" } From 45da6bc5d7761fb10f730cdb9d30fc0c8121fc4d Mon Sep 17 00:00:00 2001 From: Sergio Neves Barros Date: Sun, 23 Aug 2026 17:31:30 +0100 Subject: [PATCH 04/37] fix(manual): import ALL_EXECUTION_TYPES into the dashboard and metrics pages Both pages read ALL_EXECUTION_TYPES to map "no filter" back to the picker's value, but neither imported it - the extraction into GeneralUtilities removed the local const and only carried over the two functions. The dashboard is the home page, so it threw ReferenceError on load and the page never rendered. Not caught earlier because next build resolves an unknown identifier as a runtime global rather than failing, and the route still returns 200 - the error surfaces only when the client bundle executes in the browser. Co-Authored-By: Claude Opus 5 --- src/components/pages/dashboard/DashboardPage.js | 2 +- src/components/pages/metrics/MetricsPage.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/pages/dashboard/DashboardPage.js b/src/components/pages/dashboard/DashboardPage.js index fe415b3..1c66d91 100644 --- a/src/components/pages/dashboard/DashboardPage.js +++ b/src/components/pages/dashboard/DashboardPage.js @@ -43,7 +43,7 @@ import { getDateRangesPicker, getDurationAsString } from '../../../utility/TimeU import ExecutionBarChart from './charts/ExecutionBarChart'; import BuildExecutionPieChart from './charts/BuildExecutionPieChart'; import ConfirmModal from '../../common/ConfirmModal'; -import { getExecutionTypeOptions, toExecutionTypeParam } from '../../../utility/GeneralUtilities'; +import { ALL_EXECUTION_TYPES, getExecutionTypeOptions, toExecutionTypeParam } from '../../../utility/GeneralUtilities'; const generateFilterMenuData = function (environments, components) { const data = []; diff --git a/src/components/pages/metrics/MetricsPage.js b/src/components/pages/metrics/MetricsPage.js index 4462cae..6945622 100644 --- a/src/components/pages/metrics/MetricsPage.js +++ b/src/components/pages/metrics/MetricsPage.js @@ -29,7 +29,7 @@ import { getPaletteColor, getPlatformLabel } from '../../../utility/ChartConfig' import { getDateRangesPicker } from '../../../utility/TimeUtilities'; import ExecutionMetricsResultsBarChart from './charts/ExecutionMetricsResultsBarChart'; import PhaseMetricsResultsBarChart from './charts/PhaseMetricsResultsBarChart'; -import { getExecutionTypeOptions, toExecutionTypeParam } from '../../../utility/GeneralUtilities'; +import { ALL_EXECUTION_TYPES, getExecutionTypeOptions, toExecutionTypeParam } from '../../../utility/GeneralUtilities'; const MetricsPage = function (props) { const router = useRouter(); From 15b1e69016685d567a64bea35ad685ae8649d152 Mon Sep 17 00:00:00 2001 From: Sergio Neves Barros Date: Sun, 23 Aug 2026 17:47:58 +0100 Subject: [PATCH 05/37] fix(manual): do not send an empty action for a shared step inclusion The step editor holds action: '' for the disabled input on a shared step row, and the draft was sent to the API verbatim. An inclusion has no action of its own, so the empty string is now stripped from the payload rather than sent as content. The API rejected it, which is what surfaced as the duplicate key error when saving a test case that included a shared step. Co-Authored-By: Claude Opus 5 --- .../manual-test-cases/ManualTestCaseDetailPage.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/pages/manual-test-cases/ManualTestCaseDetailPage.js b/src/components/pages/manual-test-cases/ManualTestCaseDetailPage.js index ec354ae..30cebd3 100644 --- a/src/components/pages/manual-test-cases/ManualTestCaseDetailPage.js +++ b/src/components/pages/manual-test-cases/ManualTestCaseDetailPage.js @@ -113,10 +113,22 @@ function ManualTestCaseDetailPage(props) { loadHistory(); }, [activeTab, caseId]); + // A shared step inclusion has no action of its own - the shared step's contents are + // expanded in its place - so the empty string the editor holds for the disabled input + // is stripped rather than sent as content. + const toPayload = (current) => ({ + ...current, + steps: (current.steps || []).map((step) => { + if (!('sharedStep' in step)) return step; + const { action, expected, ...rest } = step; + return action ? { ...rest, action, expected } : rest; + }), + }); + const handleSave = async () => { setSaving(true); try { - const saved = await manualTestCaseRequests.updateTestCase(caseId, draft); + const saved = await manualTestCaseRequests.updateTestCase(caseId, toPayload(draft)); setTestCase(saved); // The version may or may not have moved - only a content change burns one - so // the list is refetched rather than assumed. From bc0206d411ac58bbfc71041d3f349a633b931c31 Mon Sep 17 00:00:00 2001 From: Sergio Neves Barros Date: Sun, 23 Aug 2026 17:57:25 +0100 Subject: [PATCH 06/37] feat(manual): show a clear result when a test case is saved Saving gave only a transient corner toast, rendered without an icon and not dismissible - easy to miss entirely, and it could not say what the save actually did. - An inline banner beside the Save button reports the outcome and stays until the next edit, alongside the toast rather than replacing it. - Success distinguishes the two real outcomes: "Saved as version 4" versus "Saved. The content did not change, so no new version was created." Both are successes but they mean different things to whoever reads the history later, and only a content change burns a version. - Failure shows the API's own message - which names the offending step or custom field - instead of a generic "could not save". - Editing any field clears the banner, so it never describes a state the form no longer reflects. Shared steps and custom fields had no success feedback at all; both now confirm, and the shared step save reports a cascade count when editing re-versioned other test cases. All 12 manual-feature toasts are now showIcon + closable, matching what the Message component needs to be visible and dismissible. Adds .app-alert-success to the shared alert set rather than a page-level rule. Co-Authored-By: Claude Opus 5 --- .../attachment-upload/AttachmentUpload.js | 4 +- .../AdminCustomFieldsPage.js | 14 +++- .../ManualTestCaseVersionPage.js | 2 +- .../ManualTestCaseDetailPage.js | 70 ++++++++++++++++--- .../manual-test-cases/ManualTestCasesPage.js | 4 +- .../ManualTestRunDetailPage.js | 4 +- .../manual-test-runs/ManualTestRunsPage.js | 2 +- .../pages/shared-steps/SharedStepsPage.js | 31 ++++---- src/styles/components.less | 4 ++ src/translations/cn.json | 9 ++- src/translations/en.json | 9 ++- src/translations/nl.json | 9 ++- src/translations/th.json | 9 ++- 13 files changed, 130 insertions(+), 41 deletions(-) diff --git a/src/components/features/attachment-upload/AttachmentUpload.js b/src/components/features/attachment-upload/AttachmentUpload.js index c25112a..d81d9d2 100644 --- a/src/components/features/attachment-upload/AttachmentUpload.js +++ b/src/components/features/attachment-upload/AttachmentUpload.js @@ -24,7 +24,7 @@ const AttachmentUpload = ({ owner, attachments, onChange, readOnly }) => { const pushError = (error, fallbackId) => { toaster.push( - + {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))} , { placement: 'topEnd' }, @@ -61,7 +61,7 @@ const AttachmentUpload = ({ owner, attachments, onChange, readOnly }) => { if (navigator.clipboard) { navigator.clipboard.writeText(reference); toaster.push( - + , { placement: 'topEnd' }, diff --git a/src/components/pages/admin-custom-fields/AdminCustomFieldsPage.js b/src/components/pages/admin-custom-fields/AdminCustomFieldsPage.js index 901c23e..e3e4871 100644 --- a/src/components/pages/admin-custom-fields/AdminCustomFieldsPage.js +++ b/src/components/pages/admin-custom-fields/AdminCustomFieldsPage.js @@ -36,7 +36,7 @@ const AdminCustomFieldsPage = function (props) { const pushError = (error, fallbackId) => { toaster.push( - + {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))} , { placement: 'topEnd' }, @@ -92,6 +92,16 @@ const AdminCustomFieldsPage = function (props) { ...payload, team: currentTeam._id, }); } + toaster.push( + + {intl.formatMessage({ + id: editing._id + ? 'page.admin-custom-fields.toast.updated' + : 'page.admin-custom-fields.toast.created', + })} + , + { placement: 'topEnd' }, + ); setEditing(undefined); loadDefinitions(); } catch (error) { @@ -108,7 +118,7 @@ const AdminCustomFieldsPage = function (props) { try { const response = await customFieldRequests.deleteCustomField(definition._id); toaster.push( - + { toaster.push( - + {getApiErrorMessage(error, intl.formatMessage({ id: fallbackId }))} , { placement: 'topEnd' }, diff --git a/src/components/pages/manual-test-cases/ManualTestCaseDetailPage.js b/src/components/pages/manual-test-cases/ManualTestCaseDetailPage.js index 30cebd3..d1a27f0 100644 --- a/src/components/pages/manual-test-cases/ManualTestCaseDetailPage.js +++ b/src/components/pages/manual-test-cases/ManualTestCaseDetailPage.js @@ -35,6 +35,10 @@ function ManualTestCaseDetailPage(props) { const [activeTab, setActiveTab] = useState('details'); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + // Inline save feedback. A toast alone is easy to miss - it is a small transient corner + // popup - and it cannot say whether the save burned a version, which is the thing an + // author most needs to know about a manual test case. + const [saveResult, setSaveResult] = useState(undefined); const [historyLoading, setHistoryLoading] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); @@ -127,6 +131,8 @@ function ManualTestCaseDetailPage(props) { const handleSave = async () => { setSaving(true); + setSaveResult(undefined); + const previousVersion = testCase ? testCase.version : undefined; try { const saved = await manualTestCaseRequests.updateTestCase(caseId, toPayload(draft)); setTestCase(saved); @@ -135,19 +141,50 @@ function ManualTestCaseDetailPage(props) { const versionResponse = await manualTestCaseRequests.getVersions(caseId); setVersions(versionResponse.versions || []); setHistory([]); + + // Saying which of the two happened matters: "saved as v4" and "saved, still v3" + // are both successes, but they mean different things to whoever reads the + // history or runs a test against this case later. + const versionBurned = saved.version !== previousVersion; + const message = versionBurned + ? intl.formatMessage( + { id: 'page.manual-test-case-detail.toast.saved-new-version' }, + { version: saved.version }, + ) + : intl.formatMessage({ id: 'page.manual-test-case-detail.toast.saved-no-change' }); + setSaveResult({ type: 'success', message }); toaster.push( - - {intl.formatMessage({ id: 'page.manual-test-case-detail.toast.saved' })} + + {message} , { placement: 'topEnd' }, ); } catch (error) { - pushError(error, 'page.manual-test-case-detail.toast.save-error'); + // The API's own message is far more useful than a generic failure - it names + // the offending custom field, the missing shared step, or the invalid value. + const message = getApiErrorMessage( + error, + intl.formatMessage({ id: 'page.manual-test-case-detail.toast.save-error' }), + ); + setSaveResult({ type: 'error', message }); + toaster.push( + + {message} + , + { placement: 'topEnd' }, + ); } finally { setSaving(false); } }; + // Any further edit makes the previous save result stale, so it is dismissed rather + // than left sitting above a form that has since changed. + const updateDraft = (changes) => { + setSaveResult(undefined); + setDraft((current) => ({ ...current, ...changes })); + }; + const handleClone = async () => { try { const clone = await manualTestCaseRequests.cloneTestCase(caseId); @@ -217,6 +254,17 @@ function ManualTestCaseDetailPage(props) { + {saveResult && ( +
+ {saveResult.message} +
+ )} +