Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ interface HighlightedInputProps {
title?: string;
testId?: string;
multiline?: boolean;
/** Forwarded key handler, invoked only when the autocomplete dropdown is not open (so it
* can drive e.g. Enter-to-send without stealing Enter from suggestion selection). */
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
}

interface HoveredToken {
Expand Down Expand Up @@ -75,7 +78,8 @@ export const HighlightedInput: React.FC<HighlightedInputProps> = ({
variablesAutocomplete = true,
title,
testId,
multiline = false
multiline = false,
onKeyDown
}) => {
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
const mirrorRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -253,7 +257,10 @@ export const HighlightedInput: React.FC<HighlightedInputProps> = ({
};

const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (!autocomplete) return;
if (!autocomplete) {
onKeyDown?.(event);
return;
}
const { items, active } = autocomplete;
switch (event.key) {
case 'ArrowDown':
Expand Down Expand Up @@ -378,7 +385,7 @@ export const HighlightedInput: React.FC<HighlightedInputProps> = ({
visibility: hoverPos ? 'visible' : 'hidden'
}}
>
<VariableInfoCard name={hovered.name} />
<VariableInfoCard name={hovered.name} editable />
</HoverCard>
</Portal>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const BodyTab: React.FC<BodyTabProps> = ({
: 'text'
}
height={fillHeight ? '100%' : '300px'}
variableAware
testId="body-editor"
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
import type { HttpRequest } from '@opencollection/types/requests/http';
import { StyledWrapper } from './StyledWrapper';
import MenuDropdown from '../../../../../../ui/MenuDropdown';
import HighlightedInput from '../../../../../HighlightedInput/HighlightedInput';
import { useResolvedVariables } from '../../../../../../hooks/useVariableResolver';
import { getHttpMethod, getRequestUrl, getHttpParams } from '../../../../../../utils/schemaHelpers';
import { syncPathParams, syncQueryParams } from '../../../../../../utils/pathParams';
import { availableMethods } from '../../../../../../theme/methodColors';
Expand All @@ -17,6 +19,7 @@ interface QueryBarProps {
}

const QueryBar: React.FC<QueryBarProps> = ({ item, onSendRequest, isLoading, onItemChange }) => {
const { isFound, names } = useResolvedVariables();
const [url, setUrl] = useState(getRequestUrl(item));
const [method, setMethod] = useState(getHttpMethod(item));

Expand Down Expand Up @@ -74,12 +77,14 @@ const QueryBar: React.FC<QueryBarProps> = ({ item, onSendRequest, isLoading, onI
</button>
</MenuDropdown>

<input
type="text"
<HighlightedInput
value={url}
onChange={(e) => handleUrlChange(e.target.value)}
onValueChange={handleUrlChange}
isFound={isFound}
names={names}
placeholder="Enter request URL"
onKeyPress={(e) => {
testId="query-bar-url"
onKeyDown={(e) => {
if (e.key === 'Enter' && url.trim() && !isLoading) {
onSendRequest();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,28 @@ export const StyledWrapper = styled.div`
border-radius: var(--oc-radius);
background-color: var(--bg-primary);

input {
flex: 1;
/* The URL field is a HighlightedInput (wrapper div + painted mirror), not a bare input.
Stretch the wrapper across the row and give it the compact mono look of the query bar.
!important is required: HighlightedInput's own StyledWrapper is inserted after this one
(child renders after parent) and would otherwise win at equal specificity, leaving the
URL bar too tall with the cell font size. */
.highlight-input {
flex: 1 !important;
min-width: 0;
outline: none;
border: none;
border-radius: 0;
background-color: transparent;
padding: 0 !important;
font-family: var(--font-mono);
font-weight: 400;
font-size: 0.75rem;
line-height: 1.125rem;
color: var(--text-primary);
}

&::placeholder {
color: var(--text-secondary);
opacity: 0.6;
}
.highlight-input .text-input,
.highlight-input .highlight-input-mirror {
padding: 0 !important;
font-size: 0.75rem !important;
line-height: 1.125rem !important;
}

.highlight-input .highlight-input-mirror {
left: 0 !important;
right: 0 !important;
}

.method-select {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ export const StyledWrapper = styled.div`

.var-value-container {
position: relative;
height: 2.25rem;
min-height: 2.25rem;
max-height: 11.125rem; /* ~9 lines, matching the desktop app; scrolls past this */
padding: 0.5rem;
overflow-y: auto;
overflow-x: hidden;
Expand All @@ -81,6 +82,39 @@ export const StyledWrapper = styled.div`
color: var(--text-tertiary);
}

.var-value-editable {
cursor: text;
}

/* While editing, ring the value box in the brand colour (matching the desktop app). */
.var-value-container:focus-within {
outline: 0.0625rem solid var(--oc-brand);
outline-offset: -0.0625rem;
}

.var-value-edit {
display: block;
width: 100%;
margin: 0;
padding: 0;
font-family: var(--font-sans);
font-size: 0.8125rem;
font-weight: 400;
line-height: 1.25rem;
color: var(--text-primary);
background: transparent;
border: none;
resize: none;
white-space: pre-wrap;
word-break: break-all;
}

/* The container shows the brand ring; keep the textarea itself ringless. */
.var-value-edit:focus,
.var-value-edit:focus-visible {
outline: none;
}

.var-icons {
position: absolute;
top: 50%;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,47 @@ describe('VariableInfoCard', () => {
expect(part(root, 'note').text).toBe('Variable is not defined');
});
});

const editableCardTree = (name: string) => {
const store = createOpenCollectionStore();
store.dispatch(setDocsCollection(collection));
store.dispatch(setActiveEnv('Dev'));
return (
<Provider store={store}>
<VariableResolverProvider>
<VariableInfoCard name={name} editable />
</VariableResolverProvider>
</Provider>
);
};

describe('VariableInfoCard (editable)', () => {
it('renders an editable value for an environment variable', () => {
const value = part(useRenderToDom(editableCardTree('host')), 'value');
expect(value.getAttribute('role')).toBe('button');
expect(value.classList.contains('var-value-editable')).toBe(true);
});

it('renders an editable value for a collection variable', () => {
const value = part(useRenderToDom(editableCardTree('apiVersion')), 'value');
expect(value.classList.contains('var-value-editable')).toBe(true);
});

it('stays read-only by default (docs surfaces do not pass editable)', () => {
const value = part(useRenderToDom(cardTree('host')), 'value');
expect(value.classList.contains('var-value-editable')).toBe(false);
expect(value.getAttribute('role')).toBeFalsy();
});

it('never makes a secret variable editable', () => {
const value = part(useRenderToDom(editableCardTree('bearer_token')), 'value');
expect(value.text).toBe('(Secret)');
expect(value.classList.contains('var-value-editable')).toBe(false);
});

it('never makes a read-only scope (process.env) editable', () => {
const root = useRenderToDom(editableCardTree('process.env.HOME'));
expect(part(root, 'note').text).toBe('read-only');
expect(part(root, 'value').classList.contains('var-value-editable')).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import React from 'react';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useResolvedVariables } from '../../hooks';
import { CopyButton } from '../../ui/CopyButton/CopyButton';
import { SCOPE_LABELS, INVALID_NAME_WARNING } from '../../constants';
import type { VariableScope } from '../../utils/variableResolution';
import { StyledWrapper } from './StyledWrapper';

// Scopes whose values can be inline-edited (mirrors desktop; process.env/runtime/dynamic/
// oauth2/$secrets stay read-only). Global has no playground store, so it is read-only too.
const EDITABLE_SCOPES = new Set<VariableScope>(['environment', 'collection', 'folder', 'request']);

interface VariableInfoCardProps {
name: string;
/** Allow inline-editing the value (playground surfaces only; docs stay read-only). */
editable?: boolean;
testId?: string;
}

Expand All @@ -16,9 +22,62 @@ const getReadOnlyNote = (scope: VariableScope, activeEnvName: string | null): st
return null;
};

export const VariableInfoCard: React.FC<VariableInfoCardProps> = ({ name, testId = 'variable-info-card' }) => {
const { lookup, activeEnvName } = useResolvedVariables();
export const VariableInfoCard: React.FC<VariableInfoCardProps> = ({
name,
editable = false,
testId = 'variable-info-card'
}) => {
const { lookup, activeEnvName, updateVariable } = useResolvedVariables();
const info = lookup(name);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const editRef = useRef<HTMLTextAreaElement>(null);

// Grow the edit field with its content (the container caps it and scrolls past ~9 lines).
useLayoutEffect(() => {
const el = editRef.current;
if (!editing || !el) return;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, [editing, draft]);

// Editable for concrete scopes (env/collection/folder/request); read-only scopes and
// secrets stay read-only. Environment edits need an active environment to target.
const canEdit =
editable &&
info.valid &&
!info.secret &&
EDITABLE_SCOPES.has(info.scope) &&
// Only plain-string values are inline-editable; editing a typed value (object/number/etc)
// as text would drop its `{ type, data }` shape, so those stay read-only.
(info.dataType === undefined || info.dataType === 'string') &&
(info.scope !== 'environment' || !!activeEnvName);

// Leave edit mode when the hovered token changes (the card instance is reused across tokens).
useEffect(() => {
setEditing(false);
}, [name]);

const startEditing = () => {
// Edit the raw stored value (which may contain `{{refs}}`), not the deep-resolved display value.
setDraft(info.rawValue);
setEditing(true);
};

const commit = () => {
setEditing(false);
if (draft !== info.rawValue) updateVariable(info.name, draft);
};

const handleEditKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
commit();
} else if (event.key === 'Escape') {
event.preventDefault();
setEditing(false);
}
};

const header = (
<div className="var-info-header">
Expand Down Expand Up @@ -66,7 +125,19 @@ export const VariableInfoCard: React.FC<VariableInfoCardProps> = ({ name, testId
}

const readOnlyNote = getReadOnlyNote(info.scope, activeEnvName);
const placeholder = info.secret ? '(Secret)' : info.value === '' ? '(empty)' : null;
const placeholder = info.secret ? '(Secret)' : !canEdit && info.value === '' ? '(empty)' : null;

const copyIcon = (
<div className="var-icons">
<CopyButton
text={info.value}
label="Copy value"
resetAfterMs={1000}
className="copy-button"
testId={`${testId}-copy`}
/>
</div>
);

return (
<StyledWrapper className="variable-info-card" data-testid={testId}>
Expand All @@ -76,20 +147,49 @@ export const VariableInfoCard: React.FC<VariableInfoCardProps> = ({ name, testId
<div className="var-value-display var-value-placeholder" data-testid={`${testId}-value`}>
{placeholder}
</div>
) : canEdit ? (
editing ? (
<textarea
ref={editRef}
className="var-value-edit"
data-testid={`${testId}-edit`}
value={draft}
autoFocus
rows={1}
spellCheck={false}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={commit}
/>
) : (
<>
<div
className="var-value-display var-value-editable"
data-testid={`${testId}-value`}
role="button"
tabIndex={0}
title="Click to edit"
// mousedown + preventDefault so the display never grabs focus/selection (which
// flashes a focus ring) before it swaps to the textarea. Keyboard uses onKeyDown.
onMouseDown={(event) => {
event.preventDefault();
startEditing();
}}
onKeyDown={(event) => {
if (event.key === 'Enter') startEditing();
}}
>
{info.value === '' ? '(empty)' : info.value}
</div>
{copyIcon}
</>
)
) : (
<>
<div className="var-value-display" data-testid={`${testId}-value`}>
{info.value}
</div>
<div className="var-icons">
<CopyButton
text={info.value}
label="Copy value"
resetAfterMs={1000}
className="copy-button"
testId={`${testId}-copy`}
/>
</div>
{copyIcon}
</>
)}
</div>
Expand Down
Loading
Loading